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.
Mental model
Section titled “Mental model”Every track() call walks a fixed drop chain, in this order:
- 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 incrementsStats.DroppedSampledand returnsfalse. - Rate cap. A token bucket guards Roblox’s custom-event budget: it refills at
MaxPerMinute / 60tokens per second with a burst capacity ofMaxPerMinute(default 120 — 2/s sustained, a full minute’s budget spendable at once). An empty bucket incrementsStats.DroppedRateLimitedand returnsfalse. - Queue cap. The queue holds at most 500 entries (a module constant, not configurable). A full queue increments
Stats.DroppedOverflowand returnsfalse. - Insert. The entry joins the queue and
track()returnstrue.
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.
Event schema
Section titled “Event schema”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 destination — LogCustomEvent 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 CustomField01–CustomField03 keys) or verbatim to a custom destination |
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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)endtrack returns false when the event was dropped — normally you ignore it, but a spec asserting on it catches accidental over-tracking early.
Ship batches to your own endpoint
Section titled “Ship batches to your own endpoint”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.
Bridge script errors automatically
Section titled “Bridge script errors automatically”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,})API reference
Section titled “API reference”| 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 |
Options
Section titled “Options”| 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.
Bus topics
Section titled “Bus topics”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 |
Design notes
Section titled “Design notes”- 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.1spends 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 aDestinationthat sends it somewhere.