Skip to content

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.

Every event walks the same chain, in this order:

  1. Stats.Tracked increments. With the default destination, an event that has a player and fields has its field keys checked: keys that are not Enum.AnalyticsCustomFieldKeys names (CustomField01 to CustomField03) warn once per event name, because Roblox ignores them.
  2. Sampling. When the event name has a Sample rate, a random roll against it decides whether the event survives. Names without a rate keep everything. A drop increments Stats.DroppedSampled.
  3. The rate cap. A token bucket refills at MaxPerMinute / 60 tokens per second and holds at most MaxPerMinute (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 increments Stats.DroppedRateLimited.
  4. The queue cap. The queue holds at most 500 events, a fixed constant. A full queue increments Stats.DroppedOverflow.
  5. The event joins the queue and the call returns true. Every drop above returns false.

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.Sent grows 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, DroppedFailed

The 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.

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.Sent counts 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.

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.

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).
src/Server/Game/Services/TelemetryService.luau
--!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
end
end
return TelemetryService

track 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.

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.

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?
})
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.
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.

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.
  • 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.1 spends 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’s stop(). The kernel runs stop() when the server closes, so the tail of the queue goes out. Events tracked after destroy() still count in Tracked and 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 Destination you write.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.