--[[
	RoSession · live analytics for Roblox games · https://rosession.com/docs

	Put this ModuleScript in ServerStorage, turn on Game Settings > Security >
	"Allow HTTP Requests", then from a server Script:

		local RoSession = require(game.ServerStorage.RoSession)
		RoSession.start({ key = "rs_live_…" })

	That records every session (join -> leave, and why they left), servers,
	FPS, ping, devices, errors and purchase prompts. Optional, for more detail:

		RoSession.event(player, "BossDefeated", 42, { boss = "Golem" })
		RoSession.funnel(player, "Onboarding", 3, "First pizza")
		RoSession.currency(player, "Coins", 250, "QuestReward")   -- negative = spent
		MarketplaceService.ProcessReceipt = RoSession.receipts(handler)

		RoSession.profile(player, { level = 5, tutorialStep = 2 })  -- 0 = tutorial done
		RoSession.loaded(player)               -- their save finished loading
		RoSession.activity(player, "Fishing")   -- what they're doing (nil to clear)
		RoSession.task(player, "Quest: Find the key") -- something in progress (nil when done)
		RoSession.crumb(player, "Opened the map")     -- breadcrumb only
		RoSession.kick(player, "Exploiting")    -- kick, and record why

	Events are batched and sent every 10 seconds. Nothing is sent from the client:
	the RoSessionClient LocalScript inside this module reports device, FPS, idle
	time, the open menu and client errors to the server over a RemoteEvent.
]]

local HttpService = game:GetService("HttpService")
local LocalizationService = game:GetService("LocalizationService")
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local ScriptContext = game:GetService("ScriptContext")
local StarterPlayer = game:GetService("StarterPlayer")
local Stats = game:GetService("Stats")

local RoSession = {}
RoSession.VERSION = "1.1.0"

local DEFAULT_URL = "https://rosession.com/api/v1/ingest"
local FLUSH_SECONDS = 10
local TICK_SECONDS = 30
local MAX_BUFFER = 3000 -- events kept while RoSession can't be reached
local MAX_BATCH = 800
local MAX_CRUMBS = 15
local CLIENT_TICK_MIN = 8 -- seconds between client reports we accept
local CLIENT_ERRORS_PER_MIN = 6
local SERVER_ERRORS_PER_MIN = 30

local IS_STUDIO = RunService:IsStudio()

local options = {}
local started = false
local enabled = false
local buffer = {}
local sessions = {} -- [player] = session
local closing = false
local failures = 0
local sending = false
local usesLoaded = false -- the game calls RoSession.loaded, so "never loaded" means something
local usesCurrency = false
local loggedReceipts = {}
local productNames = {}

local server = {
	id = game.JobId ~= "" and game.JobId or ("studio-" .. HttpService:GenerateGUID(false)),
	placeId = game.PlaceId,
	placeVersion = game.PlaceVersion,
	studio = IS_STUDIO,
	private = game.PrivateServerId ~= "",
	maxPlayers = Players.MaxPlayers,
	started = DateTime.now().UnixTimestampMillis,
	sdk = RoSession.VERSION,
}

local function now()
	return DateTime.now().UnixTimestampMillis
end

local function log(...)
	if options.debug then
		print("[RoSession]", ...)
	end
end

local function short(v, n)
	if v == nil then
		return nil
	end
	return string.sub(tostring(v), 1, n or 60)
end

-- finite numbers only (JSONEncode can't take NaN / inf)
local function number(v)
	if type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge then
		return v
	end
	return nil
end

local function push(e)
	if not enabled then
		return
	end
	e.t = e.t or now()
	if #buffer >= MAX_BUFFER then
		table.remove(buffer, 1) -- drop the oldest
	end
	table.insert(buffer, e)
end

--------------------------------------------------
-- sending
--------------------------------------------------

local function apiKey()
	if options.secret then
		local ok, secret = pcall(HttpService.GetSecret, HttpService, options.secret)
		if ok then
			return secret
		end
		warn("[RoSession] couldn't read the secret '" .. tostring(options.secret) .. "': " .. tostring(secret))
	end
	return options.key or ""
end

local warnedHttp = false

local function flush()
	if not enabled or sending or #buffer == 0 then
		return
	end
	sending = true
	local count = math.min(#buffer, MAX_BATCH)
	local batch = table.move(buffer, 1, count, 1, {})
	local okEncode, body = pcall(HttpService.JSONEncode, HttpService, { server = server, events = batch })
	if not okEncode then
		warn("[RoSession] dropped a batch that couldn't be encoded:", body)
		for _ = 1, count do
			table.remove(buffer, 1)
		end
		sending = false
		return
	end
	local ok, res = pcall(HttpService.RequestAsync, HttpService, {
		Url = options.url or DEFAULT_URL,
		Method = "POST",
		Headers = { ["Content-Type"] = "application/json", ["x-api-key"] = apiKey() },
		Body = body,
	})
	if ok and res.Success then
		for _ = 1, count do
			table.remove(buffer, 1)
		end
		failures = 0
		log("sent", count, "events")
	else
		failures += 1
		local message = ok and (tostring(res.StatusCode) .. " " .. tostring(res.Body)) or tostring(res)
		if not ok and string.find(message, "Http requests are not enabled") then
			if not warnedHttp then
				warnedHttp = true
				warn("[RoSession] turn on Game Settings > Security > Allow HTTP Requests, then restart the server.")
			end
		elseif failures == 1 or failures % 30 == 0 then
			warn("[RoSession] send failed:", message)
		end
		-- RoSession will never take these (bad key / bad batch): don't retry forever
		if ok and (res.StatusCode == 400 or res.StatusCode == 401 or res.StatusCode == 413) then
			for _ = 1, count do
				table.remove(buffer, 1)
			end
		end
	end
	sending = false
end

--------------------------------------------------
-- breadcrumbs
--------------------------------------------------

local function crumb(s, text)
	table.insert(s.Crumbs, { os.clock(), short(text, 60) })
	if #s.Crumbs > MAX_CRUMBS then
		table.remove(s.Crumbs, 1)
	end
end

-- crumbs that read better than the raw event name
local function label(name, f1, f2)
	if name == "Funnel" then
		return tostring(f2 or f1)
	elseif name == "PanelOpened" then
		return "Opened " .. tostring(f1)
	elseif name == "Purchase" then
		return "Bought " .. tostring(f1)
	elseif name == "PromptClosed" then
		return tostring(f1) .. " prompt → " .. tostring(f2)
	end
	return f1 ~= nil and (name .. " · " .. tostring(f1)) or name
end

local NO_CRUMB = { Economy = true }

--------------------------------------------------
-- public API
--------------------------------------------------

-- fields: { "a", "b", "c" } or { key = value, ... } (up to 3 used for breakdowns)
function RoSession.event(player, name, value, fields)
	if type(name) ~= "string" or name == "" then
		warn("[RoSession] event needs a name")
		return
	end
	local s = player and sessions[player]
	local e = {
		n = short(name, 60),
		u = player and player.UserId or nil,
		s = s and s.Id or nil,
		v = number(value),
	}
	if type(fields) == "table" then
		if #fields > 0 then
			e.f = { short(fields[1]), short(fields[2]), short(fields[3]) }
		else
			local keys = {}
			for k in pairs(fields) do
				table.insert(keys, tostring(k))
			end
			table.sort(keys)
			local data = { _keys = {} }
			e.f = {}
			for i, k in ipairs(keys) do
				local v = fields[k]
				data[k] = type(v) == "number" and number(v) or short(v, 120)
				if i <= 3 then
					e.f[i] = short(v)
					data._keys[i] = short(k, 30)
				end
			end
			e.d = data
		end
	end
	push(e)
	if s and not NO_CRUMB[e.n] then
		crumb(s, label(e.n, e.f and e.f[1], e.f and e.f[2]))
	end
end

function RoSession.funnel(player, funnelName, step, stepName)
	if type(funnelName) ~= "string" or type(step) ~= "number" then
		warn("[RoSession] funnel(player, funnelName, step, stepName)")
		return
	end
	RoSession.event(player, "Funnel", math.floor(step), { funnelName, stepName or ("Step " .. step) })
end

-- FNV-1a of a string, so a player lands in the same variant in every server and session.
-- The multiply by the FNV prime (2^24 + 403) is split so it stays exact in a double.
local function hash(text)
	local h = 2166136261
	for i = 1, #text do
		h = bit32.bxor(h, string.byte(text, i))
		h = (bit32.lshift(h, 24) + h * 403) % 4294967296
	end
	return h
end

-- A/B test: picks this player's variant and logs it once per session. variants is a list
-- ({ "Classic", "Bundles" }, equal split, the first is the control) or weights
-- ({ Classic = 80, Bundles = 20 }, the biggest weight is the control). Returns the name.
function RoSession.variant(player, testName, variants)
	if type(testName) ~= "string" or testName == "" or type(variants) ~= "table" then
		warn("[RoSession] variant(player, testName, { \"A\", \"B\" })")
		return nil
	end
	local list = {}
	if #variants > 0 then
		for _, name in ipairs(variants) do
			table.insert(list, { name = tostring(name), weight = 1 })
		end
	else
		for name, weight in pairs(variants) do
			if type(weight) == "number" and weight > 0 then
				table.insert(list, { name = tostring(name), weight = weight })
			end
		end
		table.sort(list, function(a, b)
			if a.weight ~= b.weight then
				return a.weight > b.weight
			end
			return a.name < b.name
		end)
	end
	if #list == 0 then
		warn("[RoSession] variant needs at least one variant")
		return nil
	end
	local total = 0
	for _, v in ipairs(list) do
		total += v.weight
	end
	local userId = player and player.UserId or 0
	local roll = hash(tostring(userId) .. ":" .. testName) / 4294967296 * total
	local index, pick = #list, list[#list].name
	for i, v in ipairs(list) do
		roll -= v.weight
		if roll < 0 then
			index, pick = i, v.name
			break
		end
	end
	local s = player and sessions[player]
	if s then
		s.Tests = s.Tests or {}
		if s.Tests[testName] == pick then
			return pick
		end
		s.Tests[testName] = pick
	end
	RoSession.event(player, "Experiment", index - 1, { testName, pick })
	return pick
end

-- amount > 0 = earned (a source), < 0 = spent (a sink)
function RoSession.currency(player, currency, amount, source)
	amount = number(amount)
	if not amount or amount == 0 or type(currency) ~= "string" then
		return
	end
	usesCurrency = true
	local s = player and sessions[player]
	if s then
		if amount > 0 then
			s.Earned += amount
			s.LastEarned = os.clock()
		else
			s.Spent -= amount
		end
	end
	RoSession.event(player, "Economy", math.abs(amount), { amount > 0 and "Source" or "Sink", currency, short(source or "Unknown") })
end

-- { level, tutorialStep (0 = done), cash, isNew } - any you have
function RoSession.profile(player, info)
	local s = sessions[player]
	if not s or type(info) ~= "table" then
		return
	end
	if number(info.level) then
		s.Level = math.floor(info.level)
	end
	if number(info.tutorialStep) then
		s.Tutorial = math.floor(info.tutorialStep)
	end
	if number(info.cash) then
		s.Cash = info.cash
	end
	push({
		n = "session_meta",
		u = player.UserId,
		s = s.Id,
		d = { level = s.Level, tutorialStep = s.Tutorial, cash = s.Cash, isNew = if type(info.isNew) == "boolean" then info.isNew else nil },
	})
end

function RoSession.loaded(player)
	usesLoaded = true
	local s = sessions[player]
	if s and not s.Loaded then
		s.Loaded = true
		push({ n = "session_meta", u = player.UserId, s = s.Id, d = { loaded = true } })
		crumb(s, "Data loaded")
	end
end

function RoSession.activity(player, name)
	if typeof(player) == "Instance" and player:IsA("Player") then
		player:SetAttribute("RoSessionActivity", name and short(name, 30) or nil)
	end
end

function RoSession.task(player, description)
	local s = sessions[player]
	if s then
		s.Task = description and short(description, 80) or nil
		if description then
			crumb(s, description)
		end
	end
end

function RoSession.crumb(player, text)
	local s = player and sessions[player]
	if s then
		crumb(s, text)
	end
end

function RoSession.kick(player, reason)
	local s = sessions[player]
	if s then
		s.Kicked = short(reason or "Kicked", 200)
	end
	player:Kick(reason)
end

-- MarketplaceService.ProcessReceipt = RoSession.receipts(function(receipt) ... end)
function RoSession.receipts(handler)
	assert(type(handler) == "function", "RoSession.receipts(handler) needs your ProcessReceipt function")
	return function(receipt)
		local result = handler(receipt)
		if result == Enum.ProductPurchaseDecision.PurchaseGranted and not loggedReceipts[receipt.PurchaseId] then
			loggedReceipts[receipt.PurchaseId] = true
			local player = Players:GetPlayerByUserId(receipt.PlayerId)
			task.spawn(function()
				local id = receipt.ProductId
				if productNames[id] == nil then
					local ok, info = pcall(MarketplaceService.GetProductInfo, MarketplaceService, id, Enum.InfoType.Product)
					productNames[id] = ok and info and info.Name or tostring(id)
				end
				local s = player and sessions[player]
				push({
					n = "Purchase",
					u = receipt.PlayerId,
					s = s and s.Id or nil,
					v = receipt.CurrencySpent,
					f = { short(productNames[id]), s and s.Level and ("Lv " .. s.Level) or nil },
				})
				if s then
					crumb(s, "Bought " .. tostring(productNames[id]))
				end
			end)
		end
		return result
	end
end

--------------------------------------------------
-- sessions
--------------------------------------------------

local function joinSource(player)
	if player.FollowUserId and player.FollowUserId ~= 0 then
		return "Followed friend"
	end
	local ok, join = pcall(player.GetJoinData, player)
	if ok and join then
		if join.ReferredByPlayerId and join.ReferredByPlayerId ~= 0 then
			return "Invite link"
		elseif join.SourcePlaceId and join.SourcePlaceId ~= 0 and join.SourcePlaceId ~= game.PlaceId then
			return "Teleport"
		elseif join.LaunchData and join.LaunchData ~= "" then
			return "Link with launch data"
		end
	end
	return "Home / Search"
end

local function startSession(player)
	if sessions[player] then
		return
	end
	local s = {
		Id = HttpService:GenerateGUID(false),
		Joined = os.clock(),
		Crumbs = {},
		Earned = 0,
		Spent = 0,
		LastEarned = os.clock(),
		Errors = 0,
		Client = {},
		LastClientTick = 0,
		ClientErrors = 0,
		ClientErrorMinute = 0,
	}
	sessions[player] = s
	push({
		n = "session_start",
		u = player.UserId,
		s = s.Id,
		d = {
			name = player.Name,
			display = player.DisplayName,
			accountAge = player.AccountAge,
			premium = player.MembershipType == Enum.MembershipType.Premium,
			joinSource = joinSource(player),
		},
	})
	crumb(s, "Joined")
	task.spawn(function()
		local ok, country = pcall(LocalizationService.GetCountryRegionForPlayerAsync, LocalizationService, player)
		if ok and sessions[player] == s then
			push({ n = "session_meta", u = player.UserId, s = s.Id, d = { country = country } })
		end
	end)
end

local function endSession(player, shutdown)
	local s = sessions[player]
	if not s then
		return
	end
	sessions[player] = nil
	local c = s.Client
	local sinceTick = c.At and (os.clock() - c.At) or 0
	local prompt = s.LastPrompt
	local crumbs = {}
	local t = os.clock()
	for _, cr in ipairs(s.Crumbs) do
		table.insert(crumbs, { math.floor(t - cr[1]), cr[2] })
	end
	push({
		n = "session_end",
		u = player.UserId,
		s = s.Id,
		d = {
			shutdown = shutdown or nil,
			kicked = s.Kicked,
			loaded = if usesLoaded then s.Loaded == true else nil,
			level = s.Level,
			cash = s.Cash,
			earned = s.Earned,
			spent = s.Spent,
			tutorialStep = s.Tutorial,
			idle = c.Idle and (c.Idle + sinceTick) or nil,
			inTask = s.Task,
			menu = c.Menu,
			activity = c.Activity,
			noProgress = usesCurrency and math.floor(os.clock() - s.LastEarned) or nil,
			errors = s.Errors,
			lastPrompt = prompt and {
				key = prompt.Key,
				bought = prompt.Bought,
				ago = math.floor(os.clock() - prompt.At),
			} or nil,
			breadcrumbs = crumbs,
		},
	})
end

--------------------------------------------------
-- client reports (RemoteEvent "RoSessionRemote": "Info" | "Tick" | "Error")
--------------------------------------------------

local PLATFORMS = { Desktop = true, Phone = true, Tablet = true, Console = true, VR = true }

local function clean(v, n)
	return type(v) == "string" and string.sub(string.gsub(v, "[^%w%s%-%._:/()]", ""), 1, n or 40) or nil
end

local function onClient(player, kind, payload, extra)
	local s = sessions[player]
	if not s or type(kind) ~= "string" then
		return
	end
	if kind == "Info" and type(payload) == "table" and not s.InfoSent then
		s.InfoSent = true
		push({
			n = "client_info",
			u = player.UserId,
			s = s.Id,
			d = {
				platform = PLATFORMS[payload.platform] and payload.platform or "Unknown",
				device = clean(payload.device, 60),
			},
		})
	elseif kind == "Tick" and type(payload) == "table" then
		local t = os.clock()
		if t - s.LastClientTick < CLIENT_TICK_MIN and not payload.urgent then
			return
		end
		local window = math.clamp(t - s.LastClientTick, 1, 120)
		s.LastClientTick = t
		local act = {}
		local total = 0
		if type(payload.act) == "table" then
			for k, v in pairs(payload.act) do
				local key = clean(k, 30)
				if key and type(v) == "number" and v > 0 and total < window + 2 then
					local secs = math.min(v, window + 2 - total)
					act[key] = math.floor(secs)
					total += secs
				end
			end
		end
		s.Client = {
			At = t,
			Activity = clean(payload.activity, 30),
			Menu = clean(payload.menu, 30),
			Idle = number(payload.idle) and math.clamp(payload.idle, 0, 86400) or nil,
		}
		local ok, ping = pcall(player.GetNetworkPing, player)
		push({
			n = "client_tick",
			u = player.UserId,
			s = s.Id,
			d = {
				fps = number(payload.fps) and math.clamp(math.floor(payload.fps), 0, 999) or nil,
				ping = ok and math.floor(ping * 2000) or nil, -- round trip, ms
				idle = s.Client.Idle and math.floor(s.Client.Idle) or nil,
				activity = s.Client.Menu and ("Menu: " .. s.Client.Menu) or s.Client.Activity,
				act = act,
				level = s.Level,
				cash = s.Cash,
				tutorialStep = s.Tutorial,
			},
		})
	elseif kind == "Error" then
		local minute = math.floor(os.clock() / 60)
		if minute ~= s.ClientErrorMinute then
			s.ClientErrorMinute, s.ClientErrors = minute, 0
		end
		s.ClientErrors += 1
		if s.ClientErrors > CLIENT_ERRORS_PER_MIN then
			return
		end
		s.Errors += 1
		push({
			n = "error",
			u = player.UserId,
			s = s.Id,
			d = { side = "client", message = short(payload, 500), trace = short(extra, 2000) },
		})
	end
end

--------------------------------------------------
-- setup
--------------------------------------------------

local function serverTick()
	local players = Players:GetPlayers()
	local ping, n = 0, 0
	local ids = {}
	for _, p in ipairs(players) do
		local ok, v = pcall(p.GetNetworkPing, p)
		if ok then
			ping += v * 2000
			n += 1
		end
		local s = sessions[p]
		if s then
			table.insert(ids, s.Id)
		end
	end
	push({
		n = "server_tick",
		d = {
			players = #players,
			fps = math.floor(workspace:GetRealPhysicsFPS() * 10) / 10,
			mem = math.floor(Stats:GetTotalMemoryUsageMb()),
			ping = n > 0 and math.floor(ping / n) or nil,
			s = ids, -- sessions still inside: keeps them open even if their client goes quiet
		},
	})
end

-- the client half: copied into StarterPlayerScripts, and into anyone already playing
local function installClient()
	local template = script:FindFirstChild("RoSessionClient")
	if not template then
		warn("[RoSession] the RoSessionClient LocalScript is missing from the module: device, FPS and idle data are off")
		return
	end
	local folder = StarterPlayer:FindFirstChildOfClass("StarterPlayerScripts")
	if folder and not folder:FindFirstChild("RoSessionClient") then
		local copy = template:Clone()
		copy.Enabled = true
		copy.Parent = folder
	end
	for _, player in ipairs(Players:GetPlayers()) do
		local gui = player:FindFirstChildOfClass("PlayerGui")
		if gui and not gui:FindFirstChild("RoSessionClient") then
			local holder = Instance.new("ScreenGui")
			holder.Name = "RoSessionClient"
			holder.ResetOnSpawn = false
			local copy = template:Clone()
			copy.Enabled = true
			copy.Parent = holder
			holder.Parent = gui
		end
	end
end

-- options: { key = "rs_live_…" } or { secret = "<Creator Hub secret name>" },
--          url? (default RoSession), studio? (false = don't send from Studio), debug?
function RoSession.start(opts)
	assert(RunService:IsServer(), "RoSession.start() runs on the server (a Script, not a LocalScript)")
	if started then
		return RoSession
	end
	started = true
	options = type(opts) == "table" and opts or {}
	if type(options.key) ~= "string" and type(options.secret) ~= "string" then
		warn("[RoSession] start({ key = \"rs_live_…\" }): no API key given, nothing will be sent")
		return RoSession
	end
	if IS_STUDIO and options.studio == false then
		log("off in Studio (studio = false)")
		return RoSession
	end
	enabled = true

	local remote = ReplicatedStorage:FindFirstChild("RoSessionRemote")
	if not remote then
		remote = Instance.new("RemoteEvent")
		remote.Name = "RoSessionRemote"
		remote.Parent = ReplicatedStorage
	end
	remote.OnServerEvent:Connect(onClient)
	installClient()

	push({ n = "server_start", d = { maxPlayers = Players.MaxPlayers } })

	-- purchase prompts (developer products and game passes)
	MarketplaceService.PromptProductPurchaseFinished:Connect(function(userId, productId, purchased)
		local player = Players:GetPlayerByUserId(userId)
		task.spawn(function()
			if productNames[productId] == nil then
				local ok, info = pcall(MarketplaceService.GetProductInfo, MarketplaceService, productId, Enum.InfoType.Product)
				productNames[productId] = ok and info and info.Name or tostring(productId)
			end
			local s = player and sessions[player]
			if s then
				s.LastPrompt = { Key = productNames[productId], Bought = purchased, At = os.clock() }
			end
			RoSession.event(player, "PromptClosed", nil, { productNames[productId], purchased and "Bought" or "Declined" })
		end)
	end)
	MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, purchased)
		task.spawn(function()
			local ok, info = pcall(MarketplaceService.GetProductInfo, MarketplaceService, passId, Enum.InfoType.GamePass)
			local name = ok and info and info.Name or tostring(passId)
			local s = sessions[player]
			if s then
				s.LastPrompt = { Key = name, Bought = purchased, At = os.clock() }
			end
			RoSession.event(player, "PromptClosed", nil, { name, purchased and "Bought" or "Declined" })
			if purchased and ok and info and number(info.PriceInRobux) then
				push({ n = "Purchase", u = player.UserId, s = s and s.Id or nil, v = info.PriceInRobux, f = { short(name), s and s.Level and ("Lv " .. s.Level) or nil } })
			end
		end)
	end)

	-- server script errors (rate-limited)
	local errorMinute, errorCount = 0, 0
	ScriptContext.Error:Connect(function(message, trace)
		local minute = math.floor(os.clock() / 60)
		if minute ~= errorMinute then
			errorMinute, errorCount = minute, 0
		end
		errorCount += 1
		if errorCount <= SERVER_ERRORS_PER_MIN then
			push({ n = "error", d = { side = "server", message = short(message, 500), trace = short(trace, 2000) } })
		end
	end)

	Players.PlayerAdded:Connect(startSession)
	for _, player in ipairs(Players:GetPlayers()) do
		startSession(player)
	end
	Players.PlayerRemoving:Connect(function(player)
		-- deferred, so the game's own PlayerRemoving handlers (last currency / events) count
		task.defer(endSession, player, closing)
	end)

	game:BindToClose(function()
		closing = true
		local inside = #Players:GetPlayers()
		for _, player in ipairs(Players:GetPlayers()) do
			endSession(player, true)
		end
		push({ n = "server_end", d = { reason = inside > 0 and "Shutdown (players inside)" or "Empty" } })
		local deadline = os.clock() + (IS_STUDIO and 3 or 20)
		while #buffer > 0 and os.clock() < deadline do
			if not sending then
				task.spawn(flush)
			end
			task.wait(0.2)
		end
	end)

	task.spawn(function()
		local lastTick = -math.huge
		while true do
			if os.clock() - lastTick >= TICK_SECONDS then
				lastTick = os.clock()
				pcall(serverTick)
			end
			task.spawn(flush)
			-- back off while RoSession can't be reached (up to 2 minutes)
			task.wait(FLUSH_SECONDS * math.min(12, 2 ^ math.min(failures, 4)))
		end
	end)

	log("started", RoSession.VERSION, "->", options.url or DEFAULT_URL)
	return RoSession
end

-- menus tagged with this (CollectionService) show up as "Menu: <name>" while visible
RoSession.MENU_TAG = "RoSessionMenu"

return RoSession
