Skip to content

Analytics

Analytics is a batched, sampled, rate-capped event pipeline. track() is a queue insert — cheap enough to call from combat handlers — and a Scheduler flush at Background priority hands batches to a destination on a cadence. The design decision that defines it: every dropped event is counted, never silent. Sampling, rate caps, and queue overflow all increment a named Stats counter, so a dashboard that looks quiet because events are being dropped is distinguishable from a game that is actually quiet.

The default destination is Roblox’s AnalyticsService:LogCustomEvent — Creator Hub custom-event dashboards with breakdown fields and zero external infrastructure. Inject a Destination function to ship batches to a webhook or a warehouse instead.

Every track() call walks a fixed drop chain, in this order:

  1. Sampling. If the event name has an entry in Sample, a random roll against its keep rate decides whether the event survives. Names without an entry keep everything. A drop increments Stats.DroppedSampled and returns false.
  2. Rate cap. A token bucket guards Roblox’s custom-event budget: it refills at MaxPerMinute / 60 tokens per second with a burst capacity of MaxPerMinute (default 120 — 2/s sustained, a full minute’s budget spendable at once). An empty bucket increments Stats.DroppedRateLimited and returns false.
  3. Queue cap. The queue holds at most 500 entries (a module constant, not configurable). A full queue increments Stats.DroppedOverflow and returns false.
  4. Insert. The entry joins the queue and track() returns true.

The flush loop (every FlushSeconds, default 10) swaps the queue for a fresh table, hands the whole batch to the destination inside a pcall, and increments Stats.Flushes. On success Stats.Sent grows by the batch size. On failure the batch is lost — and only that batch: the queue was already swapped, so events tracked during a failing flush are safe, and the next flush proceeds normally.

track() ──sample──▶ ──rate cap──▶ ──queue cap──▶ queue ──every FlushSeconds──▶ Destination(batch)
│ │ │ │
DroppedSampled DroppedRateLimited DroppedOverflow Sent / batch lost (warned)

The rate cap is charged at track() time, not at flush time. Tokens spent on events that later die with a failing destination are not refunded — the cap models Roblox’s ingestion budget, not delivery guarantees.

Each queued entry is an Entry:

Field Type Meaning
Name string Event name — the dashboard key, and the key Sample rates match against
Player Player? Attribution. Required by the default destinationLogCustomEvent needs a player, so playerless entries are skipped by it. Custom destinations receive them
Value number? Magnitude; the default destination sends 1 when omitted
Fields { [string]: any }? Passed through as LogCustomEvent’s breakdown dictionary (Roblox’s CustomField01CustomField03 keys) or verbatim to a custom destination
-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Analytics = require(Root.Analytics)
return function(kernel)
local Events = Analytics.attach(kernel, {
MaxPerMinute = 120, -- protects Roblox's custom-event budget
Sample = { PositionPing = 0.1 }, -- keep 10% of chatty events
})
kernel.Bus:subscribe("Combat.Kill", function(_, killerSession, victimSession)
Events:track("PlayerKill", killerSession.Player, 1, {
CustomField01 = victimSession.Player.Name,
})
end)
end

track returns false when the event was dropped — normally you ignore it, but a spec asserting on it catches accidental over-tracking early.

Destination receives the whole batch; anything you can do with a table you can do with it. A webhook example:

local HttpService = game:GetService("HttpService")
local Events = Analytics.attach(kernel, {
FlushSeconds = 30, -- one POST per 30s, not one per event
Destination = function(batch)
local Rows = table.create(#batch)
for _, Entry in batch do
table.insert(Rows, {
Name = Entry.Name,
UserId = if Entry.Player then Entry.Player.UserId else nil,
Value = Entry.Value,
Fields = Entry.Fields,
})
end
-- an error here is caught by the flush pcall; only this batch is lost
HttpService:PostAsync("https://analytics.example.com/ingest", HttpService:JSONEncode(Rows))
end,
})

A destination that throws loses its own batch and nothing else — the failure is warned with the batch size, the counters keep counting, and the next flush runs on schedule.

WatchErrors = true subscribes to the Kernel.ScriptError bus topic and converts each capture into a ScriptError event: Value is the deduplicated occurrence count, Fields.Message is the error truncated to 100 characters, Fields.Source is the script path.

local Events = Analytics.attach(kernel, {
WatchErrors = true,
Destination = function(batch) -- see the caution below
postToErrorWebhook(batch)
end,
})
Member Description
Analytics.attach(kernel, options?) → Analytics Creates the pipeline and (unless SkipLoop) registers the flush loop at Background priority
events:track(name, player?, value?, fields?) → boolean Queues an event. false means sampled out, over the rate cap, or queue full — each counted in Stats
events:destroy() Flushes whatever remains, cancels the flush loop, disconnects the WatchErrors subscription
events.Stats Live counters — see below
Option Default Meaning
FlushSeconds 10 Flush cadence
MaxPerMinute 120 Token-bucket budget guard: MaxPerMinute / 60 tokens/s sustained, burst up to MaxPerMinute
Sample {} Per-event-name keep rate 0..1. Absent names keep everything
Destination LogCustomEvent batcher function(batch: { Entry }) — receives every flushed batch
WatchErrors false Bridge Kernel.ScriptError into ScriptError events
Seed random Seeds the sampling RNG — deterministic sampling for specs
SkipLoop false Skip the flush loop; call flushes yourself (specs)
Counter Incremented when
Tracked Every track() call, before any drop decision
Sent An event’s batch was handed to the destination without it throwing
DroppedSampled The sampling roll failed
DroppedRateLimited The token bucket was empty
DroppedOverflow The queue was at 500 entries
Flushes A non-empty flush ran (success or failure)

Tracked should equal Sent + DroppedSampled + DroppedRateLimited + DroppedOverflow + (queued, not yet flushed) + (lost to destination failures) — when it doesn’t over a window, something is calling into internals it shouldn’t.

Analytics publishes nothing. It consumes one topic, only when WatchErrors is set:

Topic Direction Used for
Kernel.ScriptError (message, trace, source, count) consumed Converted to a ScriptError event with truncated message and source
  • Telemetry, not a ledger. Batches lost to a crashing destination are warned and gone; over-cap events drop immediately. Never route anything that grants value (purchases, trades) through Analytics — that is what Receipts and Transactions are for.
  • Sampling runs before the rate cap. A chatty event sampled at 0.1 spends tokens only for the 10% it keeps, so sampling is how you protect the budget for events that matter.
  • destroy() flushes. Shutdown paths that tear the pipeline down do not lose the tail — the final queue goes out synchronously before the loop is cancelled.
  • Privacy posture: the pipeline stores nothing and phones nowhere on its own. Events carry exactly what you put in them; the default sink is Roblox’s first-party AnalyticsService, and data only leaves Roblox if you inject a Destination that sends it somewhere.