ludic/packages/ludic.telemetry
2026-09-25 15:31:34 +03:00
..
tests wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
ident.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
index.ludic feat(ludic.telemetry): an event queue batched to a PostHog-shaped endpoint 2026-09-25 05:33:16 +03:00
package.ludic feat(ludic.telemetry): an event queue batched to a PostHog-shaped endpoint 2026-09-25 05:33:16 +03:00
ports.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
queries.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
queue.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
README.md feat(ludic.telemetry): an event queue batched to a PostHog-shaped endpoint 2026-09-25 05:33:16 +03:00
send.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00
state.ludic wip(0.S2): migrate - a var nothing writes becomes a let; a state is keyed by its module, directory or program, so every program's plan agrees; paths normalized; program states named SceneDemoState / scene_demo_st; the LSP reads state, mut and entry/handler parameters 2026-09-25 15:31:34 +03:00

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 and nothing else.

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)

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:

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

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.