ludic/packages/ludic.telemetry/README.md
Orkuncakilkaya e8c1d54a96 feat(ludic.telemetry): an event queue batched to a PostHog-shaped endpoint
Events are encoded once into lines held in memory; a batch is the first
lines joined, with the player id put in where a mark stood, POSTed on
Http's own thread every flush_ms or at flush_at events; a failure keeps
its lines and doubles the wait up to backoff_max; the queue is on disk
every save_ms and read back at the next start; off (the enabled port)
drops the request in flight, empties the queue and deletes the file; and
a run that may not send (can_send) keeps nothing. The player id is the
machine's own id (MachineGuid, IOPlatformUUID, /etc/machine-id) hashed
with the game's salt, else random; it lives in the game's id file beside
whatever else the game keeps there.

Ports: TelemetryWorld (can_send, enabled, now_ms, stamp) and
TelemetryTransport (send, poll, drop; unbound: Http). Config is a record
(host, key, path, lib, queue_file, id_file, id_salt, and the timings).
Facts: TELEMETRY_SENT, _FAILED, _ID. Not a System: it runs from a
launcher's first frame, before any world exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:33:16 +03:00

77 lines
4 KiB
Markdown

# ludic.telemetry
What a game says about how it is played, sent to an analytics server without ever costing a
frame. Each event is encoded once into a line held in memory; the lines go out in batches on
Http's own thread; a failed send keeps its lines and waits twice as long next time; the queue is
on disk every few seconds, so a crash or a quit loses little and the next start sends it; and a
player who says no has nothing queued and the file deleted. Uses
[`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
import "ludic.telemetry"
```
The wire format is PostHog's batch API: `POST <host><path>` with
`{"api_key": <key>, "batch": [{"event", "distinct_id", "properties", "timestamp"}, ...]}`. Every
event carries `$session_id` (a fresh one each start) and `$lib`. What an event is called and
what else its properties say are the game's.
The player id is one per machine: the operating system's own id (Windows `MachineGuid` through
PowerShell, macOS `IOPlatformUUID` through `ioreg`, Linux `/etc/machine-id`) hashed with the
game's salt, so a reinstall comes back as the same player and the raw id never leaves the
machine. With no salt, or where it cannot be read within ten seconds, it is random (`r-...`). It
lives in the game's id file as `{"id": ...}`; any other key the game keeps there is left alone.
An event queued before the id is known carries a mark that the batch replaces.
## Config and ports (`bind`)
```ludic
property TelemetryConfig {
host, key, path ("/batch/"), lib # an empty host or key sends nothing, ever
queue_file, id_file, id_salt, id_scratch
flush_ms (30000), flush_at (50), batch (200), queue_max (5000), backoff_max (6), save_ms (5000)
}
port TelemetryWorld {
can_send: fn() -> bool # may this run send at all - a test, a headless run (unbound: yes)
enabled: fn() -> bool # the player's switch (unbound: on)
now_ms: fn() -> int # a clock (unbound: the wall clock, by the second)
stamp: fn() -> string # an event's ISO time (unbound: now)
}
port TelemetryTransport { # unbound: Http
send: fn(string, string) -> int # url, body -> a handle, < 0 if it could not start
poll: fn(int) -> int # -1 pending, 0 failed, 1 taken
drop: fn(int) -> void # abandon one
}
```
The game binds what it needs once, where it is put together:
```ludic
bind TelemetryWorld { can_send: fn my_can_send, enabled: fn my_switch }
```
## API
| | |
| --- | --- |
| `telemetry_config(c)` | before the start |
| `telemetry_start()` | once, at boot: the id, a session, and the queue an earlier start left (or none, if off) |
| `telemetry_event(name, props)` | queue one (props may be null); nothing is kept while off or while the run may not send |
| `telemetry_tick()` | every frame: the machine id's child, a batch when due (every `flush_ms`, or at `flush_at` events), its answer, the backoff, the queue on disk every `save_ms`; off forgets |
| `telemetry_save()` | the queue on disk now (a quit, a child about to start) |
| `telemetry_forget()` | nothing queued, nothing on disk, the request in flight dropped |
| `telemetry_set_id(id)`, `telemetry_random_hex(n)` | an id outright; random hex that is distinct per start on Windows too (its `Crypto.random_hex` is zeros) |
| `telemetry_on()`, `telemetry_get_config()`, `telemetry_id()`, `telemetry_session()`, `telemetry_ready()`, `telemetry_queued()`, `telemetry_line(i)`, `telemetry_batch_body(n)`, `telemetry_fails()`, `telemetry_next_ms()`, `telemetry_in_flight()` | what the game asks |
| `telemetry_facts() -> Queue<TelemetryFact>` | `{ what, count, status, retry_ms }`: `TELEMETRY_SENT`, `TELEMETRY_FAILED`, `TELEMETRY_ID` |
| `telemetry_reset()` | back to before the start (tests) |
It is not a `System`: it runs from the first frame of a launcher, before any world exists, so the
game calls `telemetry_tick()` itself.
## Tests
```bash
ludic build packages/ludic.telemetry/tests/telemetry_test.ludic --headless -o /tmp/telemetry_test && /tmp/telemetry_test
```
A fake clock and a fake transport: nothing reaches a network.