Skip to content

Analytics ​

TypeTorch has its own analytics. It is optional and fully yours: your game logs what players do, your own backend stores it, and you query it yourself. It doesn't depend on Roblox's analytics (the explorer can show Roblox's own numbers next to yours when the backend has an Open Cloud key). Every event is labelled with the build (artifact) that was live, so you can compare builds, experiments and devices.

  • Write side: AnalyticsEngine in @typetorch/framework (0.3.0+), on the server and the client.
  • Backend, your choice: a self-hosted DuckDB server (one small VPS), or Cloudflare Basin (no server).
  • Read side: the backend (@typetorch/backend, run from a checkout or its Docker image; not on npm): the queries, the DuckDB server, the fleet API, bun run report, and the web explorer at the backend's URL (The explorer: its pages, filters and command palette).
  • Live experiment overrides: typetorch experiment (CLI 0.10.2+; below), or prepared on the explorer's Experiments page.

Contents: Using it in a game · What is collected · The event format · Backends · Settings · Experiments · Queries · Node graphs · bun run report · Privacy · Quick start: a local test

Related: The explorer · Recordings and maps · Live events · Player feedback · Notifications

Using it in a game ​

Nothing runs until you create an engine: no connections, threads or requests. Games that don't use it pay only the module load.

ts
// A server module (onInit)
import { AnalyticsEngine } from "@typetorch/framework";

const analytics = new AnalyticsEngine();
analytics.track(player, "quest_done", { quest: "tutorial" }); // anything custom
analytics.step(player, "onboarding", 3, "opened_shop"); // a funnel step: funnel, index, name
analytics.purchase(player, { product: 1234, robux: 99, where: "shop" }); // after the receipt is granted
analytics.currency(player, "coins", 50, "round_reward"); // economy: + in, - out
analytics.state("round"); // every player's activity; analytics.state(player, "shop") for one
const variant = analytics.experiment(player, "onboarding", ["short", "long"]); // the first is the control
ts
// A client controller (onStart): everything goes through the server, never to the internet
const analytics = new AnalyticsEngine();
analytics.track("opened_map");
analytics.screen("Inventory"); // for UIs that aren't separate ScreenGuis
const variant = analytics.experiment("onboarding", ["short", "long"]); // the same answer as the server's
  • One engine per generation and realm. Every new AnalyticsEngine() joins the one already running (the first one's options win), so any module can create its own. It stops with the generation. Unsent rows wait in persist and the next generation sends them, so a hot swap loses nothing.
  • It starts on the first new AnalyticsEngine() of the generation, not before. Create it in the onInit of a module with a low loadOrder. Created only from a helper that first runs on a game event, it doesn't start on an idle server, and that server sends nothing (no sessions, no tech rows).
  • Server calls take the player first. Without a player, an event is server-only (no player id).
  • Client calls are about the local player. They are marked src = "client" (a client can lie), and the server checks their shape, size (props at most 4 KB) and rate (120 a minute, 5,000 a session; a client's experiment calls count too). purchase and currency are server-only (revenue and the economy are server-authoritative): on the client they warn once and send nothing, and both the game server and the analytics server refuse them from clients.
  • Revenue counts server-sent purchases only. Payers and Robux come from purchase rows the server sent. Clients can't send purchase or currency at all (framework after 0.3.2; older engines' client rows are refused at ingest and never counted). Rows from before src existed still count. Call analytics.purchase on the server, after the receipt is granted.
  • The cloud test sends nothing (framework after 0.3.2). In typetorch test --cloud (before every prod deploy) the engine collects as usual but never uploads: no event rows, no identity rows, no HTTP request. A prod deploy never adds a fake server session.
  • Options (all on by default): sessions, tech, zones, screens, recording, fleet, identity (privacy). positions (framework 0.7.1+) is a list of event names that always carry the player's position (below). settings (server only) replaces the settings key, for tests.
  • analytics.stats() (server) returns the counters (queued, sent, dropped, refused); analytics.flush() sends soon.
  • In edit mode (UI Labs stories) it is inert.
  • The template has a complete example: AnalyticsService (server) and AnalyticsController (client).
  • Game settings: Allow HTTP Requests must be on (Game Settings > Security).

What is collected ​

On every event: the time, a random player id (never the UserId), the session, the server (JobId, type, place), the build (artifact id, #seq, branch, channel), the device, new vs returning, the player's experiment variants, and the player's state (zone:Lobby|screen:Shop|activity:round).

Automatic (the same in every game):

GroupOptionEvents
Sessionssessionsjoin and leave, session length, where they came from (direct, teleport, friend follow, share link...), device and input type, screen size, account age bucket, Premium, country, language (locale), the player policy (ads, paid random items, trading, sharing, subscriptions, China, allowed links; framework 0.6.3), the device trust level (Player.DeviceTrustLevel, settled on the leave row; framework 0.6.3), friends in the server (one friend-list lookup per join), first visit or returning, days since the last visit
Tech healthtechserver FPS and heartbeat, client FPS, ping, memory, load time, client and server errors, hot swaps and rollbacks, and who left within 60 s of a swap
Zoneszonesentering and leaving zones: parts or models tagged TTZone in the place, named by a Name attribute or the instance name. The smallest zone around the player wins
ScreensscreensScreenGuis in PlayerGui, and GuiObjects tagged TTScreen
First sessionrecordingbelow
Fleetfleeteach server's status and deploy reports from the kernel, for history (Fleet and alerts is the live view)

From your code (game-specific): funnels such as onboarding (step), purchases (purchase), currency in and out (currency), activities (state), and anything custom (track).

Never collected: chat or anything typed, usernames and display names (error texts have them replaced with <player>), UserIds.

The first-ever session, recorded in detail ​

Only a player's first session in the game is recorded, from the start until recordSeconds after their first input (60 s by default, 10-600; at most 10 minutes from the start; framework 0.7.1+, older frameworks always use 60 s). About 10 times a second it samples the character position and facing and the camera. It also records each input (key, click, tap, gamepad button, thumbstick), each button pressed or hovered (by path, e.g. Shop/Buy/Coins100), screens opened and closed, proximity prompts, deaths and spawns, and your own track() events in that window.

  • Keys are never recorded while a TextBox or the chat has focus. A text box only says which box was used, never its text.
  • It is packed into small binary chunks (tt-rec-1), sent through the server: about 10-20 KB per new player at the default 60 s, about 55-70 KB at 300 s and 110-135 KB at 600 s. A recording also ends at 256 KB or 80 chunks.
  • recordShare (0 to 1) in the settings picks the share of new players who are recorded (stable per player). Turn it down first when volume spikes.
  • The explorer's Recordings page draws them on a map and replays them: Recordings and maps.

Positions ​

Framework 0.7.1+. Game-code events (custom, funnel, purchase, currency) can carry the player's character position as the props px, py, pz (studs, rounded to 0.1; the model's PrimaryPart, else HumanoidRootPart). The explorer's map layers read exactly these keys.

ts
// One event: the event's player (client: the local player), or any player
analytics.track(player, "down", { cause: "fall" }, { position: true });

// Every event with one of these names, whichever call logs it (at most 64 names)
const analytics = new AnalyticsEngine({ positions: ["down", "death_final", "round_result"] });
  • The settings' positions list (below) adds names live, without a deploy. A client event logged before the server's hello doesn't know that list yet; the engine option works from the start.
  • Nothing is added without a character or root part, and props the game set itself (px, py or pz) are never overwritten.
  • Which map: put the map's name in a map prop on some event (for example track(player, "round_start", { map: "Forest" })). The backend files each position under the latest map event before it, and exports the models that match those names (Recordings and maps).

The event format ​

Two tables of flat JSON rows. Every column is always present: a missing value is "", 0 or false. The full contract, with every event and its props, is SCHEMA.md in the framework.

events:

ColumnMeaning
vformat version, 1
tunix ms, on the game server's clock (client events are corrected)
kindsession, tech, zone, funnel, purchase, currency, state, experiment, custom, recording_meta, fleet, notify (below), feedback (Player feedback)
namethe event name (1-64 characters)
pid, sidrandom player id and session id ("" on server-only events)
job, srv, placeJobId, server type (public, private, reserved, studio), PlaceId
art, seq, branch, channelthe build that was live
devdesktop, phone, tablet, console, vr, unknown
newppart of the player's first-ever session
statethe player's state after the event, zone:<z>|screen:<s>|activity:<a>
expthe player's experiments as JSON, {"onboarding":"short"}
sexpthe server's experiment: the artifact id of an A/B pin, or ""
srcserver or client (backend on rows the backend writes itself: notification rows)
propsthe event's own data as JSON text, at most 4096 bytes

recordings (first sessions): v, t, pid, sid, job, art, chunk, codec (tt-rec-1), data (base64), n.

Delivery is at least once: a hot swap during a request sends that batch again. The DuckDB server drops exact duplicates when it writes each day's file.

The join row ​

The session row written when a player joins carries all of Roblox's join data and the player facts, in props:

PropFromNote
fromdirect, teleport, teleport_game, referral, share, follow, or notification (a notification tap whose launch data verified)
nid, nkthe launch datathe notification's send id and kind, only with from = "notification"
launchLaunchDatanotification (verified), tt_bad (starts with tt1n. but fails the check; launch_bad says why: sig, user, old, format), or, for your game's own launch data, its first 32 characters only when the analytics setting joinLaunchPrefix is true (in the settings record's backend.analytics; default off, since launch data can hold anything). Its length is always kept as launch_len
src_place, src_gameSourcePlaceId, SourceGameIdpublic ids
refReferredByPlayerIdthe referrer's pid when this backend has seen them, else "other"; never a UserId
followFollowUserIdthe same treatment
party, party_pidsMembersthe party's size, and the pids of members seen before
ctx, ctx_item, ctx_asset, ctx_outfit, ctx_asset_typeGameJoinContextJoinSource, ItemType, AssetId, OutfitId, AssetType
tp, tp_bytesTeleportDatawhether there is any, and its JSON size (never the content)
age_daysAccountAgeexact days, next to the age bucket
localePlayer.LocaleIde.g. en-us; the explorer's language card and lang filter read it. Framework 0.7.1 writes it on every join row (also with joinData "off"); 0.6.0-0.7.0 wrote it with the rest of the join data, not with "off"
verifiedHasVerifiedBadge
membershipMembershipTypeits name

No raw UserId of another player leaves the game: the row's own pid stays its only identity. Other players' pids (ref, follow, party_pids) cost a DataStore read each, so a join looks up at most 2 of them; the rest count as "other" or aren't listed.

jd: the raw join data ​

Next to the summary props above, the join row keeps a privacy-safe copy of Player:GetJoinData() itself as the prop jd, field by field, so you can see exactly what Roblox handed the server (for example, whether a notification's launch data arrived at all). It is on by default; backend.analytics.joinData turns it off ("summary") or trims the join row further ("off").

jd fieldFrom GetJoinData()What is kept
keysthe table's top-level keysevery key Roblox returned, sorted
LaunchDataLaunchDatathe string, cut to 200 bytes (Roblox's own limit) at a UTF-8 boundary. Present only when Roblox sent the key, even as ""
LaunchDataLenLaunchDataits full length in bytes, before any cut
SourcePlaceId, SourceGameIdthe samenumbers (public ids), present only when Roblox sent them
GameJoinContextGameJoinContextevery field as a string (enum items by name). {} means Roblox sent the key with nothing in it; no GameJoinContext means it didn't send the key
TeleportDataTeleportData{ bytes, type, keys }: its JSON size, its Luau type and, for a table, its top-level keys (at most 20, each cut to 32 characters, sorted). Never its content
MembersMembers{ count, pids }: each party member's pid, or "other". Never a UserId
ReferredByPlayerId, FollowUserIdthe samethe player's pid, or "other"; absent when Roblox sent 0 or nothing. Never a UserId
otherany key Roblox adds later{ "<key>": "<type name>" }: the type only, never the value
trunctrue only when jd was cut to fit its cap
  • Other players are pids or "other". Looking a player's pid up costs a DataStore read; jd and the summary props (ref, follow, party_pids) share the same budget of at most 2 reads per join, and one lookup per player serves both. Everyone past the budget, or never seen, is "other".
  • At most 1 KB. When the JSON is bigger, it is cut in this order until it fits, and trunc: true is set: TeleportData.keys (then TeleportData itself), Members.pids, LaunchData (shortened; LaunchDataLen keeps the real length), each GameJoinContext value (to 64 characters), then other.
  • Where you see it: the explorer's player Timeline shows it on the join event (pretty-printed, collapsible, with a trunc badge when cut). The Notifications page's per-player view shows the opening session's jd next to each opened notification, where the backend provides it.

Example: a player tapped a notification, but the launch data never reached the server (there is no nid / nk, and from isn't notification). The row's jd shows that the key arrived empty. (This was a real case: the send put join_experience at the top level of the Open Cloud body instead of inside payload, so Roblox ignored it; the backend since puts it inside payload.)

json
{ "keys": ["GameJoinContext", "LaunchData"], "LaunchData": "", "LaunchDataLen": 0, "GameJoinContext": {} }

Notification rows ​

Notifications write rows with kind = "notify". The game server writes the framework's; the backend writes its own with src = "backend", sid empty, and pid from the UserId it has seen before (else empty: the row still counts). No row holds a UserId.

nameWritten byProps
wantgame serverk (kind), key (the first 12 hex of the key's SHA-256), at, until, offline
dropgame serverk, key, why (the game's)
optin_checkgame server (from the client)can, where, err
optin_showngame serverwhere
optin_closedgame serverwhere, secs (how long the prompt was up)
optin_inferredbackendstate: subscribed (a first send was accepted) or not_subscribed (Roblox said "not opted in")
plannedbackendnid, k, for (the send time), why (asap / hour)
skippedbackendk, why (online, quiet, capped_day, capped_week, lapsed, muted, outranked, expired, dropped, pref_off (the player switched the kind off: setOff), repeat)
sentbackendnid, k, try, roblox_id, category
failedbackendnid, k, http, code (e.g. bad_template, daily_limit, dup_refused), final
opengame servernid, k, age (seconds since sent), first
open_badgame serverwhy (sig, user, old, format)
playedgame servernid, k, secs (the opened session's length)

Roblox doesn't tell the game whether a player accepted the opt-in prompt, so optin_closed never says; the backend's optin_inferred is the only answer.

Backends ​

The game speaks one event format. An adapter on each side translates it for the backend you pick: the framework's sink writes (basin or duckdb), and the read side's store renders each query as SQL for that backend.

DuckDB serverCloudflare Basin
What you runone Bun process on a small VPS (1 GB is enough)nothing: Cloudflare's pipelines into Iceberg tables on R2
Ingestgzip batches, appended to raw files firstJSON arrays to two stream endpoints
Delaysecondsone roll interval (60 s minimum; expect 1-2 minutes)
Queriesbun run report, the HTTP API, createStorecreateStore (Basin SQL, read-only)
Deleting a player's rowsyesno (rows become anonymous)
Live server statusthe fleet API runs in the same processrun the fleet API alone (TYPETORCH_PARTS=fleet)

The DuckDB server ​

The backend's analytics part (src/server/main.ts in the backend repo): Bun or Node 20+, DuckDB inside the process (no database server).

  • Raw files first. Each accepted batch is appended to a raw file and answered at once. A loader moves raw files into DuckDB every 5 s. A spike fills files, never the database, and nothing accepted is lost on a restart.
  • One Parquet file per day. After midnight UTC each finished day is written as a compressed Parquet file (exact duplicates dropped), then rollups run. Queries read today's DuckDB file plus the day files in range. Old data goes by deleting old day files (TYPETORCH_KEEP_DAYS, 400 by default).
  • Fits in 1 GB: DuckDB is capped at 400 MB and spills to disk. Measured on one core: 10,000 events/s at a p99 of 35 ms and about 300 MB of memory. 1,000 players online is about 10 million events a day, far below that.
  • Tokens: game servers send with the write-only game key (TYPETORCH_API_KEY); queries need the admin token (TYPETORCH_ADMIN_TOKEN), which stays on your PC.
  • A 1 GB VPS: the backend README has the full guide (swap file, firewall, a systemd service, Caddy for TLS, backups): Run it on a VPS without Docker. Back up the day files and the raw archives off the VPS.

Cloudflare Basin ​

Done once, in your own Cloudflare account (Workers Paid plan and R2). The backend README has every prompt: Cloudflare Basin setup. In short:

  1. Create two pipelines, typetorch_events and typetorch_recordings, with npx wrangler basin pipelines setup. Load their schemas from the backend repo's generated files, basin/events.schema.json and basin/recordings.schema.json. HTTP endpoint on, authentication on, Data Catalog (Iceberg), roll interval 60 s.
  2. Create a send-only API token (Basin Pipelines Send). Game servers get this one.
  3. Keep the R2 token (for Basin SQL), your account id and the bucket on your PC only.
  4. No CLI command writes this sink (since CLI 0.9): typetorch backend setup makes the game send to the backend instead. A Basin sink already in the record (settings) stays there until you run it.

Know these before you start:

  • Rows that don't match the schema are dropped silently (the endpoint still answers 2xx). The framework sends every column on every row, so use the generated schemas exactly.
  • Schemas are fixed when a stream is created. A new format version means new streams.
  • Basin SQL is read-only. There is no DELETE, and every query needs a LIMIT of at most 10,000 (the queries handle this). It is billed by data scanned.
  • Not yet tested against a live Basin account (the backend README lists the open points).

Settings: the analytics field ​

Game servers read their sink settings from the game's signed settings record (kernel 0.3.8+). It is server-only (never sent to clients), and a change reaches running servers within seconds, so you change dials live, with no deploy or place publish.

  • DuckDB (framework 0.4+, kernel 0.4+): the record's backend = { url, key, analytics? } is the sink: events go to <url>/v1/ingest with the game key, and the dials come from backend.analytics (flushSeconds, recordShare, recordSeconds, positions, techEvery, experiments, and the join data dials joinData and joinLaunchPrefix: Settings).
  • Basin, and older frameworks: the analytics field below. backend setup keeps deriving it from backend for frameworks before 0.4; a Basin sink is always read from it.
json
{
  "backend": "duckdb",
  "events": "https://analytics.example.com/v1/ingest",
  "token": "<the game key, write-only>",
  "flushSeconds": 15,
  "recordShare": 1,
  "techEvery": 60,
  "experiments": { "onboarding": { "weights": [1, 1] } }
}
FieldMeaning
backendduckdb or basin
eventsDuckDB: the server's ingest URL. Basin: the events stream endpoint
recordingsBasin: the recordings stream endpoint (without it nothing is recorded). DuckDB: unused
tokenthe write-only token: the DuckDB ingest token, or the Basin send token. Never logged
flushSecondsseconds between sends, 5-300 (default 15)
recordShareshare of new players recorded in detail, 0-1 (default 1)
recordSecondsseconds a first-session recording runs after the first input, 10-600 (default 60; framework 0.7.1+)
positionsevent names (1-64 characters, at most 64) whose game-code events always carry px, py, pz, added to the engine option (Positions; framework 0.7.1+)
techEveryseconds between tech samples, 15-3600 (default 60)
experimentslive dials per experiment

How to write it: backend setup writes it from the game folder, after it checks the backend's address and both keys (settings):

sh
bun run typetorch backend setup --url https://backend.example.com --flush-seconds 15 --record-share 1

It derives this section from backend and keeps the other fields (techEvery, experiments). typetorch experiment writes the experiment overrides (below). No command sets techEvery, recordSeconds, positions or the join data dials yet (backend setup keeps whatever is already in backend.analytics), and settings set analytics only points to backend setup. Read it back with bun run typetorch settings get analytics (the token hidden), and stop sending with bun run typetorch settings unset backend (it drops fleet and analytics too). The CLI signs the record with both prod keys, and servers refuse one that doesn't verify.

From code, writeBackendSettings({ gameDir, url, apiKey, adminToken }) from @typetorch/backend runs the same command.

Without settings the engine still collects, but keeps only the newest 1,000 rows until settings appear. Removing them stops sending, live. On a kernel before 0.3.8 the engine sends nothing and warns once (update the kernel).

Experiments ​

Our own A/B tests, per player or per server.

Per player: analytics.experiment(player, "onboarding", ["short", "long"]) returns the player's variant. It is fixed per player and experiment name, so they keep it in every session, and both groups play on the same servers. The first call may yield briefly (until the player's id loads). Later events carry the variant in exp. You write the code for each variant.

A session holds at most 32 experiments (framework after 0.3.2). A 33rd name isn't assigned: the call returns the first variant (the control), isn't stamped, and logs a warning.

Change it live with typetorch experiment, no deploy. It writes the override into the signed settings record (the same signed write as settings set: read, verify, change, sign with both keys, ping), so servers pick it up within seconds:

sh
bun run typetorch experiment list                        # every override, with the variants the backend saw (7 days)
bun run typetorch experiment weights onboarding 3,1      # 75% get the first variant, 25% the second
bun run typetorch experiment settle onboarding long      # everyone gets "long", stamped: make it final
bun run typetorch experiment stop onboarding             # everyone gets the control (the first variant), not stamped
bun run typetorch experiment resume onboarding           # drop stop / settle, keep the weights
bun run typetorch experiment clear onboarding            # remove the override: equal split again
bun run typetorch experiment force onboarding 123456 long   # one player (UserId or pid) gets "long", for QA
bun run typetorch experiment unforce onboarding 123456
Dial (in the override)Set byEffect
"active": falsestopeveryone gets the first variant (not stamped)
"weights": [3, 1]weights75% / 25%, in the order the game lists the variants
"variant": "long"settleone variant for everyone
"players": { "<UserId or pid>": "long" }forcethose players get that variant over everything else (at most 200; needs a framework that reads players)
  • The variants live in game code, so the CLI can't check them by itself: --variants a,b (the game's list, in order) makes weights and settle check the count and the name first. --dry-run shows the override before and after.
  • Weights move as few players as possible: a player only switches when the boundary moves past their hash.
  • It needs a backend in the record (typetorch backend setup) first. The overrides live in backend.analytics.experiments (framework 0.4+), mirrored into the old analytics.experiments. Every change is logged to experiments.jsonl in the state dir; settings status lists the overrides.
  • The explorer's Experiments page shows each experiment's state, weights and forced players, and opens the players of a variant (GET /v1/experiments lists the overrides the backend knows; GET /v1/experiments/<key>/players the players, at most 200, each random or forced).

Pick your own variant (framework 0.7.1+). In game, the dev menu's Server > Experiments lists every experiment the game asked about for you, your variant and why (random, forced by the settings, or your pick). A button per variant picks it for you, Clear drops the pick; the command palette has the same as "Experiment <name>: use <variant>". Devs where the dev-only tools are open to them, owners everywhere (the dev menu); at most one change a second, and only ever your own variant. The pick is stored in the analytics DataStore (dexp/<UserId>), follows you to every server, wins over the settings (stop, settle, weights, players), and is stamped forced on the experiment row, so the results leave you out. The game sees it the next time it asks for your variant, usually your next join.

Run it from the explorer (owners). Under an experiment's results, the Run it panel has Start, Stop, Clear override, Change split and Settle. The backend never holds the signing keys, so it can't apply the change: it checks it, logs it in the audit log (experiment.prepare) and answers the exact command to run in the game's folder, with its --dry-run twin (POST /v1/experiments/<key>/change):

sh
typetorch experiment weights onboarding 3,1 --variants short,long

Viewers see no controls, and the route answers them 403.

Per server: a share of servers runs a different build: a kernel A/B pin (bun run typetorch pin <artifact> --branch prod --pct 10, signed on prod; on dev branches also the dev menu's "Load a build" > Share of a branch). Events from those servers carry the pinned artifact in sexp. The experiment query with scope: "server" compares each pin with the unpinned servers. Whole-server changes need more players to show a difference.

Results come per variant (players, returned, payers, playtime, Robux, sessions) with "how sure" in plain words: long keeps more players: 99% sure (a two-proportion test; bootstrap or Welch for averages). Per player they also:

  • leave forced players out: a player whose experiment row in the range is marked forced (settled, force, or a dev's own pick) didn't get a random variant; forcedPlayers counts them;
  • check the split (sample ratio mismatch): the random assignments per variant against the override's weights, or an even split, with a chi-square test. Below p = 0.001 (at least 5 players expected per variant) the page warns not to trust the numbers: a variant that crashes or loses its events, a bug in when the game asks, bots, or a split changed inside the range. Uneven weights need the variant's position on the rows (vi, framework 0.7.1+); older rows are checked against an even split only;
  • correct for several variants: each variant is compared with the control, and with more than two variants sure uses Holm-corrected p-values (pAdjusted; the test's own is pValue);
  • say what the traffic can find: mde / mdeLift (the smallest true difference these group sizes find 80% of the time at 5%, the 5% split over the variants when there are more than two), needed (players per group for the observed difference) and days (how long at the range's traffic until it is sure; 0 = now, null = no difference to find).

Queries ​

Every query works on both backends, with the same filters: from, to (a date, ISO time or unix ms; a date-only to includes that day), art, branch, channel, dev, players ("new" or "returning"), variant ({ experiment, variant }), sexp, place. Without from, a query covers its default range.

Audience filters (player-level): country (ISO codes, "US"), lang (language codes without the region, "en", from the join row's locale), age (account-age buckets <1d, 1-7d, 7-30d, 30-90d, 90-365d, 1-3y, 3y+), prem (true / false) and source (the join's from: direct, teleport, referral, ...). Each takes one value or a list. A player matches when one of their join rows in the range has all the set values, and then all their rows count, so "players from this campaign" works on every query. Queries over server or fleet rows (perf-server, servers, deployReport, the server parts of benchmarks and realtime) don't take them, as they don't take dev.

QueryWhat it answersDefault range
Players and retention
overviewplayers, new players, sessions, playtime, in total and per day (per hour, 5 min or minute for windows of a day or less)30 days
robloxRoblox-style numbers: first-play bounce (first session under 60 s), qualified plays (sessions of 5 min or more), D1/D7, playtime and play days per user, payer conversion, Robux per user and per payer30 days
benchmarks, realtime, trendsthe Roblox page: headline numbers as Creator Hub defines them for the last full UTC days vs the days before; concurrent players (peak and average, by device); daily or 12 / 6-hour moving averages per join source7, 7, 28 days
retentionretention by join-day cohort (days: up to 31 offsets from 1 to 90; default 1, 3, 7, 14, 30)30 days
ltvrevenue per join-day cohort: cumulative Robux per player, revenue per player (after Roblox's 30%), payer share by D0 to D3030 days
churnchurn risk per player: active, at_risk, lapsing, churned, from days since the last play against their usual gap90 days
demographicswho plays, from join rows: country, language, device, account age, Premium (share of players)28 days
explainwhy a metric moved between two windows: per segment (device, country, language, account age, Premium, join source, build, branch, new / returning), split into rate and mix effects7 days
Funnels and flow
funnelone funnel step by step: reached, share of the start, from the step before, median time, the biggest drop. Without a name, the list of funnels30 days
funnel-progresshow far given players (pids, at most 500) got in each funnel30 days
funnel-playersthe players who reached, or dropped off at, one step (at most 200)30 days
playtime-funnelplayers whose playtime reached 30 s, 1 m, 3 m, 5 m, 10 m, 30 m, 1 h, 2 h (measure: total or first session)30 days
flowthe merged graph for a group: where most players go next, and where they quit7 days
confusionfirst-session signals per zone and button: early leaves, opening and closing the same screen, walking back and forth, and from the recordings idle spots (10 s without input or movement), camera spins (360 degrees in 6 s without moving), repeated clicks (3 presses of one button within 2 s)14 days
One player
playersplayers seen in the range, most recent first, or top spenders (sort: "robux"); search = part of a pid30 days
timelineone player's sessions and events (by pid)90 days
player-graphone player's node graph over all their sessions, or one (sid)90 days
player-statsone player's Robux, purchases, sessions and playtime per day, with each session and purchase30 days
player-events, player-summarythe support key views: one player's named events, and their counts and totals30 days
Experiments
experimentper-variant numbers and how sure (scope: "player" or "server"), the split check and what the traffic can find30 days
experiment-playersthe players in one experiment: variant, random or forced, when assigned (at most 200)30 days
Recordings and maps (Recordings and maps)
recording-layersfirst-session recordings on a top-down map: paths, idle spots, deaths, screens, buttons, prompts, quits and positioned events7 days
recording-sessions, recording-replaythe recorded sessions (by map cell, ending or signal), and one for the replay7, 30 days
quit-autopsywhy first-session players quit: their last seconds against the players who stayed7 days
first-actionseconds from spawn to the first move, screen, button, prompt and round start7 days
map-positionsmap names from the events' map prop, and positioned events per map in 0.5-4 stud cells7 days
Economy and revenue
economyone currency's earned vs spent per day, the running balance, top sources and sinks30 days
monetizationRobux, purchases and payers per day, revenue by product, payer conversion, ARPPU, ARPDAU, repeat purchases, first purchase of new players30 days
monetization-players, monetization-purchaseswho bought a product (or anything), and the latest purchases (at most 200)30 days
Notifications, feedback, live events
notify-funnel, notify-optin, notify-queue, notify-playernotifications: the funnel per kind, the opt-in prompt, the sender per hour, one player's rows14, 30, 1, 400 days
feedback-summary, feedback-listplayer feedback: the distribution per scale and trend, and the rows30 days
live-event-liftthe raw numbers of a live event's lift report7 days
Performance
perf-clientclient fps, memory and ping over time (p10 / p50 / p90 / p99) by device, input, screen size, branch or build7 days
perf-serverserver TPS, physics FPS, memory and players over time, by branch or build7 days
perf-compareclient and server numbers for builds side by side, or before vs after a moment7 days
Raw and fleet
eventsthe newest rows, optionally of one kind, name or pid (up to 1,000)7 days
top-eventsthe most logged names per kind7 days
valuesthe branches, builds, channels and devices seen (for filter pickers)30 days
servers, deployReportfleet history from fleet rows (the live view is Fleet and alerts)1, 2 days

Each query's options are in the backend README; GET /v1/queries lists the names with a summary and the default range. Run them in three ways:

  • bun run report in the backend repo: the main ones in your terminal (below).

  • HTTP (DuckDB server): POST /v1/query/<name> with Authorization: Bearer <admin token> and the body { "filters": { "from": "2026-09-01", "dev": "phone" }, "options": { "funnel": "onboarding" } }. The answer is { result, ms }. The explorer's read-only viewers and named API keys with the right scope can query too.

  • Code, on either backend (a .ts file in a folder with @typetorch/backend installed, run with bun):

    ts
    import { createStore } from "@typetorch/backend";
    
    const store = await createStore({ backend: "duckdb", url: "https://analytics.example.com", token: process.env.TYPETORCH_ADMIN_TOKEN! });
    // or: { backend: "basin", accountId, bucket: "typetorch-analytics", token: <R2 token> }
    const numbers = await store.query("roblox", { from: "2026-09-01", players: "new" });
    const result = await store.query("experiment", {}, { experiment: "onboarding" });

    store.render(name, filters, options) returns the SQL without running it.

Useful options: funnel (funnel name), timeline and player-graph (pid), flow and player-graph (facet: all, zone, screen or activity; minCount; maxEdges), experiment (experiment, scope, control, weights for the split check), explain (metric, dimension, baseline).

"Is anything arriving?" Ask the DuckDB server directly instead of waiting for a chart: POST /v1/sql with the admin token and one read-only SELECT over events and recordings (today's live rows included) answers in seconds. Charts bucket by time, so their newest bucket can trail by a few minutes. See Debugging endpoints.

Node graphs ​

A graph's nodes are states (zones, screens, activities) and its edges are moves between them, with how often and how long players stayed. player-graph builds one player's graph over all their sessions; flow merges a group (new players, a variant, a build, a device).

Graphs come back as data, and toMermaid() turns one into a Mermaid diagram that any Markdown viewer renders (GitHub too), or paste it into mermaid.live:

ts
const graph = await store.query("flow", { players: "new" }, { facet: "zone" });
console.log(graph.toMermaid()); // or JSON.stringify(graph)

bun run report ​

A quick look at your data in your terminal, from a checkout of the backend while its server runs (bun run local, or your own server):

sh
bun run report -- --game ../my-game
bun run report -- --game ../my-game --pid <pid>

It asks the running backend (http://127.0.0.1:8787 by default, --url changes it) with the admin token from the game repo's .env, and prints the overview, the Roblox-style numbers, the top events, the onboarding funnel, experiments, the live servers, and one player's timeline and node graph (Mermaid). Without --pid it picks the player seen last. It never prints tokens.

Privacy and Right to Erasure ​

  • Events carry a random player id (pid, 32 hex characters), never the UserId. The link lives in your game's DataStore TypeTorchAnalytics, key p/<UserId> = { pid, first, last } (one read per join, a write on the first join and at leave). first marks the first-ever session.
  • Deleting that key makes the player's analytics anonymous, on both backends.
  • Your own server also keeps pid -> UserId. Once a player's pid is known, the game server sends one identity row { pid, uid, t } (the UserId and nothing else) per session to your analytics server: DuckDB games in the upload batch, Basin games to the backend's /v1/identity (Basin rows can't be deleted, so they never go there). It lands in a deletable table next to the fleet data, never in the events, and lets you look a player up by UserId (the explorer's Players page, GET /v1/identity?uid=) and answer erasure requests without a DataStore read. Players who joined before this update are mapped only by a backfill from the DataStore links (POST /v1/identity/backfill, needs OPENCLOUD_API_KEY with universe-datastores.objects:list and :read). Turn it off with the framework option identity: false.
  • The DuckDB server also deletes the rows. In Creator Hub > Webhooks, add https://<your host>/v1/erasure for "Right to erasure request" with a secret (ROBLOX_WEBHOOK_SECRET). The server checks Roblox's signature, ignores other games (TYPETORCH_UNIVERSE_ID), and maps the UserId to its pids through the identity table, and through Open Cloud when OPENCLOUD_API_KEY is set (a key with universe-datastores.objects:read; add :delete and TYPETORCH_ERASURE_DELETE_LINK=1 to also delete the link). The pids' rows leave the live file at once; day files, rollups and raw archives are rewritten in the background, and later rows of those pids are dropped. Then the UserId's identity rows are deleted (and refused afterwards). Its log keeps the notification id and outcome, never the UserId. The admin token can also erase by pid: POST /v1/erasure { "pid": "..." }.
  • Basin can't delete rows (its SQL is read-only). Delete the p/<UserId> link with the rest of the player's data: their rows stay, anonymous.

Quick start: a local test ​

The backend on your own PC, reached by game servers through a free Cloudflare quick tunnel. For trying it out: the tunnel URL changes every time it starts. Needs Bun, git and cloudflared.

The steps are the backend's Quick start (local). In short:

  1. Get the backend next to your game: git clone https://github.com/typetorch/backend, then in that folder bun install, bun run web:install and bun run web:build (the explorer, once).

  2. Make two keys, 32+ random characters each and different (openssl rand -hex 32 makes one), and put them in the game repo's .env (gitignored): TYPETORCH_API_KEY (the game servers' write-only key) and TYPETORCH_ADMIN_TOKEN (yours, for the CLI and the explorer).

  3. Run it, from the backend folder, pointing at the game repo:

    sh
    bun run local -- --game ../my-game

    It starts the backend, opens a quick tunnel and runs typetorch backend setup in the game. That writes the settings record (it needs the prod signing keys, fresh setup step 7, and kernel 0.3.8+ in the place) with the backend's URL, and sets backend.url in typetorch.json. That local edit changes with every tunnel, so don't commit it.

  4. Check it: open https://<words>.trycloudflare.com/healthz in a browser; it answers {"ok":true}.

Play, then look ​

  1. Make sure the game creates an AnalyticsEngine on the server and the client (the template does), and deploy it, for example to dev.
  2. Join a server and play for a minute. Running servers switch to the new backend URL and analytics settings within seconds of the write (new servers at once).
  3. bun run typetorch servers lists your server.
  4. In the backend folder: bun run report -- --game ../my-game.

When the tunnel restarts, its URL changes: run bun run local -- --game ../my-game again (it runs backend setup for the new URL). For a real game, use a VPS with your own domain instead (Deploy on Coolify or run it on a VPS without Docker).

Released under the MIT License.