An event was a tree of values encoded into a new string, a dropped line was never given back, and every batch and save joined the queue into another. Now: telemetry_props / telemetry_str / _int / _bool write the properties as JSON into a buffer the state keeps; the line is written beside it, its time worked out from the clock's seconds (TelemetryWorld.clock_s, was stamp); its bytes go into one ring (ring_bytes, at most queue_max lines), the oldest overwritten past either; a batch and the file are written into a third kept buffer and go as bytes (TelemetryTransport.send takes the bytes and their length; Http.body_bytes, Fs.write_bytes). The facts are pooled. tests/ring_test: ten thousand events past the cap in lines and in bytes, 0 bytes of heap; the line exact JSON, escaped, 2000-02-29 right; the batch puts the id in; the file round-trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
8.3 KiB
Text
202 lines
8.3 KiB
Text
# telemetry_test.ludic - events queue as lines, a batch goes when due and not before, a failure
|
|
# waits longer, off forgets, a test run never sends, and the queue survives a restart
|
|
import "ludic.telemetry"
|
|
import "ludic.base"
|
|
program TelemetryTest {
|
|
numbers float
|
|
state TelemetryTestState {
|
|
clock: int = 0
|
|
on: bool = true
|
|
allowed: bool = true
|
|
answer: int = -1
|
|
refuse: bool = false
|
|
sends: int = 0
|
|
last_url: string = ""
|
|
last_body: string = ""
|
|
}
|
|
|
|
function fake_now(telemetry_test_st: TelemetryTestState) -> int { return telemetry_test_st.clock }
|
|
function fake_enabled(telemetry_test_st: TelemetryTestState) -> bool { return telemetry_test_st.on }
|
|
function fake_can(telemetry_test_st: TelemetryTestState) -> bool { return telemetry_test_st.allowed }
|
|
function fake_clock() -> int { return 1790337600 }
|
|
function fake_send(telemetry_test_st: mut TelemetryTestState, url: string, body: []byte, n: int) -> int {
|
|
if telemetry_test_st.refuse { return -1 }
|
|
telemetry_test_st.sends += 1
|
|
telemetry_test_st.last_url = url
|
|
telemetry_test_st.last_body = text_of(body, n)
|
|
return 7
|
|
}
|
|
function fake_poll(telemetry_test_st: TelemetryTestState, h: int) -> int { return telemetry_test_st.answer }
|
|
function fake_drop(h: int) -> void { }
|
|
|
|
bind TelemetryWorld { can_send: fn fake_can, enabled: fn fake_enabled, now_ms: fn fake_now, clock_s: fn fake_clock }
|
|
bind TelemetryTransport { send: fn fake_send, poll: fn fake_poll, drop: fn fake_drop }
|
|
|
|
function qfile() -> string { return Os.temp_dir() + "/ludic-telemetry-test-queue.txt" }
|
|
function idfile() -> string { return Os.temp_dir() + "/ludic-telemetry-test-id.json" }
|
|
|
|
function fresh(telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) -> void {
|
|
telemetry_test_st.clock = 0
|
|
telemetry_test_st.on = true
|
|
telemetry_test_st.allowed = true
|
|
telemetry_test_st.answer = -1
|
|
telemetry_test_st.refuse = false
|
|
telemetry_test_st.sends = 0
|
|
telemetry_test_st.last_url = ""
|
|
telemetry_test_st.last_body = ""
|
|
telemetry_reset(telemetry_st)
|
|
if Fs.exists(qfile()) { Fs.remove(qfile()) }
|
|
Fs.write_text(idfile(), "{\"id\":\"p-1\",\"notices\":2}")
|
|
let c = new TelemetryConfig
|
|
c.host = "http://fake"
|
|
c.key = "k-1"
|
|
c.lib = "test-game"
|
|
c.queue_file = qfile()
|
|
c.id_file = idfile()
|
|
c.flush_ms = 1000
|
|
c.flush_at = 5
|
|
c.batch = 3
|
|
c.queue_max = 8
|
|
c.backoff_max = 2
|
|
c.save_ms = 100000
|
|
telemetry_config(telemetry_st, c)
|
|
telemetry_start(telemetry_st)
|
|
telemetry_tick(telemetry_st)
|
|
}
|
|
|
|
function events(telemetry_st: mut TelemetryState, n: int) -> void {
|
|
for i in 0 .. n { telemetry_event(telemetry_st, `e{i}`, null) }
|
|
}
|
|
|
|
function at(telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState, ms: int) -> void {
|
|
telemetry_test_st.clock = ms
|
|
telemetry_tick(telemetry_st)
|
|
}
|
|
|
|
function facts_of(telemetry_st: TelemetryState, what: int) -> int {
|
|
let fs = q_drain(telemetry_facts(telemetry_st))
|
|
var n = 0
|
|
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
|
|
return n
|
|
}
|
|
|
|
test "an event is one line with the player as a mark, and the batch puts the id in" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
expect(telemetry_id(telemetry_st) == "p-1")
|
|
telemetry_event(telemetry_st, "hello", null)
|
|
expect_eq(telemetry_queued(telemetry_st), 1)
|
|
let line = telemetry_line(telemetry_st, 0)
|
|
expect(Text.contains(line, "\"event\":\"hello\""))
|
|
expect(Text.contains(line, "test-game"))
|
|
expect(not Text.contains(line, "p-1"))
|
|
let body = telemetry_batch_body(telemetry_st, 1)
|
|
expect(Text.contains(body, "\"api_key\":\"k-1\""))
|
|
expect(Text.contains(body, "\"distinct_id\":\"p-1\""))
|
|
}
|
|
|
|
test "a batch goes when due, and only what the server took leaves the queue" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
events(telemetry_st, 4)
|
|
at(telemetry_st, telemetry_test_st, 500)
|
|
expect_eq(telemetry_test_st.sends, 0)
|
|
at(telemetry_st, telemetry_test_st, 1000)
|
|
expect_eq(telemetry_test_st.sends, 1)
|
|
expect(telemetry_test_st.last_url == "http://fake/batch/")
|
|
expect(telemetry_in_flight(telemetry_st))
|
|
events(telemetry_st, 1)
|
|
telemetry_test_st.answer = 1
|
|
at(telemetry_st, telemetry_test_st, 1100)
|
|
expect_eq(telemetry_queued(telemetry_st), 2)
|
|
expect_eq(facts_of(telemetry_st, TELEMETRY_SENT), 1)
|
|
}
|
|
|
|
test "enough events go early" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
events(telemetry_st, 5)
|
|
at(telemetry_st, telemetry_test_st, 10)
|
|
expect_eq(telemetry_test_st.sends, 1)
|
|
}
|
|
|
|
test "a failure keeps the lines and waits twice as long, up to the cap" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
events(telemetry_st, 2)
|
|
telemetry_test_st.answer = 0
|
|
at(telemetry_st, telemetry_test_st, 1000)
|
|
at(telemetry_st, telemetry_test_st, 1001)
|
|
expect_eq(telemetry_queued(telemetry_st), 2)
|
|
expect_eq(telemetry_fails(telemetry_st), 1)
|
|
expect_eq(telemetry_next_ms(telemetry_st), 1001 + 2000)
|
|
expect_eq(facts_of(telemetry_st, TELEMETRY_FAILED), 1)
|
|
at(telemetry_st, telemetry_test_st, 3001)
|
|
at(telemetry_st, telemetry_test_st, 3002)
|
|
at(telemetry_st, telemetry_test_st, 7002)
|
|
at(telemetry_st, telemetry_test_st, 7003)
|
|
expect_eq(telemetry_fails(telemetry_st), 2)
|
|
expect_eq(telemetry_next_ms(telemetry_st), 7003 + 4000)
|
|
}
|
|
|
|
test "a request that cannot start backs off too" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
telemetry_test_st.refuse = true
|
|
events(telemetry_st, 1)
|
|
at(telemetry_st, telemetry_test_st, 1000)
|
|
expect_eq(telemetry_fails(telemetry_st), 1)
|
|
expect(not telemetry_in_flight(telemetry_st))
|
|
}
|
|
|
|
test "off keeps nothing and deletes the file" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
events(telemetry_st, 3)
|
|
telemetry_save(telemetry_st)
|
|
expect(Fs.exists(qfile()))
|
|
telemetry_test_st.on = false
|
|
at(telemetry_st, telemetry_test_st, 10)
|
|
expect_eq(telemetry_queued(telemetry_st), 0)
|
|
expect(not Fs.exists(qfile()))
|
|
events(telemetry_st, 3)
|
|
expect_eq(telemetry_queued(telemetry_st), 0)
|
|
}
|
|
|
|
test "a run that may not send queues nothing and sends nothing" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
telemetry_test_st.allowed = false
|
|
events(telemetry_st, 9)
|
|
at(telemetry_st, telemetry_test_st, 100000)
|
|
expect_eq(telemetry_queued(telemetry_st), 0)
|
|
expect_eq(telemetry_test_st.sends, 0)
|
|
}
|
|
|
|
test "the queue is capped at its newest events" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
telemetry_test_st.allowed = true
|
|
events(telemetry_st, 12)
|
|
expect(telemetry_queued(telemetry_st) <= 11)
|
|
expect(Text.contains(telemetry_line(telemetry_st, telemetry_queued(telemetry_st) - 1), "e11"))
|
|
}
|
|
|
|
test "what was not sent is there at the next start" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
events(telemetry_st, 3)
|
|
telemetry_save(telemetry_st)
|
|
telemetry_reset(telemetry_st)
|
|
telemetry_start(telemetry_st)
|
|
expect_eq(telemetry_queued(telemetry_st), 3)
|
|
}
|
|
|
|
test "an id set outright is written beside the file's other keys" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
telemetry_set_id(telemetry_st, "p-2")
|
|
let v = Json.parse(Fs.read_text(idfile()))
|
|
expect(sv_str(v, "id", "") == "p-2")
|
|
expect_eq(sv_int(v, "notices", 0), 2)
|
|
}
|
|
|
|
test "no salt and no id file makes a random id" (telemetry_st: mut TelemetryState, telemetry_test_st: mut TelemetryTestState) {
|
|
fresh(telemetry_st, telemetry_test_st)
|
|
telemetry_reset(telemetry_st)
|
|
Fs.remove(idfile())
|
|
telemetry_start(telemetry_st)
|
|
expect(Text.starts_with(telemetry_id(telemetry_st), "r-"))
|
|
expect_eq(len(telemetry_session(telemetry_st)), 32)
|
|
}
|
|
}
|