Start typing to search packages!
roinsights
By @senorlawyer
Roblox
Mirrored from WallyRoInsights Roblox SDK
Server-side Luau SDK for RoInsights, near-real-time analytics for Roblox experiences. Install it once in your experience's server and the dashboard shows live sessions, retention, funnels, economy, revenue, progression, custom events, version comparison, server health and script errors within 30 to 60 seconds.
- Runs only on the server. Requiring it from a client errors, and the credential never replicates.
- Every tracking call validates its input, appends to an in-memory queue and returns. Calls never yield and never throw into your game code.
- One background thread does all network work, with one request in flight at a time.
Contents
- Install
- Set up the credential
- Quick start
- API reference
- Automatic tracking
- Experiments
- Mirroring to Roblox Analytics
- Limits
- Delivery, retries and shutdown
- Troubleshooting
- Design notes
- Development
Install
Put the RoInsights ModuleScript in ServerScriptService or ServerStorage. Anywhere else
replicates to clients.
Model file
Download RoInsights.rbxm from the GitHub Releases page of the RoInsights repository and drag it
into ServerScriptService in Studio.
Creator Store
Insert the RoInsights model from the Creator Store and move it to ServerScriptService. Check that the publisher is RoInsights before you insert it.
Wally
[server-dependencies]
RoInsights = "senorlawyer/roinsights@1.1.0"
Run wally install, then require it from ServerScriptService.ServerPackages.RoInsights (or
wherever your project maps ServerPackages).
Enable HTTP
In Studio, open Game Settings > Security, turn on Allow HTTP Requests and publish. Without it the SDK warns once and keeps retrying.
Set up the credential
Create a project in the RoInsights dashboard and copy its project credential. It starts with
ri_live_. Whoever has it can send data to your project, so keep it on the server.
Init looks for the credential in this order:
- A Roblox secret (recommended). Add a secret named
ROINSIGHTS_TOKENon your experience's Secrets page in Creator Hub, with the credential as the value andingest.roinsights.devas the allowed domain. The SDK reads it withHttpService:GetSecretand sends it asBearer <secret>. It never prints or stores the value. To test in Studio, add the same secret to Studio's local secrets. Use theSecretNameoption to pick a different name. - The
Tokenoption passed toInit. - A
ConfigModuleScript parented to theRoInsightsModuleScript that returns{ Token = "ri_live_..." }. Seeexamples/Config.luau.
Options 2 and 3 put the credential in plain text, so the SDK refuses them unless RoInsights (and
its Config child) are descendants of ServerScriptService or ServerStorage. If they replicate to
clients, Init warns and stays off.
Quick start
Call Init once from a Script in ServerScriptService:
local ServerScriptService = game:GetService("ServerScriptService")
local RoInsights = require(ServerScriptService.RoInsights)
RoInsights:Init()
Then track from any server code:
RoInsights:Event(Player, "OpenedShop", { Tab = "Gems" })
RoInsights:Economy(Player, "Sink", "Coins", 250, 1200, "Shop", "SpeedBoots")
RoInsights:Progression(Player, "Campaign", "Complete", 3, "Desert")
Examples
Bootstrap.server.luauis a Script for ServerScriptService. It initializes the SDK and, onPlayerAdded, assigns an experiment, records the starting coins as an economy source, logs the first onboarding funnel step and sets session context. It expects theRoInsightsModuleScript in ServerScriptService and the credential in theROINSIGHTS_TOKENsecret (or aConfigmodule).Config.luauis the optionalConfigModuleScript. Parent it to theRoInsightsModuleScript and use it only when you cannot use a secret. Both modules must sit in ServerScriptService or ServerStorage, and a real credential must never be committed to a public repository.Purchase.server.luaureports developer products fromMarketplaceService.ProcessReceiptand game passes fromPromptGamePassPurchaseFinished. Each product'sGrantfunction is where you add the item and save the receipt'sPurchaseIdin your player data, so a retried receipt never grants twice. Report the purchase only after the grant succeeded, then returnPurchaseGranted.
API reference
All methods use colon syntax. Properties is an optional flat table of up to 24 keys whose values
are strings, finite numbers or booleans (see Limits).
RoInsights:Init(Config?)
Starts the SDK. Safe to call more than once; only the first call counts. Call it from one server
Script before other scripts start tracking. Calls made before Init are ignored.
| Option | Type | Default | Meaning |
|---|---|---|---|
Token | string? | nil | Project credential, if you do not use a secret |
SecretName | string | "ROINSIGHTS_TOKEN" | Name of the Roblox secret to read |
Endpoint | string | "https://ingest.roinsights.dev" | Ingest API base URL |
MirrorToRobloxAnalytics | boolean | false | Also log to Roblox AnalyticsService |
TrackStudio | boolean | true | Send data from Studio play sessions (tagged studio) |
AutoTrackSessions | boolean | true | Track player sessions automatically |
Debug | boolean | false | Print [RoInsights] logs, including dropped input |
MaxQueueSize | number | 5000 | Events held in memory before the oldest are dropped (100 to 100000) |
CaptureErrors | boolean | true | Report server script errors (see Script errors) |
Unknown options and wrong types warn once and fall back to the default.
RoInsights:Event(Player?, Name, Properties?)
A named event. Pass nil as the player for a server-level event.
RoInsights:Custom(Player?, Name, Value?, Properties?)
A named event with a numeric value, for sums and averages. Mirrors to
AnalyticsService:LogCustomEvent when mirroring is on.
RoInsights:Funnel(Player, FunnelName, FunnelSessionId?, Step, StepName, Properties?)
One step of a funnel. Step is an integer from 1 to 100. Pass a FunnelSessionId to tell apart
repeated runs of the same funnel, such as several shop visits. Mirrors to
AnalyticsService:LogFunnelStepEvent.
RoInsights:Economy(Player, FlowType, Currency, Amount, EndingBalance?, TransactionType, ItemSku?, Properties?)
A currency source or sink. FlowType is "Source" or "Sink", Amount must be greater than 0
and EndingBalance is the player's balance afterwards. Mirrors to AnalyticsService:LogEconomyEvent
when EndingBalance is given, because Roblox requires it.
RoInsights:Progression(Player, PathName, Status, Level, LevelName?, Properties?)
Status is "Start", "Complete" or "Fail"; Level is an integer from 0 to 1000000. Mirrors to
AnalyticsService:LogProgressionEvent.
RoInsights:Purchase(Player, Info, Properties?)
A Robux purchase. Call it from your own ProcessReceipt after you grant the item; the SDK never
touches MarketplaceService.ProcessReceipt.
RoInsights:Purchase(Player, {
Type = "DeveloperProduct",
Name = "100 Gems",
Robux = ReceiptInfo.CurrencySpent,
ProductId = ReceiptInfo.ProductId,
ReceiptId = ReceiptInfo.PurchaseId,
})
| Field | Type | Notes |
|---|---|---|
Type | string | "DeveloperProduct", "GamePass", "Subscription" or "Other" |
Name | string | Item name, 1 to 64 characters |
Robux | number | Robux spent, an integer from 0 to 100000000 |
ProductId | number? | Product or game pass id |
ReceiptId | string? | Receipt or purchase id, up to 64 characters |
RoInsights:SetPlayerContext(Player, Context)
Attaches flat key/value data (segments, cohorts, settings) to the player's current session. Only
keys whose value changed are sent. Context belongs to one session: set it again after the player
rejoins. Needs an active session, so it does nothing when AutoTrackSessions is off.
RoInsights:Experiment(Player, ExperimentName, Variants) -> string
Returns the player's variant and records it as session context. See Experiments.
RoInsights:Flush()
Asks the background thread to send on its next tick (within about a second). Does not yield.
RoInsights:GetStatus()
Returns a table for debugging:
| Field | Meaning |
|---|---|
Connected | The server holds a valid server token |
ServerId | Server session id from the ingest API |
QueueSize | Events waiting to be sent, including an unacknowledged batch |
Dropped | Events lost to a full queue, rejected batches or events that cannot be encoded |
DroppedErrors | Script error occurrences not reported because 50 distinct errors were already waiting |
LastError | Last delivery error, or why the SDK is inactive |
Automatic tracking
Without any tracking calls, the SDK collects the following.
Player sessions
With AutoTrackSessions on (the default):
- Players already in the server at
Init, and every laterPlayers.PlayerAdded, get asession_startwith a new session id,premium,accountAgeDaysandcountry. countryis the two-letter ISO 3166-1 code returned byLocalizationService:GetCountryRegionForPlayerAsync. The lookup runs in its own thread and waits at most 3 seconds; the session starts with the time the player joined either way, and withoutcountrywhen the lookup fails or is too slow.Players.PlayerRemovingsendssession_end. Duration is computed by the server.- Every event for a player carries that player's session id, including events tracked while the
country lookup is still running. If your code tracks a player before the SDK's
PlayerAddedlistener runs, the session starts on that first event. - Events for a player who already left are still sent, without a session id.
Server health
Every batch reports the server's player count and doubles as a heartbeat, which keeps live player
counts accurate even when no events happen. It also carries a server object measured since the
previous batch:
| Field | Source |
|---|---|
fps | Heartbeat frames divided by the time they covered |
fpsMin | Lowest heartbeat rate of any full second in that period |
memoryMb | Stats:GetTotalMemoryUsageMb() |
dataReceiveKbps, dataSendKbps | Stats.DataReceiveKbps, Stats.DataSendKbps |
pingMs, pingP90Ms | Mean and 90th percentile of Player:GetNetworkPing() across current players, sampled when the batch is built |
instances | Stats.InstanceCount |
Each value is clamped to the protocol's range, and a value that cannot be read is left out. Without
a heartbeat rate or a memory reading the whole server object is left out.
Script errors
With CaptureErrors on (the default), errors from server scripts (ScriptContext.Error) are
grouped by message and script between batches. Each group becomes one error event with the
number of occurrences, the time of the first one and its stack trace. Messages are cut to 300
characters, script names to 200 and stacks to 1000. At most 50 distinct groups are kept per batch;
further new errors are counted in DroppedErrors. Errors raised by RoInsights itself are ignored.
Privacy
The SDK sends only what is listed here and what your own tracking calls pass. It never reads chat,
display names, usernames or other personal data. Players are identified by Roblox UserId, and
country is the two-letter region code Roblox provides, not a location or address.
Experiments
Experiment is deterministic: the same player always gets the same variant for the same experiment
name, on every server, with no network call. It works before Init and when tracking is off, so
gameplay can rely on it.
The bucket is FNV-1a (32-bit) of "<ExperimentName>:<UserId>", passed through the MurmurHash3
fmix32 finalizer, modulo the number of variants, plus one. The finalizer stops two-variant tests in
different experiments from lining up. Renaming an experiment or reordering its variants reshuffles
players.
The assignment is stored as session context under Exp_<ExperimentName>, with characters outside
A-Z a-z 0-9 _ replaced by _ and the key cut to 48 characters. If the input is invalid (no
player, empty name, no variants), it returns the first variant, or "" when there is none.
local Variant = RoInsights:Experiment(Player, "ShopLayout", { "Control", "Grid" })
Mirroring to Roblox Analytics
Set MirrorToRobloxAnalytics = true to also log Custom, Funnel, Economy and Progression
calls to Roblox's built-in AnalyticsService. Each call is protected and runs after the event is
queued, so a mirroring failure never affects RoInsights data. Roblox accepts three custom fields;
the SDK fills CustomField01 to CustomField03 from the first three properties in sorted key
order, formatted as "Key - Value". Event and Purchase are not mirrored: Roblox caps custom
event names and already records purchases.
Limits
These match the ingest protocol. Input that breaks them is fixed when possible and dropped
otherwise; with Debug on, each call logs one line describing what was dropped.
| Field | Limit |
|---|---|
Names (Name, FunnelName, StepName, Currency, TransactionType, ItemSku, PathName, LevelName, purchase Name) | 1 to 64 characters; longer names are truncated |
| Property count | 24 per event; extra keys are dropped in sorted key order |
| Property keys | Match ^[A-Za-z][A-Za-z0-9_]{0,47}$ |
| Property values | Strings up to 200 characters (truncated), finite numbers, booleans. Nested tables are dropped |
FunnelSessionId, ReceiptId | Up to 64 characters |
Funnel Step | Integer from 1 to 100 |
Economy Amount | Greater than 0 |
Progression Level | Integer from 0 to 1000000 |
Purchase Robux | Integer from 0 to 100000000 |
| Queue | MaxQueueSize events; when full the oldest are dropped and counted in Dropped |
Lengths are counted the way JavaScript counts them (UTF-16 code units), and truncation never splits a character.
Delivery, retries and shutdown
The background thread connects with the project credential, receives a short-lived server token, then sends batches:
- every flush interval (30 seconds unless the server says otherwise), even with no events;
- sooner when 200 or more events are waiting (at most every 2 seconds), when a batch was full, or
after
Flush().
Batches respect the server's event and byte limits. A failed batch keeps its id, so the server discards duplicates when a retry repeats one that actually arrived.
| Response | What the SDK does |
|---|---|
| 400 | Drops the batch |
| 401 | Reconnects, then resends the same batch |
| 403 on events | Stops sending until the server restarts |
| 413 | Splits the batch in half and sends both halves with new ids |
| 429 | Waits Retry-After seconds, then resends the same batch |
| 5xx or network error | Retries after 2, 4, 8 ... up to 60 seconds |
Connect failures back off the same way. A rejected credential, an unavailable project or a universe mismatch warns once and retries every 5 minutes.
On shutdown the SDK's game:BindToClose handler ends every open session, sends what it can within
25 seconds (Roblox allows 30) and then disconnects the server.
Troubleshooting
Call RoInsights:GetStatus() from the server console, or set Debug = true.
| Message or symptom | Fix |
|---|---|
HTTP requests are disabled | Turn on Game Settings > Security > Allow HTTP Requests and publish |
no credential found | Add the ROINSIGHTS_TOKEN secret, or use the Token option or a Config module |
refusing to use a plain-text token: ... replicates to clients | Move RoInsights to ServerScriptService or ServerStorage, or use a secret |
not a project credential | Use the ri_live_ project credential, not a server token |
belongs to a RoInsights project configured for another universe | Use the credential of the project for this experience, or change the project's universe |
the project credential was rejected | The credential was revoked or rotated. Copy the current one from the dashboard |
the SDK is server-only | Require RoInsights from a server Script, not a LocalScript |
| Nothing arrives from Studio | TrackStudio is false, or Studio has no local secret. Studio data is tagged studio in the dashboard |
Dropped keeps growing | The ingest API is unreachable, or you send more than the queue holds. Raise MaxQueueSize or send fewer events |
Design notes
The source carries no comments; the reasoning behind its less obvious choices lives here.
Module layout
| Module | Role |
|---|---|
init | Public API. Validates arguments and builds events. Every public call runs inside one pcall boundary, so a bug can never throw into game code. A rejected call returns a reason, logged only with Debug |
Runtime | The only module that reads Roblox globals. It packs services, the clock, the scheduler and server identity into an Env table that every other module receives, which lets the Lune suite swap all of them for fakes |
Settings | Merges Init options with the defaults. Unknown keys and wrong types fall back to the default and warn once, so a typo never silently changes behaviour |
Credential | Credential resolution and the replication check |
Validate | Cheap sanitizers that run on the caller's thread |
Queue | Bounded FIFO that evicts the oldest event when full, because recent activity matters more to a live dashboard than a backlog the server may already treat as stale |
Batch | Cuts request-sized batches off the queue |
Transport | The HTTP boundary. Every request runs in one protected call and returns a result with a status code; status 0 means the request never completed |
Agent | Player sessions, the queue and the single background thread that connects, sends, retries and shuts down |
Health | Heartbeat rate counters and the per-batch server snapshot |
Errors | Groups ScriptContext.Error reports between batches |
Mirror | Optional copy into Roblox AnalyticsService |
Experiment | Deterministic variant assignment |
Types | Shared type contracts, including Env |
Credential resolution and replication
Init takes the first credential it finds: the Roblox secret named by SecretName, then the
Token option, then a Config ModuleScript that is a child of RoInsights. A secret never exposes
its value to Luau and is sent as Secret:AddPrefix("Bearer "), so it is safe wherever the module
lives. If a Config module that replicates to clients still exists next to a secret, the SDK uses
the secret and warns that the module leaks.
A plain-text token is accepted only when RoInsights is a descendant of ServerScriptService or
ServerStorage, and for the Config module, only when that module is too. Any other location
replicates to clients and would hand the credential to every player, so Init refuses, warns and
leaves the SDK off. Tokens are trimmed and must match ^ri_live_[A-Za-z0-9_-]+$; server tokens
(ri_srv_) are rejected. No message, log or status ever contains a token.
Requiring the module where RunService:IsClient() is true and RunService:IsServer() is false
raises an error. Both are checked because Studio's Run mode reports true for both.
Experiment hashing is a public contract
The variant index is fmix32(FNV1a32("<ExperimentName>:<UserId>")) % #Variants + 1, where the
user id is formatted as an integer. FNV-1a alone keeps the parity of the input bytes in its lowest
bit, so two-variant experiments would line up with each other; the MurmurHash3 fmix32 finalizer
removes that correlation. Products are computed in 16-bit halves because a 32 x 32-bit product does
not fit exactly in a double. Changing any part of this reshuffles every running experiment, so it
must stay fixed across SDK versions. The test suite pins reference values computed independently.
Batching and size estimation
A batch is cut by adding events from the head of the queue while both limits from the connect
response hold: maxEventsPerBatch, and an estimated size of 256 bytes reserved for the envelope
plus each event's JSON length and one byte for the separating comma. Each event is encoded once
when it is considered, so the estimate is exact for the events and conservative for the envelope.
An event that cannot fit into any request on its own, or cannot be encoded as JSON, is discarded and
counted in Dropped, so it can never block the queue. When a batch leaves events behind because it
hit a limit, the next batch goes out on the following tick instead of waiting for the flush
interval. A 413 response still splits the batch in half with new batch ids.
The background thread wakes once per second. Public calls only set fields and append to the queue,
which is why Flush() takes effect within about a second and why at most one request is ever in
flight.
Shutdown budget
Roblox gives game:BindToClose handlers 30 seconds. The SDK's handler ends every open session,
marks the agent as closing and waits, polling every 0.1 seconds, for at most 25 seconds. The
background thread then drains: it reconnects if needed, sends batches and honours retry waits, but
stops 3 seconds before the 25 second deadline to leave time for the disconnect request. Events that
could not be sent in time are lost and remain visible in QueueSize. After a 403 the server is
halted and shutdown returns immediately without a disconnect.
Lazy session start
Your own PlayerAdded handlers can run before the SDK's and already track the player. The first
event for a player without a session therefore starts the session, and the SDK's own PlayerAdded
handler then reuses it, so each join produces exactly one session_start. A player who has left
never gets a new session: events reported during or after PlayerRemoving, such as a save on leave,
are sent with the user id but without a session id. Sessions are not started while the server is
shutting down.
Country lookup and session ordering
GetCountryRegionForPlayerAsync yields, so the session is created at once (its id and join time are
fixed then) and only its session_start event waits. A lookup thread and a 3 second timer race to
send it; whichever finishes first sends it with the original join time, and the other does nothing.
If the player leaves first, PlayerRemoving sends session_start without country immediately and
session_end right after, so the start always precedes the end in the queue. The same happens for
every open session when the server shuts down. Player events tracked while the lookup runs carry
the session id but can be queued ahead of their session_start; the server joins them by session
id, not by order.
Server health cost
The Heartbeat handler only adds to four numbers on an existing table: frames and elapsed time for
the batch, and the same pair for the current second. It allocates nothing and reads no services.
fps is frames divided by elapsed time, the true average rate; averaging 1 / delta per frame
would let a few very short frames inflate it. fpsMin closes a one-second window each time the
second's elapsed time reaches one second. Everything else (memory, network, instances, ping) is
read once per batch inside pcall. The snapshot is stored with the batch, so a retried batch
resends the same measurements, and a 413 split keeps them on the first half only.
Error capture safety
The ScriptContext.Error handler runs inside pcall and only updates an in-memory table, so it
cannot raise an error of its own and start a loop. Errors are ignored when the reporting script is
the RoInsights module or one of its children, or when the stack trace contains a line from it
(<RoInsights path>. or <RoInsights path>,), so a fault in the SDK is never reported back into the
SDK. Groups are keyed by script path and message; the server applies its own fingerprint that strips
numbers, ids and quoted text. The 50-group cap bounds memory when a script fails with a different
message every frame.
Development
Tools are pinned in rokit.toml (Lune, StyLua, selene, Wally, luau-lsp). From sdk/roblox:
rokit install
stylua --check src tests scripts examples
selene src tests scripts
lune run scripts/test
lune run scripts/build
lune run scripts/test [filter]runs everytests/*.spec.luaufile, each test in a fresh fake world, prints a summary and exits with status 1 on any failure. The optional filter matches test names.lune run scripts/buildwritesdist/RoInsights.rbxm: a ModuleScript namedRoInsightsbuilt fromsrc/init.luau, with one child ModuleScript per othersrcfile and aVersionattribute. It reads the file back and checks every child before exiting, and fails when the versions inwally.tomlandsrc/Settings.luaudisagree. No Rojo is involved.- The same command stages
dist/wally/forwally publish --project-path dist/wally. Wally consumers sync packages with Rojo-compatible tools, which need the generateddefault.project.jsonthere to mapsrc/to a single ModuleScript.
Strict type checking with luau-lsp needs the Roblox definitions file and a sourcemap, which
scripts/sourcemap.luau writes without Rojo so luau-lsp can resolve script.Parent requires:
curl -sSfL -o globalTypes.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
lune run scripts/sourcemap
luau-lsp analyze --platform=roblox --definitions=globalTypes.d.luau --sourcemap=sourcemap.json src
The tests compile the real src files with @lune/luau against fake Roblox globals, a virtual
clock and a cooperative scheduler, so timing and retry behaviour run without real sleeps.
License
MIT. See LICENSE.
Package Details
Install command (Click to copy)
Version
1.1.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
