Analytics
A batched, sampled, rate-capped event queue that flushes to AnalyticsService (custom, economy, funnel, onboarding and progression events) or to your own destination, and counts every drop.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Analytics - Since
- v0.3.0
- Source
- Analytics.luau
Analytics is a batched, sampled, rate-capped event pipeline. track() and the typed event methods are queue inserts,
cheap enough to call from combat handlers, and a flush hands the batch to a destination a few seconds later. The rule
that defines it: every dropped event is counted. Sampling, the rate cap, queue overflow, invalid arguments and failed
deliveries each increment a named Stats counter, so a dashboard that looks quiet because events are dropping can be
told apart from a game that is quiet.
The default destination is Roblox’s AnalyticsService: LogCustomEvent for track(), and LogEconomyEvent,
LogFunnelStepEvent, LogOnboardingFunnelStepEvent and LogProgressionEvent for the typed methods. Those feed the
Creator Hub dashboards with no infrastructure of your own. Inject a Destination to send batches to a webhook or a
warehouse instead.
Mental model
Section titled “Mental model”Every event walks the same chain, in this order:
Stats.Trackedincrements. With the default destination, an event that has a player and fields has its field keys checked: keys that are notEnum.AnalyticsCustomFieldKeysnames (CustomField01toCustomField03) warn once per event name, because Roblox ignores them.- Sampling. When the event name has a
Samplerate, a random roll against it decides whether the event survives. Names without a rate keep everything. A drop incrementsStats.DroppedSampled. - The rate cap. A token bucket refills at
MaxPerMinute / 60tokens per second and holds at mostMaxPerMinute(default 120: 2 per second sustained, a full minute’s worth in a burst). This is the framework’s own cap; the engine reference states no per-minute budget for AnalyticsService. An empty bucket incrementsStats.DroppedRateLimited. - The queue cap. The queue holds at most 500 events, a fixed constant. A full queue increments
Stats.DroppedOverflow. - The event joins the queue and the call returns
true. Every drop above returnsfalse.
The first queued event arms one Scheduler deadline FlushSeconds later (default 10 s) at
Background priority. The flush runs on its own thread, off the scheduler step, so a custom destination may yield. It
swaps the queue for a fresh table, increments Stats.Flushes, and calls the destination inside a pcall:
- On success,
Stats.Sentgrows by the number the destination returned, or by the batch size when it returned nothing. - On a throw, the batch is lost with a warning (
[Analytics] destination failed, 12 event(s) lost: ...). Only that batch: events tracked during the failing call sit in the new queue.
Events queued while a flush runs are flushed after it, on a new deadline. With nothing queued, nothing is scheduled.
track() -> sample -> rate cap -> queue cap -> queue --(FlushSeconds)--> Destination(batch) | | | | DroppedSampled DroppedRateLimited DroppedOverflow Sent, DroppedPlayerless, DroppedFailedThe rate cap is charged when the event is tracked. Tokens spent on events that a failing destination later loses are not refunded: the cap limits what enters the pipeline.
The default destination
Section titled “The default destination”Every AnalyticsService Log method needs a player. The default destination:
- Skips playerless events and counts them in
Stats.DroppedPlayerless. - Sends every other event with its own protected call, so one bad call (a player who just left, bad fields) cannot lose
the rest of the batch. A call that errors counts in
Stats.DroppedFailed. - Returns the number it delivered, so
Stats.Sentcounts only events Roblox accepted.
It also watches Roblox’s per-experience limits and warns once when the server sees more distinct values than Roblox keeps: 5 currencies, 20 transaction types, 100 item SKUs, 10 funnels, and 8,000 custom field combinations. None of these checks run with a custom destination.
Typed events
Section titled “Typed events”The typed methods ride the same queue and the same chain, under the event names Economy, Funnel, Onboarding and
Progression (which is what Sample rates match against). Before queueing, each checks the argument rules the
AnalyticsService reference documents. A call that breaks one returns false, counts in Stats.Tracked and
Stats.DroppedInvalid, and warns once per method and rule.
| Method | Checks | AnalyticsService call |
|---|---|---|
economy(player, flowType, currency, amount, endingBalance, transactionType, itemSku?, fields?) |
A player; flowType is "Source", "Sink" or an Enum.AnalyticsEconomyFlowType; currency non-empty; amount finite and above 0; endingBalance finite and at least 0; transactionType a non-empty name or an Enum.AnalyticsEconomyTransactionType; itemSku non-empty when given. |
LogEconomyEvent |
funnel(player, funnelName, funnelSessionId?, step, stepName?, fields?) |
A player; funnelName non-empty; funnelSessionId a string when given; step an integer from 1 to 100. |
LogFunnelStepEvent |
onboarding(player, step, stepName?, fields?) |
A player; step an integer from 1 to 100. |
LogOnboardingFunnelStepEvent |
progression(player, pathName, status, level, levelName?, fields?) |
A player; pathName non-empty with no commas, quotes or newlines; status is "Start", "Complete", "Fail", "Custom" or an Enum.AnalyticsProgressionType; level an integer. |
LogProgressionEvent |
Use the built-in transaction type names (IAP, Shop, Gameplay, ContextualPurchase, TimedReward,
Onboarding) for Roblox’s breakdowns. A funnel session id is only for recurring funnels; Roblox ignores repeated steps
in one session or without an id. A custom destination receives typed events as entries with Kind and named Data.
Event schema
Section titled “Event schema”Each queued entry is an Entry:
| Field | Type | Meaning |
|---|---|---|
Name |
string |
The event name, and the key Sample rates match against. Economy, Funnel, Onboarding or Progression for typed events. |
Player |
Player? |
Attribution. Required by the default destination; custom destinations receive playerless entries too. |
Value |
number? |
The magnitude. The default destination sends 1 for a custom event without one. Typed events carry the amount, step or level. |
Fields |
{ [string]: any }? |
Custom fields. Roblox uses only CustomField01 to CustomField03. |
Kind |
string? |
nil for custom events; "Economy", "Funnel", "Onboarding" or "Progression" for typed ones. |
Data |
{ [string]: any }? |
A typed event’s arguments by name (FlowType, Currency, Amount, EndingBalance, TransactionType, ItemSku; FunnelName, FunnelSessionId, Step, StepName; PathName, Status, Level, LevelName). |
--!strict-- TelemetryService: gameplay, economy and onboarding events.
local ServerScriptService = game:GetService("ServerScriptService")
local Analytics = require(ServerScriptService.ChloeKernelServer.Analytics)
local TelemetryService = { Name = "TelemetryService",}
function TelemetryService.init(self: any, kernel: any) self.Kernel = kernel self.Events = Analytics.attach(kernel, { MaxPerMinute = 120, Sample = { PositionPing = 0.1 }, -- keep 10% of a chatty event })end
function TelemetryService.start(self: any) self.Connections = { -- Shop.Bought and Round.Won are this game's own topics self.Kernel.Bus:subscribe("Shop.Bought", function(_, session, itemId: string, price: number, balance: number) self.Events:economy( session.Player, "Sink", "Coins", price, balance, Enum.AnalyticsEconomyTransactionType.Shop, itemId ) end), self.Kernel.Bus:subscribe("Round.Won", function(_, session, roundNumber: number, mapName: string) self.Events:track("RoundWon", session.Player, roundNumber, { CustomField01 = mapName }) end), }end
function TelemetryService.firstMove(self: any, player: Player) self.Events:onboarding(player, 1, "FirstMove")end
function TelemetryService.stop(self: any) for _, Connection in self.Connections or {} do Connection:disconnect() end if self.Events then self.Events:destroy() -- flushes what is queued endend
return TelemetryServicetrack and the typed methods return false when the event was dropped. Game code usually ignores it; a spec that
asserts on it catches accidental over-tracking early.
Send batches to your own endpoint
Section titled “Send batches to your own endpoint”Destination receives the whole batch and may yield. Return the number of events it delivered so Stats.Sent stays
honest; return nothing to count the whole batch as sent.
local HttpService = game:GetService("HttpService")
local Events = Analytics.attach(kernel, { FlushSeconds = 30, -- one POST per 30 s at most Destination = function(batch): number? 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, Kind = Entry.Kind, Data = Entry.Data, Fields = Entry.Fields, }) end -- An error here is caught by the flush; only this batch is lost HttpService:PostAsync("https://analytics.example.com/ingest", HttpService:JSONEncode(Rows)) return #batch 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 its own deadline.
Bridge script errors
Section titled “Bridge script errors”WatchErrors = true subscribes to Kernel.ScriptError and turns each report into a
ScriptError event: Value is the deduplicated occurrence count, Fields.Message the error cut to 100 characters, and
Fields.Source the script path. Script errors have no player, so the default destination would drop every one of them;
attach warns about that combination at once. Pair WatchErrors with a custom Destination:
local ErrorEvents = Analytics.attach(kernel, { WatchErrors = true, Destination = sendToErrorWebhook, -- your function(batch) -> number?})API reference
Section titled “API reference”| Member | Description |
|---|---|
Analytics.attach(kernel, options?) -> events |
Builds the pipeline. Nothing is scheduled until the first event queues. |
events:track(name, player?, value?, fields?) -> boolean |
Queues a custom event. false when sampled out, over the rate cap or the queue is full. |
events:economy(player, flowType, currency, amount, endingBalance, transactionType, itemSku?, fields?) -> boolean |
Queues a LogEconomyEvent. false also for invalid arguments. |
events:funnel(player, funnelName, funnelSessionId?, step, stepName?, fields?) -> boolean |
Queues a LogFunnelStepEvent. |
events:onboarding(player, step, stepName?, fields?) -> boolean |
Queues a LogOnboardingFunnelStepEvent. |
events:progression(player, pathName, status, level, levelName?, fields?) -> boolean |
Queues a LogProgressionEvent. |
events:destroy() |
Cancels the pending flush deadline, flushes what remains (yields while a custom destination yields), and disconnects the WatchErrors subscription. |
events.Stats |
Live counters, below. |
Options
Section titled “Options”| Option | Default | Meaning |
|---|---|---|
FlushSeconds |
10 |
Delay from the first queued event to its flush. |
MaxPerMinute |
120 |
Token bucket: MaxPerMinute / 60 per second sustained, bursts up to MaxPerMinute. |
Sample |
{} |
Keep rate per event name, 0 to 1. Names without a rate keep everything. |
Destination |
the AnalyticsService batcher | function(batch: { Entry }) -> number?. May yield. Return the count delivered. |
WatchErrors |
false |
Bridge Kernel.ScriptError into ScriptError events. |
Seed |
random | Seeds the sampling RNG, for deterministic specs. |
Service |
AnalyticsService |
Test injection point for the default destination. |
SkipLoop |
false |
Never schedule flushes; specs flush by hand. |
| Counter | Incremented when |
|---|---|
Tracked |
Every call, before any drop decision, including invalid typed calls. |
Sent |
Events a flush delivered: the destination’s returned count, or the batch size when it returned nothing. |
DroppedSampled |
The sampling roll failed. |
DroppedRateLimited |
The token bucket was empty. |
DroppedOverflow |
The queue held 500 events. |
DroppedInvalid |
A typed call broke a documented argument rule. |
DroppedPlayerless |
The default destination skipped an event without a player. |
DroppedFailed |
The default destination’s call for one event errored. |
Flushes |
A flush with at least one event ran, whether or not the destination threw. |
Over any window, Tracked equals Sent plus every Dropped counter plus events still queued, events in batches whose
destination threw, and events a custom destination reported as undelivered. The last two are logged but not counted.
Bus topics
Section titled “Bus topics”Analytics publishes nothing. With WatchErrors, it consumes one topic:
| Topic | Payload | Used for |
|---|---|---|
Kernel.ScriptError |
message, trace, source, count |
Converted into a ScriptError event with the message cut to 100 characters. |
Design notes
Section titled “Design notes”- Analytics is telemetry. Over-cap events drop at once, and a batch lost to a crashing destination is gone. Never route anything that grants value through it; that is what Receipts and Transactions are for.
- Sampling runs before the rate cap. An event sampled at
0.1spends tokens only for the 10% it keeps, so sampling is how you keep the budget for events that matter. - Call
destroy()from the owning service’sstop(). The kernel runsstop()when the server closes, so the tail of the queue goes out. Events tracked afterdestroy()still count inTrackedand queue, but no flush is scheduled for them. - The pipeline stores nothing and sends nothing on its own. Events carry exactly what you put in them, the default sink
is Roblox’s own AnalyticsService, and data leaves Roblox only through a
Destinationyou write.