--BounceTelemetry script in ServerScriptService --Sends the four bounce-funnel events to the analytics server. -- --This is a SERVER ModuleScript, not a LocalScript. join and session_end must be --fired server-side: client telemetry loses exactly the sessions you care about --(the client died, errored, or was killed before it could flush). -- --Setup: -- 1. Paste this file into ServerScriptService as a ModuleScript named -- BounceTelemetry. -- 2. Set CONFIG.apiKey to the server's INGEST_KEY. -- 3. Enable HTTP requests (Game Settings > Security) and allowlist the -- endpoint's domain for this place in the Creator Dashboard. -- 4. Call BounceTelemetry.tutorialStart / firstExtraction from the real -- first-run path. join and session_end are wired here already. -- 5. Publish, then check the dashboard. local HttpService = game:GetService("HttpService") local Players = game:GetService("Players") local BounceTelemetry = {} local CONFIG = { endpoint = "https://analytics.shortscake.com/v1/events", apiKey = "REPLACE_WITH_INGEST_KEY", flushSeconds = 10, batchSize = 40, maxQueue = 400, debug = false, } local sessions = {} local queue = {} local warned = false local sequence = 0 local function log(...) if CONFIG.debug then print("[bounce]", ...) end end local function now() -- Wall clock that survives pauses and matches os.time() semantics. return workspace:GetServerTimeNow() end local function placeVersion() return tostring(game.PlaceVersion) end -- Queue one event. Every event carries the session and place identity, so a -- batch can be replayed after a failed POST without losing attribution. local function enqueue(player, name, fields) local session = sessions[player] if not session then return end sequence += 1 local event = { event = name, event_id = string.format("%s-%d", session.id, sequence), session_id = session.id, user_id = player.UserId, ts = os.time(), place_version = placeVersion(), } for key, value in pairs(fields or {}) do event[key] = value end queue[#queue + 1] = event -- A hard cap keeps a dead endpoint from growing memory without bound. while #queue > CONFIG.maxQueue do table.remove(queue, 1) end if #queue >= CONFIG.batchSize then task.spawn(BounceTelemetry.flush) end end function BounceTelemetry.flush() if #queue == 0 then return end local batch = {} local count = math.min(#queue, CONFIG.batchSize) for index = 1, count do batch[index] = table.remove(queue, 1) end local payload = HttpService:JSONEncode({ events = batch }) local ok, response = pcall(function() return HttpService:PostAsync(CONFIG.endpoint, payload, Enum.HttpContentType.ApplicationJson, false, { ["X-Api-Key"] = CONFIG.apiKey, }) end) if ok then log("flushed", #batch, response) return end -- Put the batch back in front so the next flush keeps event order. for index = #batch, 1, -1 do table.insert(queue, 1, batch[index]) end if not warned then warned = true warn("[bounce] telemetry post failed; will retry: " .. tostring(response)) end end -- Call when a tutorial step is actually shown or taken. function BounceTelemetry.tutorialStart(player, step) local session = sessions[player] if not session then return end session.lastStep = step enqueue(player, "tutorial_start", { step = step, seconds_since_join = now() - session.t0, }) end -- Call at each boundary between the tutorial and the extraction, e.g. -- "opened_first_chest", "found_extract", "reached_exit". This is what turns the -- funnel into a step-by-step one; first reach per name wins. function BounceTelemetry.step(player, name) local session = sessions[player] if not session then return end enqueue(player, "step", { step = name, seconds_since_join = now() - session.t0, }) end -- Call when the extraction COMMITS on the server, not when the client asks for it. function BounceTelemetry.firstExtraction(player, method) local session = sessions[player] if not session or session.extracted then return end session.extracted = true enqueue(player, "first_extraction", { seconds_since_join = now() - session.t0, method = method, }) end -- Call whenever the player reaches a meaningful new step, for session_end.last_step. function BounceTelemetry.setLastStep(player, step) local session = sessions[player] if session then session.lastStep = step end end local function onPlayerAdded(player) if sessions[player] then return end sessions[player] = { id = string.format("%d-%d", player.UserId, os.time()), t0 = now(), extracted = false, lastStep = nil, } enqueue(player, "join", {}) log("join", sessions[player].id) end local function onPlayerRemoving(player) local session = sessions[player] if not session then return end enqueue(player, "session_end", { seconds_played = now() - session.t0, extracted = session.extracted == true, last_step = session.lastStep, }) sessions[player] = nil task.spawn(BounceTelemetry.flush) end local configured = CONFIG.endpoint ~= "https://analytics.shortscake.com/v1/events" and CONFIG.apiKey ~= "REPLACE_WITH_INGEST_KEY" if not configured then warn("[bounce] telemetry disabled: set CONFIG.endpoint and CONFIG.apiKey in BounceTelemetry") return BounceTelemetry end Players.PlayerAdded:Connect(onPlayerAdded) Players.PlayerRemoving:Connect(onPlayerRemoving) for _, player in ipairs(Players:GetPlayers()) do onPlayerAdded(player) end task.spawn(function() while true do task.wait(CONFIG.flushSeconds) BounceTelemetry.flush() end end) game:BindToClose(function() for player in pairs(sessions) do onPlayerRemoving(player) end local deadline = os.clock() + 5 while #queue > 0 and os.clock() < deadline do BounceTelemetry.flush() task.wait() end end) return BounceTelemetry