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>
This commit is contained in:
parent
cac8c740fa
commit
e8c1d54a96
10 changed files with 711 additions and 0 deletions
92
packages/ludic.telemetry/ident.ludic
Normal file
92
packages/ludic.telemetry/ident.ludic
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# ident.ludic - one player id per machine: the operating system's own id (Windows MachineGuid,
|
||||
# macOS IOPlatformUUID, Linux /etc/machine-id) hashed with the game's salt, else random
|
||||
var telemetry__proc: int = -1
|
||||
var telemetry__proc_out: string = ""
|
||||
var telemetry__proc_t0: int = 0
|
||||
var telemetry__rand_n: int = 0
|
||||
|
||||
# n random bytes as hex. Crypto.random_hex reads /dev/urandom, which Windows lacks (zeros there),
|
||||
# so it is mixed with the clock and the process and hashed: distinct per start everywhere
|
||||
export function telemetry_random_hex(n: int) -> string {
|
||||
telemetry__rand_n += 1
|
||||
let seed = Crypto.random_hex(n) + "|" + `{Time.now()}|{Crypto.random_u32()}|{Os.pid()}|{telemetry__rand_n}`
|
||||
return Crypto.sha256(seed)[0..n * 2]
|
||||
}
|
||||
|
||||
# the id outright (a game with accounts of its own, a test); written to the id file
|
||||
export function telemetry_set_id(id: string) -> void {
|
||||
telemetry__id = id
|
||||
let path = telemetry__conf().id_file
|
||||
if len(path) > 0 {
|
||||
var v = telemetry__read_obj(path)
|
||||
if v == null { v = Value.object() }
|
||||
Value.put(v, "id", Value.str(id))
|
||||
Fs.write_text(path, Json.encode(v))
|
||||
}
|
||||
telemetry__fact(TELEMETRY_ID, 0, 0, 0)
|
||||
}
|
||||
|
||||
function telemetry__random_id() -> void { telemetry_set_id("r-" + telemetry_random_hex(16)) }
|
||||
|
||||
function telemetry__from_machine(raw: string) -> void {
|
||||
let salt = telemetry__conf().id_salt
|
||||
telemetry_set_id("m-" + Crypto.sha256(salt + ":" + Text.lower(raw))[0..32])
|
||||
}
|
||||
|
||||
function telemetry__id_begin() -> void {
|
||||
if len(telemetry__conf().id_salt) == 0 {
|
||||
telemetry__random_id()
|
||||
return
|
||||
}
|
||||
let plat = Os.platform()
|
||||
if plat == "linux" {
|
||||
let m = Fs.read_text("/etc/machine-id")
|
||||
if m != null and len(Text.trim(m)) > 0 { telemetry__from_machine(Text.trim(m)) } else { telemetry__random_id() }
|
||||
return
|
||||
}
|
||||
telemetry__proc_out = telemetry__conf().id_scratch
|
||||
if len(telemetry__proc_out) == 0 { telemetry__proc_out = Os.temp_dir() + "/ludic-telemetry-machine.txt" }
|
||||
if Fs.exists(telemetry__proc_out) { Fs.remove(telemetry__proc_out) }
|
||||
telemetry__proc = telemetry__spawn_machine(plat)
|
||||
telemetry__proc_t0 = Time.now()
|
||||
if telemetry__proc < 0 { telemetry__random_id() }
|
||||
}
|
||||
|
||||
function telemetry__spawn_machine(plat: string) -> int {
|
||||
let args = new []string
|
||||
if plat == "windows" {
|
||||
var root = "C:\\Windows"
|
||||
if Os.has_env("SystemRoot") and len(Os.env("SystemRoot")) > 0 { root = Os.env("SystemRoot") }
|
||||
let out = "'" + Text.replace(telemetry__proc_out, "'", "''") + "'"
|
||||
push(args, "-NoProfile")
|
||||
push(args, "-NonInteractive")
|
||||
push(args, "-Command")
|
||||
push(args, "(Get-ItemProperty -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid | Out-File -Encoding ascii -LiteralPath " + out)
|
||||
return Process.spawn(root + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", args)
|
||||
}
|
||||
push(args, "-c")
|
||||
push(args, "ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/{print $4}' > '" + telemetry__proc_out + "'")
|
||||
return Process.spawn("/bin/sh", args)
|
||||
}
|
||||
|
||||
# the machine id, once the child has written it (ten seconds at most)
|
||||
function telemetry__id_tick() -> void {
|
||||
if telemetry__proc < 0 { return }
|
||||
let code = Process.poll(telemetry__proc)
|
||||
if code < 0 {
|
||||
if Time.now() - telemetry__proc_t0 > 10 {
|
||||
Process.kill(telemetry__proc)
|
||||
Process.free(telemetry__proc)
|
||||
telemetry__proc = -1
|
||||
telemetry__random_id()
|
||||
}
|
||||
return
|
||||
}
|
||||
Process.free(telemetry__proc)
|
||||
telemetry__proc = -1
|
||||
var raw = ""
|
||||
let s = Fs.read_text(telemetry__proc_out)
|
||||
if s != null { raw = Text.trim(s) }
|
||||
if Fs.exists(telemetry__proc_out) { Fs.remove(telemetry__proc_out) }
|
||||
if code == 0 and len(raw) >= 8 { telemetry__from_machine(raw) } else { telemetry__random_id() }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue