Telemetry API
For whoever maintains the service. If you're putting telemetry in a game, you want Adding it to a game instead. You don't need any of this.
Base URL: https://telemetry.ardenttitanite.com
Auth: X-Telemetry-Key header.
Two keys, and they must differ:
| Key | For | Can |
|---|---|---|
INGEST_KEY | The games | Write segments, register games |
READ_KEY | Consumers | Read only |
A leaked read key can't forge activity. That's the point of splitting them.
POST /v1/segments
{
"eventId": "a-guid-generated-once-per-batch",
"gameKey": "genesius",
"placeId": "137231719757604",
"jobId": "server-job-id",
"serverTime": 1785735243003,
"segments": [
{
"robloxId": "1234567890",
"username": "Someone",
"team": "Blue",
"startedAt": 1785734043003,
"endedAt": 1785735243003,
"reason": "leave"
}
]
}
| Response | Means |
|---|---|
201 {duplicate: false} | Stored |
200 {duplicate: true} | Already had this eventId. Nothing counted twice. |
400 | Malformed. Retrying won't help. |
403 | Game isn't registered, or is disabled. |
401 | Wrong key. |
Reuse eventId when retrying. That's the whole idempotency story. The
sender never has to know whether its first attempt landed.
robloxId is a string. A Roblox ID above 2^53 rounds as a JSON number, and a
rounded ID writes one player's session onto someone else's account.
POST /v1/games
Register a game. Holding the ingest key isn't enough to report. A game has to be registered too, so one game can be switched off without rotating everyone's key.
{ "gameKey": "genesius", "label": "GENESIUS Territory", "placeId": "137231719757604" }
POST /v1/games/:gameKey/disable
Stops that game reporting. Its existing records are untouched.
GET /v1/playtime
?gameKey=genesius&since=<ms>&until=<ms>. Defaults to the last 7 days.
Computed from the raw rows on every request, excluded segments left out. Deliberately not a running total: a total that drifts can't be checked, whereas this can be recomputed and compared against the envelopes it came from.
GET /v1/stats
Envelope, segment, excluded and game counts. Good for a health check.
GET /v1/presence
?gameKey=<key>&since=<ms>&until=<ms>, who was in a game during a window.
This is what turns an event into an attendance sheet without anybody taking a register. It answers who was present, and nothing more. It does not decide who attended: the conditions for that live in Utilities, so the same telemetry can serve two groups that count attendance differently.
Windows longer than a day are refused rather than served slowly. An event window is hours, and a request for a year of a busy game is a mistake worth saying out loud.
A game that has since been disabled still answers here. Its old segments are real, and losing somebody's attendance because the game was switched off afterwards would be a poor trade.
GET /v1/players and /v1/players/:robloxId
The player lookup behind the HQ console.
Read key only, never the ingest key. Every game holds the ingest key, and a game has no business asking what somebody did in a different game.
A 404 on one player means never recorded, which is a different answer from
recorded with no playtime, and the two stay distinguishable.
GET /v1/teams
The teams actually seen in the data, which is what the rule editor offers.
Teams are picked from this list rather than typed, because a rule matching a team name that no game ever reports is a rule that silently never fires.
The activity rules
| Route | Does |
|---|---|
GET /v1/rules | List them |
POST /v1/rules | Create one |
PATCH /v1/rules/:id | Change one |
DELETE /v1/rules/:id | Remove one |
POST /v1/rules/:id/test | Fire one against current data without waiting |
All five sit behind the read key, not the ingest key, and that split is the point. A rule can point a webhook at a Discord channel. Every game holds the ingest key, so a game that could write rules could make the service post into any server it liked.
The game gateway
Three things a game already wanted, and previously could only have by holding a Utilities key of its own. Now telemetry holds the key and the game holds none.
| Route | Does |
|---|---|
GET /v1/gateway/status | Will these work? Ask at start-up |
POST /v1/gateway/points | Award points in game |
GET /v1/gateway/events | Is an event running for my group right now |
POST /v1/gateway/applications | Submit an application from in game |
All four take the ingest key and require the game to be registered. Holding the key is not enough, which is what lets one game be switched off without rotating everybody else's.
status exists so a game can find out at start-up whether the capabilities it
depends on will work, rather than discovering it when somebody is standing in a
lobby waiting for points that were never going to arrive. It reports four
things separately: the key you sent, whether the gateway is configured at all,
whether your game is registered, and whether it is authorised.
Points keep their idempotency keys all the way through. Utilities refuses a
repeated key, so a game retrying a forward it never saw the answer to does not
pay twice. That is the same promise /v1/segments makes about eventId, kept
by the same mechanism one service further along.
The events route does not take an attendance list. The game is told an event is running and nothing else. Presence is already arriving as segments, and asking the game to also report who attended would be asking the same question twice and then trusting the looser answer.
robloxUserId on an application is a string, for the reason robloxId is
on a segment: an id above 2^53 rounds, and a rounded id files one person's
application under somebody else's account.
When a segment gets flagged
| Reason | Rule |
|---|---|
| ends before it starts | endedAt earlier than startedAt |
| longer than the ceiling | over MAX_SEGMENT_MS, default 12 hours |
| starts in the future | beyond MAX_CLOCK_SKEW_MS, default 6 hours ahead |
| non-numeric timestamps | not a number |
Flagged segments are stored, excluded from totals, and never deleted.
Running it
cd ~/titan-telemetry
git pull && npm ci && npm run build
sudo systemctl restart titan-telemetry
sudo journalctl -u titan-telemetry -f
Port 8794 on 127.0.0.1, published by the cloudflared-titan-telemetry tunnel.
Database at ~/titan-telemetry/data/telemetry.sqlite.