Forest Logo
search
package_2

roinsights

By @senorlawyer

Roblox

Mirrored from Wally

RoInsights 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

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:

  1. A Roblox secret (recommended). Add a secret named ROINSIGHTS_TOKEN on your experience's Secrets page in Creator Hub, with the credential as the value and ingest.roinsights.dev as the allowed domain. The SDK reads it with HttpService:GetSecret and sends it as Bearer <secret>. It never prints or stores the value. To test in Studio, add the same secret to Studio's local secrets. Use the SecretName option to pick a different name.
  2. The Token option passed to Init.
  3. A Config ModuleScript parented to the RoInsights ModuleScript that returns { Token = "ri_live_..." }. See examples/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.luau is a Script for ServerScriptService. It initializes the SDK and, on PlayerAdded, assigns an experiment, records the starting coins as an economy source, logs the first onboarding funnel step and sets session context. It expects the RoInsights ModuleScript in ServerScriptService and the credential in the ROINSIGHTS_TOKEN secret (or a Config module).
  • Config.luau is the optional Config ModuleScript. Parent it to the RoInsights ModuleScript 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.luau reports developer products from MarketplaceService.ProcessReceipt and game passes from PromptGamePassPurchaseFinished. Each product's Grant function is where you add the item and save the receipt's PurchaseId in your player data, so a retried receipt never grants twice. Report the purchase only after the grant succeeded, then return PurchaseGranted.

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.

OptionTypeDefaultMeaning
Tokenstring?nilProject credential, if you do not use a secret
SecretNamestring"ROINSIGHTS_TOKEN"Name of the Roblox secret to read
Endpointstring"https://ingest.roinsights.dev"Ingest API base URL
MirrorToRobloxAnalyticsbooleanfalseAlso log to Roblox AnalyticsService
TrackStudiobooleantrueSend data from Studio play sessions (tagged studio)
AutoTrackSessionsbooleantrueTrack player sessions automatically
DebugbooleanfalsePrint [RoInsights] logs, including dropped input
MaxQueueSizenumber5000Events held in memory before the oldest are dropped (100 to 100000)
CaptureErrorsbooleantrueReport 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,
})
FieldTypeNotes
Typestring"DeveloperProduct", "GamePass", "Subscription" or "Other"
NamestringItem name, 1 to 64 characters
RobuxnumberRobux spent, an integer from 0 to 100000000
ProductIdnumber?Product or game pass id
ReceiptIdstring?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:

FieldMeaning
ConnectedThe server holds a valid server token
ServerIdServer session id from the ingest API
QueueSizeEvents waiting to be sent, including an unacknowledged batch
DroppedEvents lost to a full queue, rejected batches or events that cannot be encoded
DroppedErrorsScript error occurrences not reported because 50 distinct errors were already waiting
LastErrorLast 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 later Players.PlayerAdded, get a session_start with a new session id, premium, accountAgeDays and country.
  • country is the two-letter ISO 3166-1 code returned by LocalizationService: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 without country when the lookup fails or is too slow.
  • Players.PlayerRemoving sends session_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 PlayerAdded listener 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:

FieldSource
fpsHeartbeat frames divided by the time they covered
fpsMinLowest heartbeat rate of any full second in that period
memoryMbStats:GetTotalMemoryUsageMb()
dataReceiveKbps, dataSendKbpsStats.DataReceiveKbps, Stats.DataSendKbps
pingMs, pingP90MsMean and 90th percentile of Player:GetNetworkPing() across current players, sampled when the batch is built
instancesStats.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.

FieldLimit
Names (Name, FunnelName, StepName, Currency, TransactionType, ItemSku, PathName, LevelName, purchase Name)1 to 64 characters; longer names are truncated
Property count24 per event; extra keys are dropped in sorted key order
Property keysMatch ^[A-Za-z][A-Za-z0-9_]{0,47}$
Property valuesStrings up to 200 characters (truncated), finite numbers, booleans. Nested tables are dropped
FunnelSessionId, ReceiptIdUp to 64 characters
Funnel StepInteger from 1 to 100
Economy AmountGreater than 0
Progression LevelInteger from 0 to 1000000
Purchase RobuxInteger from 0 to 100000000
QueueMaxQueueSize 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.

ResponseWhat the SDK does
400Drops the batch
401Reconnects, then resends the same batch
403 on eventsStops sending until the server restarts
413Splits the batch in half and sends both halves with new ids
429Waits Retry-After seconds, then resends the same batch
5xx or network errorRetries 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 symptomFix
HTTP requests are disabledTurn on Game Settings > Security > Allow HTTP Requests and publish
no credential foundAdd the ROINSIGHTS_TOKEN secret, or use the Token option or a Config module
refusing to use a plain-text token: ... replicates to clientsMove RoInsights to ServerScriptService or ServerStorage, or use a secret
not a project credentialUse the ri_live_ project credential, not a server token
belongs to a RoInsights project configured for another universeUse the credential of the project for this experience, or change the project's universe
the project credential was rejectedThe credential was revoked or rotated. Copy the current one from the dashboard
the SDK is server-onlyRequire RoInsights from a server Script, not a LocalScript
Nothing arrives from StudioTrackStudio is false, or Studio has no local secret. Studio data is tagged studio in the dashboard
Dropped keeps growingThe 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

ModuleRole
initPublic 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
RuntimeThe 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
SettingsMerges 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
CredentialCredential resolution and the replication check
ValidateCheap sanitizers that run on the caller's thread
QueueBounded 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
BatchCuts request-sized batches off the queue
TransportThe HTTP boundary. Every request runs in one protected call and returns a result with a status code; status 0 means the request never completed
AgentPlayer sessions, the queue and the single background thread that connects, sends, retries and shuts down
HealthHeartbeat rate counters and the per-batch server snapshot
ErrorsGroups ScriptContext.Error reports between batches
MirrorOptional copy into Roblox AnalyticsService
ExperimentDeterministic variant assignment
TypesShared 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 every tests/*.spec.luau file, 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/build writes dist/RoInsights.rbxm: a ModuleScript named RoInsights built from src/init.luau, with one child ModuleScript per other src file and a Version attribute. It reads the file back and checks every child before exiting, and fails when the versions in wally.toml and src/Settings.luau disagree. No Rojo is involved.
  • The same command stages dist/wally/ for wally publish --project-path dist/wally. Wally consumers sync packages with Rojo-compatible tools, which need the generated default.project.json there to map src/ 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

check_circle

Safe for commercial use

Automated license review — not legal advice.