{"id":"senorlawyer/roinsights","name":"roinsights","scope":"senorlawyer","platform":"roblox","description":"Server-side Roblox SDK for RoInsights, near-real-time analytics for Roblox experiences","version":"1.1.0","latest":"1.1.0","versions":["1.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"cff2bf17718c16b76618585c82bc218c7b3bd4ba81d4b978d8b6df5d12f08260","likes":0,"downloads":0,"install":"forest install senorlawyer/roinsights","url":"https://forest.dev/p/roblox/senorlawyer/roinsights","files":"https://api.forest.dev/ai/package/roblox/senorlawyer/roinsights/files","readme":"# RoInsights Roblox SDK\n\nServer-side Luau SDK for [RoInsights](https://roinsights.dev), near-real-time analytics for Roblox\nexperiences. Install it once in your experience's server and the dashboard shows live sessions,\nretention, funnels, economy, revenue, progression, custom events, version comparison, server\nhealth and script errors within 30 to 60 seconds.\n\n- Runs only on the server. Requiring it from a client errors, and the credential never replicates.\n- Every tracking call validates its input, appends to an in-memory queue and returns. Calls never\n  yield and never throw into your game code.\n- One background thread does all network work, with one request in flight at a time.\n\n## Contents\n\n- [Install](#install)\n- [Set up the credential](#set-up-the-credential)\n- [Quick start](#quick-start)\n- [API reference](#api-reference)\n- [Automatic tracking](#automatic-tracking)\n- [Experiments](#experiments)\n- [Mirroring to Roblox Analytics](#mirroring-to-roblox-analytics)\n- [Limits](#limits)\n- [Delivery, retries and shutdown](#delivery-retries-and-shutdown)\n- [Troubleshooting](#troubleshooting)\n- [Design notes](#design-notes)\n- [Development](#development)\n\n## Install\n\nPut the `RoInsights` ModuleScript in **ServerScriptService** or **ServerStorage**. Anywhere else\nreplicates to clients.\n\n### Model file\n\nDownload `RoInsights.rbxm` from the GitHub Releases page of the RoInsights repository and drag it\ninto ServerScriptService in Studio.\n\n### Creator Store\n\nInsert the RoInsights model from the Creator Store and move it to ServerScriptService. Check that\nthe publisher is RoInsights before you insert it.\n\n### Wally\n\n```toml\n[server-dependencies]\nRoInsights = \"senorlawyer/roinsights@1.1.0\"\n```\n\nRun `wally install`, then require it from `ServerScriptService.ServerPackages.RoInsights` (or\nwherever your project maps `ServerPackages`).\n\n### Enable HTTP\n\nIn Studio, open **Game Settings > Security**, turn on **Allow HTTP Requests** and publish. Without\nit the SDK warns once and keeps retrying.\n\n## Set up the credential\n\nCreate a project in the RoInsights dashboard and copy its project credential. It starts with\n`ri_live_`. Whoever has it can send data to your project, so keep it on the server.\n\n`Init` looks for the credential in this order:\n\n1. **A Roblox secret (recommended).** Add a secret named `ROINSIGHTS_TOKEN` on your experience's\n   Secrets page in Creator Hub, with the credential as the value and `ingest.roinsights.dev` as the\n   allowed domain. The SDK reads it with `HttpService:GetSecret` and sends it as `Bearer <secret>`.\n   It never prints or stores the value. To test in Studio, add the same secret to Studio's local\n   secrets. Use the `SecretName` option to pick a different name.\n2. **The `Token` option** passed to `Init`.\n3. **A `Config` ModuleScript** parented to the `RoInsights` ModuleScript that returns\n   `{ Token = \"ri_live_...\" }`. See [`examples/Config.luau`](examples/Config.luau).\n\nOptions 2 and 3 put the credential in plain text, so the SDK refuses them unless `RoInsights` (and\nits `Config` child) are descendants of ServerScriptService or ServerStorage. If they replicate to\nclients, `Init` warns and stays off.\n\n## Quick start\n\nCall `Init` once from a Script in ServerScriptService:\n\n```lua\nlocal ServerScriptService = game:GetService(\"ServerScriptService\")\nlocal RoInsights = require(ServerScriptService.RoInsights)\n\nRoInsights:Init()\n```\n\nThen track from any server code:\n\n```lua\nRoInsights:Event(Player, \"OpenedShop\", { Tab = \"Gems\" })\nRoInsights:Economy(Player, \"Sink\", \"Coins\", 250, 1200, \"Shop\", \"SpeedBoots\")\nRoInsights:Progression(Player, \"Campaign\", \"Complete\", 3, \"Desert\")\n```\n\n### Examples\n\n- [`Bootstrap.server.luau`](examples/Bootstrap.server.luau) is a Script for ServerScriptService. It\n  initializes the SDK and, on `PlayerAdded`, assigns an experiment, records the starting coins as an\n  economy source, logs the first onboarding funnel step and sets session context. It expects the\n  `RoInsights` ModuleScript in ServerScriptService and the credential in the `ROINSIGHTS_TOKEN`\n  secret (or a `Config` module).\n- [`Config.luau`](examples/Config.luau) is the optional `Config` ModuleScript. Parent it to the\n  `RoInsights` ModuleScript and use it only when you cannot use a secret. Both modules must sit in\n  ServerScriptService or ServerStorage, and a real credential must never be committed to a public\n  repository.\n- [`Purchase.server.luau`](examples/Purchase.server.luau) reports developer products from\n  `MarketplaceService.ProcessReceipt` and game passes from `PromptGamePassPurchaseFinished`. Each\n  product's `Grant` function is where you add the item and save the receipt's `PurchaseId` in your\n  player data, so a retried receipt never grants twice. Report the purchase only after the grant\n  succeeded, then return `PurchaseGranted`.\n\n## API reference\n\nAll methods use colon syntax. `Properties` is an optional flat table of up to 24 keys whose values\nare strings, finite numbers or booleans (see [Limits](#limits)).\n\n### `RoInsights:Init(Config?)`\n\nStarts the SDK. Safe to call more than once; only the first call counts. Call it from one server\nScript before other scripts start tracking. Calls made before `Init` are ignored.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `Token` | `string?` | `nil` | Project credential, if you do not use a secret |\n| `SecretName` | `string` | `\"ROINSIGHTS_TOKEN\"` | Name of the Roblox secret to read |\n| `Endpoint` | `string` | `\"https://ingest.roinsights.dev\"` | Ingest API base URL |\n| `MirrorToRobloxAnalytics` | `boolean` | `false` | Also log to Roblox `AnalyticsService` |\n| `TrackStudio` | `boolean` | `true` | Send data from Studio play sessions (tagged `studio`) |\n| `AutoTrackSessions` | `boolean` | `true` | Track player sessions automatically |\n| `Debug` | `boolean` | `false` | Print `[RoInsights]` logs, including dropped input |\n| `MaxQueueSize` | `number` | `5000` | Events held in memory before the oldest are dropped (100 to 100000) |\n| `CaptureErrors` | `boolean` | `true` | Report server script errors (see [Script errors](#script-errors)) |\n\nUnknown options and wrong types warn once and fall back to the default.\n\n### `RoInsights:Event(Player?, Name, Properties?)`\n\nA named event. Pass `nil` as the player for a server-level event.\n\n### `RoInsights:Custom(Player?, Name, Value?, Properties?)`\n\nA named event with a numeric value, for sums and averages. Mirrors to\n`AnalyticsService:LogCustomEvent` when mirroring is on.\n\n### `RoInsights:Funnel(Player, FunnelName, FunnelSessionId?, Step, StepName, Properties?)`\n\nOne step of a funnel. `Step` is an integer from 1 to 100. Pass a `FunnelSessionId` to tell apart\nrepeated runs of the same funnel, such as several shop visits. Mirrors to\n`AnalyticsService:LogFunnelStepEvent`.\n\n### `RoInsights:Economy(Player, FlowType, Currency, Amount, EndingBalance?, TransactionType, ItemSku?, Properties?)`\n\nA currency source or sink. `FlowType` is `\"Source\"` or `\"Sink\"`, `Amount` must be greater than 0\nand `EndingBalance` is the player's balance afterwards. Mirrors to `AnalyticsService:LogEconomyEvent`\nwhen `EndingBalance` is given, because Roblox requires it.\n\n### `RoInsights:Progression(Player, PathName, Status, Level, LevelName?, Properties?)`\n\n`Status` is `\"Start\"`, `\"Complete\"` or `\"Fail\"`; `Level` is an integer from 0 to 1000000. Mirrors to\n`AnalyticsService:LogProgressionEvent`.\n\n### `RoInsights:Purchase(Player, Info, Properties?)`\n\nA Robux purchase. Call it from your own `ProcessReceipt` after you grant the item; the SDK never\ntouches `MarketplaceService.ProcessReceipt`.\n\n```lua\nRoInsights:Purchase(Player, {\n\tType = \"DeveloperProduct\",\n\tName = \"100 Gems\",\n\tRobux = ReceiptInfo.CurrencySpent,\n\tProductId = ReceiptInfo.ProductId,\n\tReceiptId = ReceiptInfo.PurchaseId,\n})\n```\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `Type` | `string` | `\"DeveloperProduct\"`, `\"GamePass\"`, `\"Subscription\"` or `\"Other\"` |\n| `Name` | `string` | Item name, 1 to 64 characters |\n| `Robux` | `number` | Robux spent, an integer from 0 to 100000000 |\n| `ProductId` | `number?` | Product or game pass id |\n| `ReceiptId` | `string?` | Receipt or purchase id, up to 64 characters |\n\n### `RoInsights:SetPlayerContext(Player, Context)`\n\nAttaches flat key/value data (segments, cohorts, settings) to the player's current session. Only\nkeys whose value changed are sent. Context belongs to one session: set it again after the player\nrejoins. Needs an active session, so it does nothing when `AutoTrackSessions` is off.\n\n### `RoInsights:Experiment(Player, ExperimentName, Variants) -> string`\n\nReturns the player's variant and records it as session context. See [Experiments](#experiments).\n\n### `RoInsights:Flush()`\n\nAsks the background thread to send on its next tick (within about a second). Does not yield.\n\n### `RoInsights:GetStatus()`\n\nReturns a table for debugging:\n\n| Field | Meaning |\n| --- | --- |\n| `Connected` | The server holds a valid server token |\n| `ServerId` | Server session id from the ingest API |\n| `QueueSize` | Events waiting to be sent, including an unacknowledged batch |\n| `Dropped` | Events lost to a full queue, rejected batches or events that cannot be encoded |\n| `DroppedErrors` | Script error occurrences not reported because 50 distinct errors were already waiting |\n| `LastError` | Last delivery error, or why the SDK is inactive |\n\n## Automatic tracking\n\nWithout any tracking calls, the SDK collects the following.\n\n### Player sessions\n\nWith `AutoTrackSessions` on (the default):\n\n- Players already in the server at `Init`, and every later `Players.PlayerAdded`, get a\n  `session_start` with a new session id, `premium`, `accountAgeDays` and `country`.\n- `country` is the two-letter ISO 3166-1 code returned by\n  `LocalizationService:GetCountryRegionForPlayerAsync`. The lookup runs in its own thread and waits\n  at most 3 seconds; the session starts with the time the player joined either way, and without\n  `country` when the lookup fails or is too slow.\n- `Players.PlayerRemoving` sends `session_end`. Duration is computed by the server.\n- Every event for a player carries that player's session id, including events tracked while the\n  country lookup is still running. If your code tracks a player before the SDK's `PlayerAdded`\n  listener runs, the session starts on that first event.\n- Events for a player who already left are still sent, without a session id.\n\n### Server health\n\nEvery batch reports the server's player count and doubles as a heartbeat, which keeps live player\ncounts accurate even when no events happen. It also carries a `server` object measured since the\nprevious batch:\n\n| Field | Source |\n| --- | --- |\n| `fps` | Heartbeat frames divided by the time they covered |\n| `fpsMin` | Lowest heartbeat rate of any full second in that period |\n| `memoryMb` | `Stats:GetTotalMemoryUsageMb()` |\n| `dataReceiveKbps`, `dataSendKbps` | `Stats.DataReceiveKbps`, `Stats.DataSendKbps` |\n| `pingMs`, `pingP90Ms` | Mean and 90th percentile of `Player:GetNetworkPing()` across current players, sampled when the batch is built |\n| `instances` | `Stats.InstanceCount` |\n\nEach value is clamped to the protocol's range, and a value that cannot be read is left out. Without\na heartbeat rate or a memory reading the whole `server` object is left out.\n\n### Script errors\n\nWith `CaptureErrors` on (the default), errors from server scripts (`ScriptContext.Error`) are\ngrouped by message and script between batches. Each group becomes one `error` event with the\nnumber of occurrences, the time of the first one and its stack trace. Messages are cut to 300\ncharacters, script names to 200 and stacks to 1000. At most 50 distinct groups are kept per batch;\nfurther new errors are counted in `DroppedErrors`. Errors raised by RoInsights itself are ignored.\n\n### Privacy\n\nThe SDK sends only what is listed here and what your own tracking calls pass. It never reads chat,\ndisplay names, usernames or other personal data. Players are identified by Roblox `UserId`, and\n`country` is the two-letter region code Roblox provides, not a location or address.\n\n## Experiments\n\n`Experiment` is deterministic: the same player always gets the same variant for the same experiment\nname, on every server, with no network call. It works before `Init` and when tracking is off, so\ngameplay can rely on it.\n\nThe bucket is FNV-1a (32-bit) of `\"<ExperimentName>:<UserId>\"`, passed through the MurmurHash3\n`fmix32` finalizer, modulo the number of variants, plus one. The finalizer stops two-variant tests in\ndifferent experiments from lining up. Renaming an experiment or reordering its variants reshuffles\nplayers.\n\nThe assignment is stored as session context under `Exp_<ExperimentName>`, with characters outside\n`A-Z a-z 0-9 _` replaced by `_` and the key cut to 48 characters. If the input is invalid (no\nplayer, empty name, no variants), it returns the first variant, or `\"\"` when there is none.\n\n```lua\nlocal Variant = RoInsights:Experiment(Player, \"ShopLayout\", { \"Control\", \"Grid\" })\n```\n\n## Mirroring to Roblox Analytics\n\nSet `MirrorToRobloxAnalytics = true` to also log `Custom`, `Funnel`, `Economy` and `Progression`\ncalls to Roblox's built-in `AnalyticsService`. Each call is protected and runs after the event is\nqueued, so a mirroring failure never affects RoInsights data. Roblox accepts three custom fields;\nthe SDK fills `CustomField01` to `CustomField03` from the first three properties in sorted key\norder, formatted as `\"Key - Value\"`. `Event` and `Purchase` are not mirrored: Roblox caps custom\nevent names and already records purchases.\n\n## Limits\n\nThese match the ingest protocol. Input that breaks them is fixed when possible and dropped\notherwise; with `Debug` on, each call logs one line describing what was dropped.\n\n| Field | Limit |\n| --- | --- |\n| Names (`Name`, `FunnelName`, `StepName`, `Currency`, `TransactionType`, `ItemSku`, `PathName`, `LevelName`, purchase `Name`) | 1 to 64 characters; longer names are truncated |\n| Property count | 24 per event; extra keys are dropped in sorted key order |\n| Property keys | Match `^[A-Za-z][A-Za-z0-9_]{0,47}$` |\n| Property values | Strings up to 200 characters (truncated), finite numbers, booleans. Nested tables are dropped |\n| `FunnelSessionId`, `ReceiptId` | Up to 64 characters |\n| Funnel `Step` | Integer from 1 to 100 |\n| Economy `Amount` | Greater than 0 |\n| Progression `Level` | Integer from 0 to 1000000 |\n| Purchase `Robux` | Integer from 0 to 100000000 |\n| Queue | `MaxQueueSize` events; when full the oldest are dropped and counted in `Dropped` |\n\nLengths are counted the way JavaScript counts them (UTF-16 code units), and truncation never splits\na character.\n\n## Delivery, retries and shutdown\n\nThe background thread connects with the project credential, receives a short-lived server token,\nthen sends batches:\n\n- every flush interval (30 seconds unless the server says otherwise), even with no events;\n- sooner when 200 or more events are waiting (at most every 2 seconds), when a batch was full, or\n  after `Flush()`.\n\nBatches respect the server's event and byte limits. A failed batch keeps its id, so the server\ndiscards duplicates when a retry repeats one that actually arrived.\n\n| Response | What the SDK does |\n| --- | --- |\n| 400 | Drops the batch |\n| 401 | Reconnects, then resends the same batch |\n| 403 on events | Stops sending until the server restarts |\n| 413 | Splits the batch in half and sends both halves with new ids |\n| 429 | Waits `Retry-After` seconds, then resends the same batch |\n| 5xx or network error | Retries after 2, 4, 8 ... up to 60 seconds |\n\nConnect failures back off the same way. A rejected credential, an unavailable project or a universe\nmismatch warns once and retries every 5 minutes.\n\nOn shutdown the SDK's `game:BindToClose` handler ends every open session, sends what it can within\n25 seconds (Roblox allows 30) and then disconnects the server.\n\n## Troubleshooting\n\nCall `RoInsights:GetStatus()` from the server console, or set `Debug = true`.\n\n| Mes","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/senorlawyer/roblox/roinsights/1.1.0/readme"}