Telemetry v2 — implementation plan
Telemetry v2 Implementation Plan
Section titled “Telemetry v2 Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stop losing telemetry. Every event goes through one on-disk client queue to a single deduping POST /events route. Add a crash/exit signal, lifecycle and funnel events, a mid-run timeline, real GPU/CPU frame timing, an in-game “Share diagnostics” toggle, and iOS-only MetricKit and Sentry.
Architecture:
- Pure core, thin Node.
net/event_queue_core.gdis pure RefCounted and holds the envelope, cap, batch, ack and backoff logic.net/event_queue.gdis a persistent Node that wraps it and owns the HTTPRequest. - All reporters push through the queue. That covers perf, hitch, run, encounter, feedback, lifecycle, MetricKit and timeline.
- Worker fan-out. For each event the Worker inserts into a new
eventstable withINSERT OR IGNORE, and also inserts into the matching legacy table under aWHERE NOT EXISTS (events.id)guard. Both happen in one D1batch()transaction, so a resend is a no-op everywhere. - The sim change is small. Two plain counters (
xp_by_type,boss_log) sit outside the checksum. Everything else is render-side.
Tech Stack: Godot 4.7.1 typed GDScript, GUT 9.7, Cloudflare Worker + D1 (telemetry/), Node 22 node --test (with node:sqlite for a real-SQL D1 shim), Objective-C++ (MetricKit), sentry-godot 2.2.0 (GDExtension).
Spec: docs/superpowers/specs/2026-09-29-telemetry-v2-design.md. Background: docs/research/2026-09-28-telemetry-gaps-and-options.md.
Global Constraints
Section titled “Global Constraints”Project rules
/simstays pure and deterministic. Everysim/*.gdfileextends RefCounted, with no Node/Engine/Time/OS/HTTP. The new sim fields are plain counters outsidestate_checksum()/snapshot_string(). They make no RNG draws and cause no ordering change.- The seed-1234 baseline in
tests/test_determinism_survival.gdmust not move. Never re-pin it in this plan. If it moves, the change is wrong. bash scripts/gate.shis the merge gate and must be green after every task. It runsgit checkout -- project.godot(root), so commit any intentional rootproject.godotedit BEFORE gating. This plan does not edit the rootproject.godot.platform/tvosgameplay dirs are symlinks. Nevercp/rsyncinto them. (Task 9 turnsplatform/tvos/addonsfrom a symlink into a real dir that holds agutsymlink plussentry/. It is not a gameplay dir; see Task 9.)- Use
/usr/bin/grep, never baregrep, in any interactive check. The shell’sgrepis a shim that honours.gitignore. Scripts that already usegrepare fine.
How to run and commit
- Worker tests:
node --test telemetry/test/*.test.mjs. Script tests:node --test scripts/test/*.test.mjs(the directory form fails on Node 22). - GUT single script:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<script_name_without_.gd> -gexit. After adding any newclass_namefile, rungodot --headless --path . --importfirst, or GUT silently drops the new script. - Typed GDScript: a
:=from a Variant is a compile error. Give Variant-derived vars an explicit type. An un-assertedpush_errorfails a GUT test. - One commit per task, on the task’s branch. Write the commit message to a file and use
git commit -F <file>, nevergit commit -m "$(cat <<EOF ...)"(macOS bash 3.2 apostrophe bug).
Deploys and privacy
- No deploys, no App Store Connect edits and no Sentry project creation in any task. Those are in “Chris-gated”, at the end, and need Chris’s same-day OK.
- Secret values never go in the repo or transcript. The Sentry DSN lives in a gitignored
platform/tvos/sentry_dsn.jsonthat Chris creates. The Cloudflare account id06f5622d4192dcefefbe0da75d766bfcis not a secret. - Telemetry never affects play. Every network or disk failure is silent, as today.
- The queue is disabled (push is a no-op) in the editor, headless, capture, replay and showcase, and whenever
MetaState.share_diagnosticsis false. - Envelope field names are exact:
id,t,ts,install,launch,session,build,channel,dev,auto,d. - Event type names are exact:
sample,hitch,run,encounter,feedback,launch,run_start,run_end,menu,tutorial_step,shop_buy,background,foreground,prev_exit,metrickit. run_end.reasonis one of exactly:death,story_complete,tutorial_cap,quit_menu,dev_restart.runsrows stay “banked runs only”. A quit to menu or a background kill never writes arunsrow; it writes arun_endevent.- The old Worker routes (
/sample,/hitch,/gameplay,/encounter,/feedback) stay working for old clients.
Deviations from the spec (decided here, overrule freely)
Section titled “Deviations from the spec (decided here, overrule freely)”- ≤ 20 events per
POST /events, not 50. Each event costs 2 D1 statements (legacy insert +eventsinsert), and every statement in abatch()counts as a query. The free Workers plan allows 50 queries per invocation [Certain per D1 limits docs, 2026-09-28 research]. 20 events × 2 = 40, plus at most 5 feedback rate-limit SELECTs, gives 45. - Ack rule is
ok + dup + bad == sent, notok + dup. A permanently malformed event (bad) would otherwise wedge the head of the queue forever. A 400 or 413 also drops the batch. A 404, 429, 5xx or network failure keeps it and backs off, so a v2 client that ships before the Worker deploy loses nothing. - Queue file lines are
<id>\t<type>\t<json>, not bare JSONL. Ack and cap then never re-parse JSON. Godot’sJSON.stringifyescapes tabs, so a raw tab never appears inside the JSON. File:telemetry_queue.txt. - MetricKit payload files go to the Caches ROOT as
mk-<kind>-<ms>-<rand>.json, not<Caches>/metrickit/. tvOS cannot create subdirectories in the app sandbox (seemain.gd_save_replay_if_recording). Keeping iOS and tvOS on one pattern avoids a second code path. channel_probeis sent once per launch inlaunch.d, not on every event. Legacychannel_probecolumns are NULL for v2 clients. Join onlaunch_id.- Sentry is vendored into
platform/tvos/addons/sentry/, not rootaddons/.platform/tvos/addonsis already a whole-dir symlink to rootaddons/, so “symlink it in like other shared dirs” would put a GDExtension into the root project.- The root project serves the Web export (
extensions_support=false) and the macOS gate/GUT runs. - Keeping it in the iOS/tvOS project means the web preset (gitignored, uncommittable) needs no
exclude_filter, and the gate never loads a native library. - It is not committed.
scripts/release/fetch-sentry.shfills the gitignored dir from the sha256-pinned 2.2.0 release zip (iOS + macOS libs, 44.8 MB). That keeps binaries out of git for a dormant feature. A clone without it just has no Sentry.
- The events table gains
rts(server receive ms) next to the clientts. Legacy rows written via/eventsuse the clienttswhen plausible (within 30 days back / 1 day ahead), else server time. That way an offline-queued run lands at the time it was played. runs.kills_by_typetruncation raised 400 → 2000. It has the same silent-truncation bug asbuild_picks, and it produces invalid JSON when cut.- The MetricKit sink is appended to the export’s own
dummy.cpp(renameddummy.mm), not shipped as a replacement file. That keeps Godot’s generated plugin stubs. The payload goes as raw JSON text (d.raw), not parsed and not truncated mid-JSON: Godot’s parser makes every number a float and would corrupt 64-bit stack addresses. Over 200 KB, a size stub is sent instead. - The pause-menu Diagnostics button shares the “Send feedback” row on the glass look. A full-width row breaks the existing 660 px fit test with dev rows.
- Hitch/sample
proc_ms/render_mskeep their names and values. Only labels change (dashboard, schema comment). Renaming columns would break every old row and query.
File structure
Section titled “File structure”| File | Responsibility | Task |
|---|---|---|
telemetry/schema.sql |
+ events table, new columns in CREATEs, 19 ALTERs in the migration comment block |
1 |
telemetry/migrations/2026-09-29-telemetry-v2.sql |
The live-DB migration, one statement per line | 1 |
telemetry/src/worker.js |
Shared legacy row builders, POST /events, dashboard labels |
1, 6 |
telemetry/test/d1shim.mjs |
D1-compatible shim over node:sqlite loaded from schema.sql |
1 |
telemetry/test/events.test.mjs |
/events + legacy-on-real-schema tests |
1 |
net/event_queue_store.gd |
EventQueueStore: in-memory line store (base class) |
2 |
net/event_queue_file_store.gd |
EventQueueFileStore: the on-disk queue file |
2 |
net/event_queue_core.gd |
EventQueueCore: envelope, cap, batch, ack, backoff (pure) |
2 |
net/event_queue.gd |
EventQueue Node: HTTP, flush timing, session/auto/enable |
2 |
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd |
Push through the queue | 3 |
net/exit_marker.gd |
ExitMarker: classify the previous exit (pure) |
4 |
net/lifecycle_telemetry.gd |
LifecycleTelemetry Node: launch/prev_exit/background/foreground/menu/tutorial_step + marker |
4 |
net/local_json.gd |
+ remove(name) |
4 |
ui/meta_shop_panel.gd |
+ purchased(id, cost, gold_after) signal |
4 |
sim/sim.gd + five boss files |
xp_by_type, boss_log, note_boss_spawn |
5 |
net/run_timeline.gd |
RunTimeline: level-ups offered/picked + to_dict(sim) |
5 |
net/hitch_sampler.gd |
HitchSampler: must-send + worst-2-per-30 s + 200 cap |
6 |
net/hitch_tracker.gd |
− session cap (moved to HitchSampler) |
6 |
sim/meta_state.gd, meta/meta_store.gd, net/remote_config.gd, ui/pause_menu.gd |
Share-diagnostics toggle | 7 |
marketing/appstore/compliance.md |
New privacy answers | 7 |
platform/tvos/patches/ios/metrickit-sink.mm |
MetricKit subscriber, appended to the export’s own plugin stub (dummy.cpp → dummy.mm) |
8 |
scripts/release/apply-ios-metrickit.sh |
Re-apply the iOS patch into build-ios/ (idempotent) |
8 |
net/metrickit_pickup.gd |
MetricKitPickup: payload files → metrickit events |
8 |
platform/tvos/addons/ (real dir), platform/tvos/addons/sentry/, net/sentry_boot.gd |
Dormant iOS Sentry | 9 |
scripts/release/fetch-sentry.sh, tools/sentry_probe/probe.gd |
Pinned addon fetch; one-shot API probe | 9 |
scripts/test/apply-ios-metrickit.test.mjs |
Node test of the iOS re-apply script against a fixture shell | 8 |
.gitignore, platform/tvos/project.godot, platform/tvos/export_presets.cfg, scripts/release/build-ios.sh, scripts/dev/install-dev.sh |
Sentry ignore/auto_init/tvOS exclude; build hooks | 8, 9 |
docs/architecture/dev-tools.md |
Telemetry v2 section, dSYM result | 10, 11 |
main.gd |
Wiring | 3, 4, 5, 7, 9 |
Task 1: Worker — events table, POST /events, shared legacy builders, raised truncation
Section titled “Task 1: Worker — events table, POST /events, shared legacy builders, raised truncation”Files:
- Modify:
telemetry/schema.sql - Create:
telemetry/migrations/2026-09-29-telemetry-v2.sql - Modify:
telemetry/src/worker.js(helpers after line 17const str = ...; the five POST route bodies at lines ~70-203) - Create:
telemetry/test/d1shim.mjs - Create:
telemetry/test/events.test.mjs
Interfaces:
-
Produces, for all later client tasks:
POST /events, body{"v":1,"events":[<envelope>...]}(text/plain). The body is at mostEVENTS_MAX_BYTES = 524288bytes and holds at mostEVENTS_MAX = 20events.- Responses:
200 {"ok":n,"dup":m,"bad":k},400 {"error":...}(bad json / no events),413(too large / too many),500(db). - Legacy fan-out types:
sample→sampleshitch→hitchesrun→runsencounter→encountersfeedback→feedback
- Every other valid type is stored in
events.dataonly. - Legacy payload keys are read from
dexactly as the old routes read the top-level body. Forsession,build,channel,devandis_auto/auto, the envelope overridesd.
-
Produces (exported for tests):
EVENTS_MAX,EVENTS_MAX_BYTES,sampleRow,hitchRow,runRow,encounterRow,feedbackRow. -
Step 1: Add the
eventstable, the new columns and the migration notes totelemetry/schema.sql
In each CREATE TABLE, add the new columns just before the closing );. Keep the comma on the previous last column.
samples:install_id TEXT, launch_id TEXT, event_id TEXT, auto INTEGER, gpu_ms REAL, render_cpu_ms REALruns:install_id TEXT, launch_id TEXT, event_id TEXT, timeline_json TEXTencounters,feedback,hitches:install_id TEXT, launch_id TEXT, event_id TEXT
For example, samples ends:
channel TEXT, -- build channel: dev | testflight | appstore | web; NULL = older client channel_probe TEXT, -- diagnostic probe backing the channel guess; NULL = older client install_id TEXT, -- telemetry v2: random install id (MetaState.install_id); NULL = pre-v2 route launch_id TEXT, -- telemetry v2: per-process launch id event_id TEXT, -- telemetry v2: events.id this row came from auto INTEGER, -- telemetry v2: 1 = autopilot/attract run at sample time; NULL = pre-v2 gpu_ms REAL, -- telemetry v2: viewport GPU render time; NULL = unsupported/pre-v2 render_cpu_ms REAL -- telemetry v2: viewport CPU render time + frame setup; NULL = unsupported/pre-v2);In the existing 2026-09-28 build-channels comment, replace the line -- all five tables. NULL for clients built before this. DEFERRED — not yet applied to the live DB. with:
-- all five tables. NULL for clients built before this. APPLIED to the live DB 2026-09-28 (Worker-- version 60fa39b6); the DEFERRED note that used to be here was stale.Append at the very end of the file:
-- Telemetry v2 (2026-09-29, spec docs/superpowers/specs/2026-09-29-telemetry-v2-design.md): one-- deduping event log. Every client event lands here once (id = client-generated 32-hex, the dedupe-- key); sample/hitch/run/encounter/feedback ALSO fan out into their legacy table in the same D1-- batch, guarded by NOT EXISTS on this id, so a resend is a no-op everywhere. `data` is NULL for-- those fanned-out types (the row lives in its own table) and the JSON payload for the rest.-- ts = client push time (ms), rts = server receive time (ms).CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, ts INTEGER NOT NULL, rts INTEGER NOT NULL, type TEXT NOT NULL, install TEXT, launch TEXT, session TEXT, build INTEGER, channel TEXT, dev TEXT, auto INTEGER, data TEXT);CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(type, ts);CREATE INDEX IF NOT EXISTS idx_events_install ON events(install);CREATE INDEX IF NOT EXISTS idx_events_session ON events(session);-- Migration 2026-09-29 telemetry v2 (NOT YET APPLIED — see telemetry/migrations/2026-09-29-telemetry-v2.sql-- and the plan's Chris-gated section; apply BEFORE deploying the Worker that names these columns).-- ALTER TABLE samples ADD COLUMN install_id TEXT;-- ALTER TABLE samples ADD COLUMN launch_id TEXT;-- ALTER TABLE samples ADD COLUMN event_id TEXT;-- ALTER TABLE samples ADD COLUMN auto INTEGER;-- ALTER TABLE samples ADD COLUMN gpu_ms REAL;-- ALTER TABLE samples ADD COLUMN render_cpu_ms REAL;-- ALTER TABLE runs ADD COLUMN install_id TEXT;-- ALTER TABLE runs ADD COLUMN launch_id TEXT;-- ALTER TABLE runs ADD COLUMN event_id TEXT;-- ALTER TABLE runs ADD COLUMN timeline_json TEXT;-- ALTER TABLE encounters ADD COLUMN install_id TEXT;-- ALTER TABLE encounters ADD COLUMN launch_id TEXT;-- ALTER TABLE encounters ADD COLUMN event_id TEXT;-- ALTER TABLE feedback ADD COLUMN install_id TEXT;-- ALTER TABLE feedback ADD COLUMN launch_id TEXT;-- ALTER TABLE feedback ADD COLUMN event_id TEXT;-- ALTER TABLE hitches ADD COLUMN install_id TEXT;-- ALTER TABLE hitches ADD COLUMN launch_id TEXT;-- ALTER TABLE hitches ADD COLUMN event_id TEXT;- Step 2: Create
telemetry/migrations/2026-09-29-telemetry-v2.sql
Put one statement per line with no comments. The Chris-gated deploy runs each line through --command, because a --file ALTER once silently didn’t apply (bh-telemetry skill, 2026-09-27).
CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, ts INTEGER NOT NULL, rts INTEGER NOT NULL, type TEXT NOT NULL, install TEXT, launch TEXT, session TEXT, build INTEGER, channel TEXT, dev TEXT, auto INTEGER, data TEXT);CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(type, ts);CREATE INDEX IF NOT EXISTS idx_events_install ON events(install);CREATE INDEX IF NOT EXISTS idx_events_session ON events(session);ALTER TABLE samples ADD COLUMN install_id TEXT;ALTER TABLE samples ADD COLUMN launch_id TEXT;ALTER TABLE samples ADD COLUMN event_id TEXT;ALTER TABLE samples ADD COLUMN auto INTEGER;ALTER TABLE samples ADD COLUMN gpu_ms REAL;ALTER TABLE samples ADD COLUMN render_cpu_ms REAL;ALTER TABLE runs ADD COLUMN install_id TEXT;ALTER TABLE runs ADD COLUMN launch_id TEXT;ALTER TABLE runs ADD COLUMN event_id TEXT;ALTER TABLE runs ADD COLUMN timeline_json TEXT;ALTER TABLE encounters ADD COLUMN install_id TEXT;ALTER TABLE encounters ADD COLUMN launch_id TEXT;ALTER TABLE encounters ADD COLUMN event_id TEXT;ALTER TABLE feedback ADD COLUMN install_id TEXT;ALTER TABLE feedback ADD COLUMN launch_id TEXT;ALTER TABLE feedback ADD COLUMN event_id TEXT;ALTER TABLE hitches ADD COLUMN install_id TEXT;ALTER TABLE hitches ADD COLUMN launch_id TEXT;ALTER TABLE hitches ADD COLUMN event_id TEXT;- Step 3: Write the D1 shim
telemetry/test/d1shim.mjs
// A D1-compatible database over node:sqlite (Node 22), built from telemetry/schema.sql, so Worker// tests run the Worker's REAL SQL against the REAL schema (a typo'd column fails here, not in prod).// Not a *.test.mjs file, so `node --test telemetry/test/*.test.mjs` does not run it on its own.import { DatabaseSync } from "node:sqlite";import { readFileSync } from "node:fs";
export function d1FromSchema() { const db = new DatabaseSync(":memory:"); const sql = readFileSync(new URL("../schema.sql", import.meta.url), "utf8"); db.exec(sql); // The live DB got many columns via the commented ALTER notes; a fresh DB needs them too. ALTERs // for columns a CREATE already has fail with "duplicate column" -- expected, skipped. for (const m of sql.matchAll(/^--\s+(ALTER TABLE .+;)\s*$/gm)) { try { db.exec(m[1]); } catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; } } const stmt = (sql, args = []) => ({ _sql: sql, _args: args, bind: (...a) => stmt(sql, a), run: async () => ({ success: true, meta: { changes: Number(db.prepare(sql).run(...args).changes) } }), first: async () => db.prepare(sql).get(...args) ?? null, all: async () => ({ results: db.prepare(sql).all(...args) }), }); return { raw: db, prepare: (sql) => stmt(sql), // D1 batch(): one transaction, one result per statement, rolled back as a whole on error. batch: async (stmts) => { db.exec("BEGIN"); try { const out = stmts.map((s) => ({ success: true, meta: { changes: Number(db.prepare(s._sql).run(...s._args).changes) } })); db.exec("COMMIT"); return out; } catch (e) { db.exec("ROLLBACK"); throw e; } }, };}- Step 4: Write the failing tests
telemetry/test/events.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import worker, { EVENTS_MAX, EVENTS_MAX_BYTES } from "../src/worker.js";import { d1FromSchema } from "./d1shim.mjs";
let n = 0;const hex = () => (++n).toString(16).padStart(32, "0");const env0 = { install: "inst1", launch: "lnch1", session: "sess1", build: 210, channel: "dev", dev: "iOS/iPhone15,2", auto: 0 };const ev = (t, d = {}, extra = {}) => ({ id: hex(), t, ts: Date.now(), ...env0, d, ...extra });const post = (DB, body, path = "/events") => worker.fetch(new Request("https://x" + path, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body) }), { DB });const rows = (DB, sql, ...a) => DB.raw.prepare(sql).all(...a);
test("a sample event lands in events (data NULL) and in samples with the v2 columns", async () => { const DB = d1FromSchema(); const e = ev("sample", { fps: 58.5, frame_ms: 17.1, gpu_ms: 6.2, render_cpu_ms: 3.1, quality: 1, config_id: "default" }, { auto: 1 }); const res = await post(DB, { v: 1, events: [e] }); assert.equal(res.status, 200); assert.deepEqual(await res.json(), { ok: 1, dup: 0, bad: 0 }); const [row] = rows(DB, "SELECT * FROM events"); assert.equal(row.id, e.id); assert.equal(row.type, "sample"); assert.equal(row.data, null); assert.equal(row.install, "inst1"); assert.equal(row.auto, 1); const [s] = rows(DB, "SELECT * FROM samples"); assert.equal(s.session, "sess1"); assert.equal(s.build, 210); assert.equal(s.fps, 58.5); assert.equal(s.install_id, "inst1"); assert.equal(s.launch_id, "lnch1"); assert.equal(s.event_id, e.id); assert.equal(s.auto, 1); assert.equal(s.gpu_ms, 6.2); assert.equal(s.render_cpu_ms, 3.1); assert.equal(s.dev, "iOS/iPhone15,2"); assert.equal(s.channel, "dev"); assert.equal(s.channel_probe, null);});
test("a resend is a dup and writes no second legacy row", async () => { const DB = d1FromSchema(); const e = ev("run", { mode: "survival", level: 9 }); await post(DB, { v: 1, events: [e] }); const res = await post(DB, { v: 1, events: [e] }); assert.deepEqual(await res.json(), { ok: 0, dup: 1, bad: 0 }); assert.equal(rows(DB, "SELECT COUNT(*) n FROM runs")[0].n, 1); assert.equal(rows(DB, "SELECT COUNT(*) n FROM events")[0].n, 1);});
test("the same id twice in ONE batch counts once", async () => { const DB = d1FromSchema(); const e = ev("encounter", { enemy_type: "charger", outcome: "clear" }); const res = await post(DB, { v: 1, events: [e, e] }); assert.deepEqual(await res.json(), { ok: 1, dup: 1, bad: 0 }); assert.equal(rows(DB, "SELECT COUNT(*) n FROM encounters")[0].n, 1);});
test("unknown types live in events.data only", async () => { const DB = d1FromSchema(); const e = ev("launch", { os: "iOS", first_launch: true }); await post(DB, { v: 1, events: [e] }); const [row] = rows(DB, "SELECT * FROM events"); assert.deepEqual(JSON.parse(row.data), { os: "iOS", first_launch: true }); for (const t of ["samples", "runs", "hitches", "encounters", "feedback"]) { assert.equal(rows(DB, `SELECT COUNT(*) n FROM ${t}`)[0].n, 0, t); }});
test("malformed events are counted bad and skipped; good ones in the same batch still land", async () => { const DB = d1FromSchema(); const good = ev("menu"); const res = await post(DB, { v: 1, events: [good, { id: "nothex", t: "menu" }, { id: hex(), t: "Bad-Type" }, null, ev("feedback", { note: "x" })] }); assert.deepEqual(await res.json(), { ok: 1, dup: 0, bad: 4 });});
test("limits: too many events and too large a body are 413, bad json is 400", async () => { const DB = d1FromSchema(); const many = Array.from({ length: EVENTS_MAX + 1 }, () => ev("menu")); assert.equal((await post(DB, { v: 1, events: many })).status, 413); assert.equal((await post(DB, "x".repeat(EVENTS_MAX_BYTES + 1))).status, 413); assert.equal((await post(DB, "{not json")).status, 400); assert.equal((await post(DB, { v: 1, events: [] })).status, 400);});
test("feedback keeps the 5 per 10 min per session limit; the rest count as bad", async () => { const DB = d1FromSchema(); const fb = Array.from({ length: 7 }, (_, i) => ev("feedback", { kind: "bug", note: "note " + i, platform: "iOS" })); const res = await post(DB, { v: 1, events: fb }); assert.deepEqual(await res.json(), { ok: 5, dup: 0, bad: 2 }); assert.equal(rows(DB, "SELECT COUNT(*) n FROM feedback WHERE session='sess1'")[0].n, 5); const more = await post(DB, { v: 1, events: [ev("feedback", { note: "again" })] }); assert.deepEqual(await more.json(), { ok: 0, dup: 0, bad: 1 });});
test("run events keep long build_picks/weapons_list and store the timeline", async () => { const DB = d1FromSchema(); const picks = Array.from({ length: 300 }, (_, i) => "pick" + i).join(","); // ~2.3 KB, was cut at 400 const timeline = { levelups: [[12.5, 2, ["a", "b", "c"], "b"]], xp_by_type: { swarmer: 40 }, damage_by_weapon: { blade: 900 }, bosses: [["boss", 80, 141.2]] }; await post(DB, { v: 1, events: [ev("run", { mode: "survival", build_picks: picks, weapons_list: "a".repeat(900), timeline, is_auto: false }, { auto: 1 })] }); const [r] = rows(DB, "SELECT * FROM runs"); assert.equal(r.build_picks, picks); assert.equal(r.weapons_list.length, 900); assert.deepEqual(JSON.parse(r.timeline_json), timeline); assert.equal(r.is_auto, 1, "envelope auto wins over d.is_auto"); assert.equal(r.session, "sess1");});
test("a hitch event fans out one hitches row", async () => { const DB = d1FromSchema(); await post(DB, { v: 1, events: [ev("hitch", { frame_ms: 140, proc_ms: 90, pipelines: 2, state: "play", recent: "new:death", quality: 2 })] }); const [h] = rows(DB, "SELECT * FROM hitches"); assert.equal(h.frame_ms, 140); assert.equal(h.pipelines, 2); assert.equal(h.quality, 2); assert.equal(h.recent, "new:death"); assert.equal(h.event_id.length, 32);});
test("an implausible client ts falls back to server time on legacy rows; events keeps both", async () => { const DB = d1FromSchema(); const before = Date.now(); await post(DB, { v: 1, events: [ev("sample", {}, { ts: 5 })] }); const [s] = rows(DB, "SELECT ts FROM samples"); assert.ok(s.ts >= before); const [e] = rows(DB, "SELECT ts, rts FROM events"); assert.equal(e.ts, s.ts); assert.ok(e.rts >= before);});
// Old clients (store builds <= 206/1.0) keep using the per-route endpoints: prove those still write// against the real schema, with the v2 columns NULL.test("legacy /sample and /gameplay still work on the real schema", async () => { const DB = d1FromSchema(); assert.equal((await post(DB, { build: 206, session: "old", fps: 60, max_frame_ms: 30, hitches: 0 }, "/sample")).status, 200); assert.equal((await post(DB, { build: 206, session: "old", mode: "survival", build_picks: "x".repeat(1000) }, "/gameplay")).status, 200); const [s] = rows(DB, "SELECT * FROM samples"); assert.equal(s.install_id, null); assert.equal(s.auto, null); assert.equal(s.gpu_ms, null); const [r] = rows(DB, "SELECT * FROM runs"); assert.equal(r.build_picks.length, 1000, "legacy route gets the raised limit too"); assert.equal(r.timeline_json, null);});
test("legacy /hitch batch and /encounter and /feedback still work on the real schema", async () => { const DB = d1FromSchema(); assert.equal((await post(DB, { build: 206, session: "old", hitches: [{ frame_ms: 80 }, { frame_ms: 90 }] }, "/hitch")).status, 200); assert.equal((await post(DB, { build: 206, session: "old", enemy_type: "spider" }, "/encounter")).status, 200); assert.equal((await post(DB, { build: 206, session: "old", note: "hello" }, "/feedback")).status, 200); assert.equal(rows(DB, "SELECT COUNT(*) n FROM hitches")[0].n, 2); assert.equal(rows(DB, "SELECT COUNT(*) n FROM encounters")[0].n, 1); assert.equal(rows(DB, "SELECT COUNT(*) n FROM feedback")[0].n, 1);});- Step 5: Run the tests to verify they fail
Run: node --test telemetry/test/*.test.mjs
Expected: FAIL. events.test.mjs fails to import EVENTS_MAX, and the legacy-on-schema tests fail with no such column because the Worker’s CREATE/INSERT lists and the schema don’t match yet. The existing channel/config/stamp tests PASS.
- Step 6: Add the shared builders and
/eventstotelemetry/src/worker.js
Update the header comment to list POST /events as the first line of the route list:
// POST /events — telemetry v2: a batch of ≤20 client events (dedupe by id; fans out to the legacy tables)After const str = (v, n) => ...; (line 17) insert:
const numOrNull = (v) => (v == null ? null : num(v));const json = (o, status = 200) => new Response(JSON.stringify(o), { status, headers: { ...CORS, "Content-Type": "application/json" } });const insertSQL = (table, row) => { const cols = Object.keys(row); return `INSERT INTO ${table} (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`;};
// Telemetry v2 limits. 20 events x 2 statements (+ <=5 feedback rate-limit SELECTs) stays under the// D1 free plan's 50 queries per Worker invocation.export const EVENTS_MAX = 20;export const EVENTS_MAX_BYTES = 512 * 1024;
// One row builder per legacy table, shared by the old per-route handlers and the /events fan-out so// the two can never drift. `s` = the payload (old routes: the request body; /events: `d` merged with// the envelope). `c` = {ts, ua, install, launch, id}; old routes pass null for the last three.// Object key order IS the INSERT column order.export function sampleRow(s, c) { return { ts: c.ts, build: intt(s.build), session: str(s.session, 40), fps: num(s.fps), frame_ms: num(s.frame_ms), sim_ms: num(s.sim_ms), render_ms: num(s.render_ms), draws: intt(s.draws), prims: intt(s.prims), enemies: intt(s.enemies), zones: intt(s.zones), webs: intt(s.webs), run_time: num(s.run_time), low_fx: s.low_fx ? 1 : 0, quality: intt(s.quality), dev: str(s.dev, 40), ua: c.ua, max_frame_ms: num(s.max_frame_ms), hitches: intt(s.hitches), config_id: str(s.config_id, 40) || null, channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null, install_id: c.install, launch_id: c.launch, event_id: c.id, auto: s.auto == null ? null : (s.auto ? 1 : 0), gpu_ms: numOrNull(s.gpu_ms), render_cpu_ms: numOrNull(s.render_cpu_ms), };}// h = one hitch; s = its batch (old /hitch) or the same merged payload (/events).export function hitchRow(h, s, c) { return { ts: c.ts, build: intt(s.build), session: str(s.session, 40), dev: str(s.dev, 40), quality: intt(s.quality), frame_ms: num(h.frame_ms), proc_ms: num(h.proc_ms), phys_ms: num(h.phys_ms), pipelines: intt(h.pipelines), run_time: num(h.run_time), enemies: intt(h.enemies), draws: intt(h.draws), nodes: intt(h.nodes), state: str(h.state, 16), recent: str(h.recent, 400), channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null, install_id: c.install, launch_id: c.launch, event_id: c.id, };}export function runRow(s, c) { return { ts: c.ts, build: intt(s.build), session: str(s.session, 40), mode: str(s.mode, 12), run_time: num(s.run_time), level: intt(s.level), kills: intt(s.kills), gold: intt(s.gold), dmg_dealt: num(s.dmg_dealt), dps: num(s.dps), reactions: intt(s.reactions), region: str(s.region, 16), decoy: str(s.decoy, 16), weapons: intt(s.weapons), tutorial_done: s.tutorial_done ? 1 : 0, death_cause: str(s.death_cause, 16), peak_enemies: intt(s.peak_enemies), damage_taken: num(s.damage_taken), // Raised 2026-09-29 (telemetry v2): 120/400 silently cut long builds mid-id. weapons_list: str(s.weapons_list, 1000), build_picks: str(s.build_picks, 4000), dev: str(s.dev, 40), kills_by_type: str(JSON.stringify(s.kills_by_type || {}), 2000), ruleset: str(s.ruleset, 12), is_auto: s.is_auto ? 1 : 0, ua: c.ua, pilots: s.pilots == null ? 1 : intt(s.pilots), // older builds don't send it: solo level2: s.level2 == null ? null : intt(s.level2), // P2's level; NULL on solo runs meta_spent: s.meta_spent == null ? null : intt(s.meta_spent), // gold invested in the shop; NULL = older build meta_levels: s.meta_levels == null ? null : str(s.meta_levels, 2000), // "id:lvl,..." owned shop levels config_id: str(s.config_id, 40) || null, config_hash: s.config_hash == null ? null : intt(s.config_hash), config_blacklisted: str(s.config_blacklisted, 200) || null, channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null, install_id: c.install, launch_id: c.launch, event_id: c.id, timeline_json: s.timeline == null ? null : str(JSON.stringify(s.timeline), 60000), };}export function encounterRow(s, c) { return { ts: c.ts, session: str(s.session, 40), build: intt(s.build), is_auto: s.is_auto ? 1 : 0, enemy_type: str(s.enemy_type, 16), chain: s.chain ? 1 : 0, outcome: str(s.outcome, 12), player_dashed: s.player_dashed ? 1 : 0, closest_approach_px: num(s.closest_approach_px), duration_s: num(s.duration_s), samples_json: str(JSON.stringify(s.samples || []), 4000), config_id: str(s.config_id, 40) || null, channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null, install_id: c.install, launch_id: c.launch, event_id: c.id, };}// null = not storable (a note under 3 characters).export function feedbackRow(s, c) { const note = str(s.note, 2000).trim(); if (note.length < 3) return null; return { ts: c.ts, session: str(s.session, 40), build: intt(s.build), dev: str(s.dev, 40), platform: str(s.platform, 16), kind: ["bug", "idea", "other"].includes(s.kind) ? s.kind : "other", note, context_json: str(JSON.stringify(s.context || {}), 4000), config_id: str(s.config_id, 40) || null, channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null, install_id: c.install, launch_id: c.launch, event_id: c.id, };}const legacyCtx = (req) => ({ ts: Date.now(), ua: str(req.headers.get("user-agent") || "", 240), install: null, launch: null, id: null });
const LEGACY = { sample: "samples", hitch: "hitches", run: "runs", encounter: "encounters", feedback: "feedback" };const BUILD_ROW = { sample: sampleRow, hitch: (s, c) => hitchRow(s, s, c), run: runRow, encounter: encounterRow };
function validEvent(ev) { if (!ev || typeof ev !== "object" || Array.isArray(ev)) return null; const id = typeof ev.id === "string" ? ev.id : ""; const t = typeof ev.t === "string" ? ev.t : ""; if (!/^[0-9a-f]{32}$/.test(id) || !/^[a-z_]{1,24}$/.test(t)) return null; const d = ev.d && typeof ev.d === "object" && !Array.isArray(ev.d) ? ev.d : {}; return { id, t, d, ts: ev.ts, install: str(ev.install, 40) || null, launch: str(ev.launch, 40) || null, session: str(ev.session, 40), build: intt(ev.build), channel: str(ev.channel, 12), dev: str(ev.dev, 40), auto: ev.auto ? 1 : 0, };}// Client push time when believable (an offline-queued run lands when it was played), else now.const plausibleTs = (t, now) => { const v = intt(t); return v > now - 30 * 86400000 && v < now + 86400000 ? v : now; };
async function handleEvents(req, env) { if (Number(req.headers.get("content-length") || 0) > EVENTS_MAX_BYTES) return json({ error: "too large" }, 413); const text = await req.text(); if (text.length > EVENTS_MAX_BYTES) return json({ error: "too large" }, 413); let body; try { body = JSON.parse(text); } catch { return json({ error: "bad json" }, 400); } const evs = body && Array.isArray(body.events) ? body.events : null; if (!evs || !evs.length) return json({ error: "no events" }, 400); if (evs.length > EVENTS_MAX) return json({ error: "too many" }, 413); const now = Date.now(); const ua = str(req.headers.get("user-agent") || "", 240); const stmts = []; const eventStmtIdx = []; // index in `stmts` of each accepted event's `events` insert const fbLeft = new Map(); // session -> feedback notes still allowed in this 10-minute window let bad = 0; for (const raw of evs) { const v = validEvent(raw); if (!v) { bad++; continue; } const c = { ts: plausibleTs(v.ts, now), ua, install: v.install, launch: v.launch, id: v.id }; // Envelope wins over the payload for the fields both carry. const s = { ...v.d, build: v.build, session: v.session, channel: v.channel, dev: v.dev, is_auto: v.auto, auto: v.auto }; const table = LEGACY[v.t]; let row = null; if (v.t === "feedback") { row = feedbackRow(s, c); if (!row) { bad++; continue; } if (!fbLeft.has(v.session)) { if (fbLeft.size >= 5) { bad++; continue; } // bounds the SELECTs below (D1 query budget) let recent; try { recent = await env.DB.prepare("SELECT COUNT(*) AS n FROM feedback WHERE session = ? AND ts > ?") .bind(v.session, now - 10 * 60 * 1000).first(); } catch { return json({ error: "db error" }, 500); } fbLeft.set(v.session, 5 - (recent ? recent.n : 0)); } if (fbLeft.get(v.session) <= 0) { bad++; continue; } fbLeft.set(v.session, fbLeft.get(v.session) - 1); } else if (table) { row = BUILD_ROW[v.t](s, c); } if (row) { const cols = Object.keys(row); stmts.push(env.DB.prepare( `INSERT INTO ${table} (${cols.join(",")}) SELECT ${cols.map(() => "?").join(",")} WHERE NOT EXISTS (SELECT 1 FROM events WHERE id = ?)` ).bind(...Object.values(row), v.id)); } eventStmtIdx.push(stmts.length); stmts.push(env.DB.prepare( "INSERT OR IGNORE INTO events (id,ts,rts,type,install,launch,session,build,channel,dev,auto,data) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" ).bind(v.id, c.ts, now, v.t, v.install, v.launch, v.session, v.build, v.channel || null, v.dev, v.auto, table ? null : str(JSON.stringify(v.d), 480000))); } let ok = 0, dup = 0; if (stmts.length) { let res; try { res = await env.DB.batch(stmts); } catch { return json({ error: "db error" }, 500); } for (const i of eventStmtIdx) { if (res[i] && res[i].meta && res[i].meta.changes > 0) ok++; else dup++; } } return json({ ok, dup, bad });}Directly after if (req.method === "OPTIONS") ... in fetch, add:
if (req.method === "POST" && url.pathname === "/events") return handleEvents(req, env);Replace the bodies of the five old POST routes so they use the builders. Keep each route’s parse, validation, status codes and responses exactly as they are:
if (req.method === "POST" && url.pathname === "/sample") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } try { const row = sampleRow(s, legacyCtx(req)); await env.DB.prepare(insertSQL("samples", row)).bind(...Object.values(row)).run(); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }
if (req.method === "POST" && url.pathname === "/hitch") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } const hs = Array.isArray(s.hitches) ? s.hitches.slice(0, 40) : []; if (!hs.length) return new Response("empty", { status: 400, headers: CORS }); const c = legacyCtx(req); try { await env.DB.batch(hs.map((h) => { const row = hitchRow(h, s, c); return env.DB.prepare(insertSQL("hitches", row)).bind(...Object.values(row)); })); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }
if (req.method === "POST" && url.pathname === "/gameplay") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } try { const row = runRow(s, legacyCtx(req)); await env.DB.prepare(insertSQL("runs", row)).bind(...Object.values(row)).run(); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }
if (req.method === "POST" && url.pathname === "/encounter") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } try { const row = encounterRow(s, legacyCtx(req)); await env.DB.prepare(insertSQL("encounters", row)).bind(...Object.values(row)).run(); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }
if (req.method === "POST" && url.pathname === "/feedback") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } const row = feedbackRow(s, legacyCtx(req)); if (!row) return new Response("empty", { status: 400, headers: CORS }); try { // Anonymous public endpoint: cap each session at 5 notes per 10 minutes. const recent = await env.DB.prepare( "SELECT COUNT(*) AS n FROM feedback WHERE session = ? AND ts > ?" ).bind(row.session, Date.now() - 10 * 60 * 1000).first(); if (recent && recent.n >= 5) return new Response("slow down", { status: 429, headers: CORS }); await env.DB.prepare(insertSQL("feedback", row)).bind(...Object.values(row)).run(); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }- Step 7: Run the tests to verify they pass
Run: node --test telemetry/test/*.test.mjs
Expected: PASS, all tests in channel, config, stamp and events. channel.test.mjs’s /channel,channel_probe/ still matches, because the feedback row keeps those two keys adjacent.
- Step 8: Gate and commit
Run: bash scripts/gate.sh. Expected: == GATE PASS. There are no Godot changes, so this only proves nothing else moved.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): POST /events with dedupe + legacy fan-out, events table, raised truncation
Telemetry v2 Task 1. One D1 batch per request: INSERT OR IGNORE into events, legacyINSERT guarded by NOT EXISTS(events.id). 20 events/request (D1 50-query budget).Legacy routes share the same row builders. Not deployed (Chris-gated).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add telemetry/schema.sql telemetry/migrations/2026-09-29-telemetry-v2.sql telemetry/src/worker.js telemetry/test/d1shim.mjs telemetry/test/events.test.mjsgit commit -F /tmp/bh-msg.txtTask 2: EventQueue — pure core, store, file store, Node wrapper
Section titled “Task 2: EventQueue — pure core, store, file store, Node wrapper”Files:
- Create:
net/event_queue_store.gd,net/event_queue_file_store.gd,net/event_queue_core.gd,net/event_queue.gd - Test:
tests/test_event_queue_core.gd,tests/test_event_queue.gd
Interfaces:
-
Consumes: the Worker contract from Task 1 (
POST /events, ≤20 events,{ok,dup,bad}). -
Produces (used by Tasks 3-9):
EventQueue(Node):setup(install_id: String, share: bool) -> voidpush(t: String, d: Dictionary = {}) -> String(returns the event id, or""when not enabled)flush_now() -> voidset_session(session: String) -> voidset_enabled(on: bool, install_id: String) -> voidis_enabled() -> boollaunch_id() -> Stringenable_for_test() -> voidvar auto_fn: Callablevar muted: boolvar core: EventQueueCorevar sent_bodies: Array[String](test mode)static live_platform() -> boolstatic new_id() -> String
EventQueueCore:push(t, d, auto) -> Stringpending_events() -> Array[Dictionary]pending_count() -> intpending_bytes() -> intshould_flush(now_s: float, force := false) -> boolbegin_flush(now_s: float) -> Stringfinish_flush(code: int, response: String, now_s: float) -> voidwipe() -> voidload_from_store() -> voidvar ctx: Dictionaryvar id_fn: Callablevar now_ms_fn: Callablevar max_bytes: intvar backoff_s: float
-
Ack rule (deviation 2): a batch leaves the queue on a 2xx whose
ok+dup+bad == sent, or on400/413(the Worker will never accept it). Anything else (404 before the Worker is deployed, 5xx, 429, network) keeps it and backs off. So a v2 client that runs before the Worker deploy loses nothing up to the 1 MB cap. -
Step 1: Write the failing core tests
tests/test_event_queue_core.gd
extends GutTest
# EventQueueCore (telemetry v2): the pure queue logic -- envelope, on-disk line format, cap/drop# order, batching, ack, backoff. Ids and clock are injected, so every assertion is exact.
func _core(store: EventQueueStore = null) -> EventQueueCore: var c := EventQueueCore.new(store) var n := [0] # an Array, so the lambda mutates shared state (lambdas capture locals by value) c.id_fn = func() -> String: n[0] += 1 return "%032x" % n[0] c.now_ms_fn = func() -> int: return 1_700_000_000_000 c.ctx = {"install": "i1", "launch": "l1", "session": "s1", "build": 210, "channel": "dev", "dev": "iOS/iPhone15,2"} return c
func _types(c: EventQueueCore) -> Array: return c.pending_events().map(func(e: Dictionary) -> String: return String(e.t))
func test_push_builds_the_exact_envelope() -> void: var c := _core() var id := c.push("menu", {"a": 1}, true) assert_eq(id, "%032x" % 1) var e: Dictionary = c.pending_events()[0] var keys := e.keys() keys.sort() assert_eq(keys, ["auto", "build", "channel", "d", "dev", "id", "install", "launch", "session", "t", "ts"]) assert_eq(e.t, "menu") assert_eq(e.install, "i1") assert_eq(e.launch, "l1") assert_eq(e.session, "s1") assert_eq(int(e.build), 210) assert_eq(e.channel, "dev") assert_eq(e.dev, "iOS/iPhone15,2") assert_eq(e.auto, true) assert_eq(int(e.ts), 1_700_000_000_000) assert_eq(int(e.d.a), 1)
func test_store_lines_are_id_tab_type_tab_json() -> void: var s := EventQueueStore.new() var c := _core(s) c.push("sample", {"note": "tab\there"}) var lines := s.read_all() assert_eq(lines.size(), 1) var parts := lines[0].split("\t") assert_eq(parts.size(), 3, "a tab inside the payload is escaped by JSON.stringify, never raw") assert_eq(parts[0], "%032x" % 1) assert_eq(parts[1], "sample") var parsed: Dictionary = JSON.parse_string(parts[2]) assert_eq(parsed.d.note, "tab\there")
func test_load_drops_malformed_and_torn_lines_and_keeps_order() -> void: var s := EventQueueStore.new() var a := _core(s) a.push("menu", {}) a.push("run", {}) var lines := s.read_all() s.rewrite(PackedStringArray([lines[0], "garbage", lines[1], lines[1].left(40)])) var b := _core(s) b.load_from_store() assert_eq(_types(b), ["menu", "run"]) assert_eq(s.read_all().size(), 2, "the store is rewritten without the bad lines")
func test_over_the_cap_samples_go_first_then_the_oldest() -> void: var c := _core() var pad := "x".repeat(200) c.push("run", {"pad": pad}) c.push("sample", {"pad": pad}) c.push("menu", {"pad": pad}) c.push("sample", {"pad": pad}) var one := c.pending_bytes() / 4.0 # float maths: integer division is a GDScript warning c.max_bytes = int(one * 3.5) # room for three and a half lines c.push("feedback", {"pad": pad}) # five lines: two must go, both samples assert_eq(_types(c), ["run", "menu", "feedback"]) c.max_bytes = int(one * 2.5) c.push("menu", {"pad": pad}) # no samples left: the oldest two go assert_eq(_types(c), ["feedback", "menu"]) assert_eq(c.dropped, 4)
func test_flush_timing_boot_then_interval_or_pending_count() -> void: var c := _core() assert_false(c.should_flush(0.0), "nothing pending") c.push("launch", {}) assert_true(c.should_flush(0.0), "boot: the first flush goes straight away") c.begin_flush(0.0) assert_false(c.should_flush(100.0), "one request in flight at a time") c.finish_flush(200, '{"ok":1,"dup":0,"bad":0}', 0.5) c.push("menu", {}) assert_false(c.should_flush(5.0), "under 10 s and under 20 pending") assert_true(c.should_flush(10.0), "10 s since the last flush") for k in EventQueueCore.FLUSH_PENDING: c.push("menu", {}) assert_true(c.should_flush(1.0), "20+ pending flushes early")
func test_a_flush_sends_the_20_oldest_as_one_json_body() -> void: var c := _core() for k in 25: c.push("menu", {"k": k}) var body := c.begin_flush(0.0) var parsed: Dictionary = JSON.parse_string(body) assert_eq(int(parsed.v), 1) var evs: Array = parsed.events assert_eq(evs.size(), EventQueueCore.BATCH) assert_eq(int(evs[0].d.k), 0) assert_eq(int(evs[19].d.k), 19)
func test_a_full_ack_drops_the_batch_and_resets_backoff() -> void: var s := EventQueueStore.new() var c := _core(s) for k in 3: c.push("menu", {}) c.backoff_s = 40.0 c.begin_flush(0.0) c.finish_flush(200, '{"ok":2,"dup":1,"bad":0}', 0.0) assert_eq(c.pending_count(), 0) assert_eq(s.read_all().size(), 0, "the store is rewritten too") assert_eq(c.backoff_s, 0.0)
func test_events_pushed_during_a_flush_survive_its_ack() -> void: var c := _core() c.push("menu", {}) c.push("menu", {}) c.begin_flush(0.0) c.push("run", {}) c.finish_flush(200, '{"ok":2,"dup":0,"bad":0}', 0.0) assert_eq(_types(c), ["run"])
func test_a_short_count_keeps_the_batch_and_backs_off_doubling_to_five_minutes() -> void: var c := _core() c.push("menu", {}) c.push("menu", {}) c.begin_flush(0.0) c.finish_flush(200, '{"ok":1,"dup":0,"bad":0}', 0.0) assert_eq(c.pending_count(), 2, "not every event was accounted for: resend (dedupe makes it safe)") assert_eq(c.backoff_s, 10.0) assert_false(c.should_flush(9.0)) assert_true(c.should_flush(10.0)) var t := 10.0 for k in 8: c.begin_flush(t) c.finish_flush(0, "", t) t += c.backoff_s assert_eq(c.backoff_s, EventQueueCore.BACKOFF_MAX_S)
func test_400_and_413_drop_the_batch_but_404_5xx_429_and_network_keep_it() -> void: for code in [400, 413]: var c := _core() c.push("menu", {}) c.begin_flush(0.0) c.finish_flush(code, "", 0.0) assert_eq(c.pending_count(), 0, "HTTP %d drops" % code) for code in [404, 429, 500, 0]: var c := _core() c.push("menu", {}) c.begin_flush(0.0) c.finish_flush(code, "", 0.0) assert_eq(c.pending_count(), 1, "HTTP %d keeps" % code)
func test_force_bypasses_backoff_and_interval_but_not_an_in_flight_request() -> void: var c := _core() c.push("menu", {}) c.begin_flush(0.0) assert_false(c.should_flush(0.0, true), "still one request at a time") c.finish_flush(500, "", 0.0) assert_false(c.should_flush(1.0)) assert_true(c.should_flush(1.0, true), "background/run-end flush ignores the backoff")
func test_wipe_clears_memory_and_store() -> void: var s := EventQueueStore.new() var c := _core(s) c.push("menu", {}) c.wipe() assert_eq(c.pending_count(), 0) assert_eq(c.pending_bytes(), 0) assert_eq(s.read_all().size(), 0)
func test_file_store_round_trip_persists_and_clear_removes_the_file() -> void: var name := "eq_test_%d.txt" % Time.get_ticks_usec() var fs := EventQueueFileStore.new(name) fs.append("a\tb\t{}") fs.append("c\td\t{}") assert_eq(fs.read_all(), PackedStringArray(["a\tb\t{}", "c\td\t{}"])) fs.rewrite(PackedStringArray(["c\td\t{}"])) assert_eq(EventQueueFileStore.new(name).read_all(), PackedStringArray(["c\td\t{}"]), "a new instance reads it back") fs.clear() assert_eq(fs.read_all().size(), 0) assert_false(FileAccess.file_exists("user://" + name)) fs.append("e\tf\t{}") assert_eq(fs.read_all().size(), 1, "append after clear recreates the file") fs.clear()- Step 2: Write the failing Node tests
tests/test_event_queue.gd
extends GutTest
# EventQueue node: the gates (headless / muted / share off), session + auto stamping, and the# flush hand-off. enable_for_test() swaps in an in-memory store and captures bodies instead of# sending them.
func _q() -> EventQueue: var q := EventQueue.new() add_child_autofree(q) q.setup("inst", true) return q
func test_headless_setup_is_a_silent_no_op() -> void: var q := _q() assert_false(q.is_enabled()) assert_eq(q.push("menu"), "", "headless: nothing is queued") assert_eq(q.core.pending_count(), 0)
func test_setup_mints_a_launch_id_and_stamps_the_context() -> void: var q := _q() assert_eq(q.launch_id().length(), 32) assert_eq(q.core.ctx.install, "inst") assert_eq(int(q.core.ctx.build), Sim_Const.BUILD) assert_eq(q.core.ctx.channel, BuildChannel.current()) assert_eq(q.core.ctx.dev, TelemetryDevice.id()) var other := _q() assert_ne(other.launch_id(), q.launch_id(), "a new launch id per process start")
func test_push_stamps_the_session_and_auto() -> void: var q := _q() q.enable_for_test() q.set_session("run1") q.auto_fn = func() -> bool: return true var id := q.push("run_start", {"mode": "survival"}) assert_eq(id.length(), 32) var e: Dictionary = q.core.pending_events()[0] assert_eq(e.session, "run1") assert_eq(e.auto, true) assert_eq(e.d.mode, "survival")
func test_muted_push_is_a_no_op_and_keeps_the_backlog() -> void: var q := _q() q.enable_for_test() q.push("menu") q.muted = true assert_eq(q.push("menu"), "") assert_eq(q.core.pending_count(), 1)
func test_flush_now_hands_one_body_to_the_sender() -> void: var q := _q() q.enable_for_test() q.push("menu") q.push("menu") q.flush_now() assert_eq(q.sent_bodies.size(), 1) var parsed: Dictionary = JSON.parse_string(q.sent_bodies[0]) assert_eq((parsed.events as Array).size(), 2)
func test_turning_off_wipes_and_stops_pushes_and_back_on_takes_the_new_install_id() -> void: var q := _q() q.enable_for_test() q.push("menu") q.set_enabled(false, "") assert_eq(q.core.pending_count(), 0, "the queue is wiped") assert_eq(q.push("menu"), "") q.set_enabled(true, "fresh") q.push("menu") assert_eq(q.core.pending_events()[0].install, "fresh")- Step 3: Run the tests to verify they fail
Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_event_queue -gexit
Expected: FAIL (parse errors: EventQueueCore / EventQueue not declared). -gselect=test_event_queue matches both files.
- Step 4: Create
net/event_queue_store.gd
class_name EventQueueStoreextends RefCounted
# Where EventQueueCore mirrors its pending lines (telemetry v2). This base keeps them in memory# (editor, headless, GUT); EventQueueFileStore persists the same four calls to disk.
var _mem: PackedStringArray = PackedStringArray()
func append(line: String) -> void: _mem.append(line)
func read_all() -> PackedStringArray: return _mem.duplicate()
func rewrite(lines: PackedStringArray) -> void: _mem = lines.duplicate()
func clear() -> void: _mem = PackedStringArray()- Step 5: Create
net/event_queue_file_store.gd
class_name EventQueueFileStoreextends EventQueueStore
# The on-disk telemetry queue (telemetry v2). One line per event, appended on push (never a full# rewrite per event); rewritten only on ack, cap and load cleanup. user:// first; tvOS cannot write# user:// so fall back to the cache dir (same rule as LocalJson / MetaStore). No subdirectory:# tvOS cannot create one. Best-effort on tvOS, whose cache dir can be purged.
const FILE := "telemetry_queue.txt"
var path: String = ""
func _init(file_name: String = FILE) -> void: path = _pick_path(file_name)
static func _pick_path(file_name: String) -> String: var candidates: Array[String] = ["user://" + file_name, OS.get_cache_dir().path_join(file_name)] for p in candidates: if FileAccess.file_exists(p): return p for p in candidates: var f := FileAccess.open(p, FileAccess.WRITE) if f != null: f.close() return p return ""
func append(line: String) -> void: if path == "": return var f: FileAccess = null if FileAccess.file_exists(path): f = FileAccess.open(path, FileAccess.READ_WRITE) else: f = FileAccess.open(path, FileAccess.WRITE) if f == null: return f.seek_end() f.store_string(line + "\n") f.close()
func read_all() -> PackedStringArray: if path == "" or not FileAccess.file_exists(path): return PackedStringArray() return FileAccess.get_file_as_string(path).split("\n", false)
func rewrite(lines: PackedStringArray) -> void: if path == "": return var f := FileAccess.open(path, FileAccess.WRITE) if f == null: return if not lines.is_empty(): f.store_string("\n".join(lines) + "\n") f.close()
func clear() -> void: if path != "" and FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path))- Step 6: Create
net/event_queue_core.gd
class_name EventQueueCoreextends RefCounted
# Telemetry v2 queue logic, pure (no Node, no HTTP). It builds the event envelope, keeps the# pending lines (mirrored to an EventQueueStore), caps their size, cuts batches and applies the# server's ack. Line format "<id>\t<type>\t<json envelope>": ack and the cap never re-parse JSON,# and JSON.stringify escapes tabs, so a payload can never add a field.# Spec: docs/superpowers/specs/2026-09-29-telemetry-v2-design.md (deviations in the plan).
const MAX_BYTES := 1048576const BATCH := 20 # = Worker EVENTS_MAX (D1: 2 statements per event, 50 per request)const BATCH_MAX_BYTES := 400000 # the Worker refuses bodies over 512 KBconst FLUSH_PENDING := 20const FLUSH_INTERVAL_S := 10.0const BACKOFF_MIN_S := 10.0const BACKOFF_MAX_S := 300.0
var store: EventQueueStorevar ctx: Dictionary = {"install": "", "launch": "", "session": "", "build": 0, "channel": "", "dev": ""}var id_fn: Callable # -> String, 32 hex charsvar now_ms_fn: Callable # -> int, unix msvar max_bytes: int = MAX_BYTESvar backoff_s: float = 0.0var last_flush_s: float = -INFvar next_try_s: float = -INFvar dropped: int = 0 # lines the cap threw away (diagnostic)var _lines: PackedStringArray = PackedStringArray()var _bytes: int = 0var _in_flight: PackedStringArray = PackedStringArray() # ids of the batch being sent
func _init(s: EventQueueStore = null) -> void: store = s if s != null else EventQueueStore.new() id_fn = func() -> String: return Crypto.new().generate_random_bytes(16).hex_encode() now_ms_fn = func() -> int: return int(Time.get_unix_time_from_system() * 1000.0)
static func _id_of(line: String) -> String: return line.get_slice("\t", 0)
static func _type_of(line: String) -> String: return line.get_slice("\t", 1)
static func _size_of(line: String) -> int: return line.to_utf8_buffer().size() + 1 # + the newline on disk
static func _well_formed(line: String) -> bool: return line.get_slice_count("\t") == 3 and _id_of(line).length() == 32 \ and line.get_slice("\t", 2).begins_with("{") and line.ends_with("}")
func make_event(t: String, d: Dictionary, auto: bool) -> Dictionary: return {"id": String(id_fn.call()), "t": t, "ts": int(now_ms_fn.call()), "install": String(ctx.get("install", "")), "launch": String(ctx.get("launch", "")), "session": String(ctx.get("session", "")), "build": int(ctx.get("build", 0)), "channel": String(ctx.get("channel", "")), "dev": String(ctx.get("dev", "")), "auto": auto, "d": d}
func push(t: String, d: Dictionary, auto: bool = false) -> String: var ev := make_event(t, d, auto) var line := "%s\t%s\t%s" % [ev.id, t, JSON.stringify(ev)] _lines.append(line) _bytes += _size_of(line) store.append(line) _enforce_cap() return String(ev.id)
func load_from_store() -> void: _lines = PackedStringArray() _bytes = 0 var had_bad := false for line in store.read_all(): if _well_formed(line): _lines.append(line) _bytes += _size_of(line) else: had_bad = true # a torn last line from a kill mid-append, or garbage if had_bad: store.rewrite(_lines) _enforce_cap()
func pending_count() -> int: return _lines.size()
func pending_bytes() -> int: return _bytes
func pending_events() -> Array[Dictionary]: var out: Array[Dictionary] = [] for line in _lines: var parsed: Variant = JSON.parse_string(line.get_slice("\t", 2)) if parsed is Dictionary: out.append(parsed) return out
func in_flight() -> bool: return not _in_flight.is_empty()
# Over the cap: the oldest "sample" goes first (perf rows are the most plentiful and least# precious), then the oldest line of any type.func _enforce_cap() -> void: var changed := false while _bytes > max_bytes and not _lines.is_empty(): var victim := 0 for i in _lines.size(): if _type_of(_lines[i]) == "sample": victim = i break _bytes -= _size_of(_lines[victim]) _lines.remove_at(victim) dropped += 1 changed = true if changed: store.rewrite(_lines)
# last_flush_s starts at -INF, so the first call after boot flushes straight away. `force`# (background, run end, feedback) ignores the interval and the backoff, never an in-flight request.func should_flush(now_s: float, force: bool = false) -> bool: if in_flight() or _lines.is_empty(): return false if force: return true if now_s < next_try_s: return false return _lines.size() >= FLUSH_PENDING or now_s - last_flush_s >= FLUSH_INTERVAL_S
# The oldest <= BATCH lines (and <= BATCH_MAX_BYTES, but always at least one) as the POST body.func begin_flush(now_s: float) -> String: var parts := PackedStringArray() var size := 0 _in_flight = PackedStringArray() for line in _lines: if parts.size() >= BATCH: break var j := line.get_slice("\t", 2) if not parts.is_empty() and size + j.length() > BATCH_MAX_BYTES: break parts.append(j) _in_flight.append(_id_of(line)) size += j.length() + 1 last_flush_s = now_s if parts.is_empty(): return "" return "{\"v\":1,\"events\":[%s]}" % ",".join(parts)
func finish_flush(code: int, response: String, now_s: float) -> void: var sent := _in_flight.size() var done := false if code >= 200 and code < 300: var parsed: Variant = JSON.parse_string(response) if parsed is Dictionary: var r: Dictionary = parsed done = int(r.get("ok", 0)) + int(r.get("dup", 0)) + int(r.get("bad", 0)) == sent elif code == 400 or code == 413: done = true # the Worker will never accept this batch: retrying would wedge the queue if done: ack(_in_flight) backoff_s = 0.0 next_try_s = -INF else: backoff_s = BACKOFF_MIN_S if backoff_s <= 0.0 else minf(backoff_s * 2.0, BACKOFF_MAX_S) next_try_s = now_s + backoff_s _in_flight = PackedStringArray()
func ack(ids: PackedStringArray) -> void: if ids.is_empty(): return var keep := PackedStringArray() _bytes = 0 for line in _lines: if not ids.has(_id_of(line)): keep.append(line) _bytes += _size_of(line) _lines = keep store.rewrite(_lines)
func wipe() -> void: _lines = PackedStringArray() _bytes = 0 _in_flight = PackedStringArray() store.clear()- Step 7: Create
net/event_queue.gd
class_name EventQueueextends Node
# Telemetry v2: the ONE pipe every reporter pushes through (spec# docs/superpowers/specs/2026-09-29-telemetry-v2-design.md). Persistent: created once in# main._ready and never swept by _new_run / _return_to_menu. EventQueueCore holds the logic; this# node adds the HTTP request, the flush clock and the gates. push() is a silent no-op in the# editor/headless, while `muted` (capture / replay / showcase) and when the player has turned# "Share diagnostics" off. Never affects play.
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events"const HTTP_TIMEOUT_S := 15.0
var core: EventQueueCore = EventQueueCore.new()var auto_fn: Callable # main: true while the attract-mode AI is flying the current runvar muted: bool = false # main: capture / replay / showcase runs never reportvar sent_bodies: Array[String] = [] # test mode only: what a flush would have POSTedvar _enabled: bool = falsevar _test_mode: bool = falsevar _http: HTTPRequestvar _clock_s: float = 0.0 # monotonic seconds of _process time (render-side only)
static func live_platform() -> bool: return not OS.has_feature("editor") and DisplayServer.get_name() != "headless"
static func new_id() -> String: return Crypto.new().generate_random_bytes(16).hex_encode()
func setup(install_id: String, share: bool) -> void: var live := live_platform() var store: EventQueueStore = EventQueueStore.new() if live: store = EventQueueFileStore.new() core = EventQueueCore.new(store) core.ctx = {"install": install_id, "launch": new_id(), "session": "", "build": Sim_Const.BUILD, "channel": BuildChannel.current(), "dev": TelemetryDevice.id()} _enabled = live and share if not live: return if share: core.load_from_store() else: core.wipe() # switched off last session: nothing queued before that may leave the device
func launch_id() -> String: return String(core.ctx.get("launch", ""))
func is_enabled() -> bool: return _enabled and not muted
# GUT: an in-memory store, pushes enabled, and flushes captured in sent_bodies instead of sent.func enable_for_test() -> void: core.store = EventQueueStore.new() _test_mode = true _enabled = true set_process(false) # tests drive flushes explicitly
func set_session(session: String) -> void: core.ctx["session"] = session
# The pause-menu toggle (Task 7). Off wipes the queue; on takes the freshly rotated install id.func set_enabled(on: bool, install_id: String) -> void: core.ctx["install"] = install_id if on: _enabled = _test_mode or live_platform() return _enabled = false core.wipe()
func push(t: String, d: Dictionary = {}) -> String: if not is_enabled(): return "" var auto := false if auto_fn.is_valid(): auto = bool(auto_fn.call()) return core.push(t, d, auto)
func flush_now() -> void: _try_flush(true)
func _process(dt: float) -> void: _clock_s += dt _try_flush(false)
func _try_flush(force: bool) -> void: if not is_enabled() or not core.should_flush(_clock_s, force): return var body := core.begin_flush(_clock_s) if body == "": return if _test_mode: sent_bodies.append(body) return # a test completes it with core.finish_flush(...) if _http == null: _http = HTTPRequest.new() _http.timeout = HTTP_TIMEOUT_S add_child(_http) _http.request_completed.connect(_on_done) # text/plain keeps it a CORS "simple request" (no preflight) for the web build. if _http.request(ENDPOINT, ["Content-Type: text/plain"], HTTPClient.METHOD_POST, body) != OK: core.finish_flush(0, "", _clock_s)
func _on_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void: var c := code if result == HTTPRequest.RESULT_SUCCESS else 0 core.finish_flush(c, body.get_string_from_utf8(), _clock_s)- Step 8: Run the tests to verify they pass
Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_event_queue -gexit
Expected: PASS (both files, 0 failures, no un-asserted errors).
- Step 9: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS. The determinism test is inside it and must pass unchanged. Nothing uses the queue yet.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): EventQueue — pure core, disk store, Node wrapper
Telemetry v2 Task 2. Envelope, "<id>\t<type>\t<json>" lines, 1 MB cap (samples droppedfirst), 20-event batches, ack on ok+dup+bad==sent or 400/413, backoff 10 s -> 5 min.Not wired to any reporter yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add net/event_queue_store.gd net/event_queue_file_store.gd net/event_queue_core.gd net/event_queue.gd tests/test_event_queue_core.gd tests/test_event_queue.gdgit commit -F /tmp/bh-msg.txt(*.uid files are gitignored, so there is nothing else to add.)
Task 3: Move the reporters onto the queue, with one session id and install/launch ids
Section titled “Task 3: Move the reporters onto the queue, with one session id and install/launch ids”Files:
- Modify (full rewrite shown):
net/telemetry.gd,net/gameplay_telemetry.gd - Modify:
net/dash_encounter_telemetry.gd: the header vars,setup(),_send() - Modify:
main.gd:- vars ~99-105
_ready~317-334_setup_showcase~424-428- pause feedback ~530-531
_return_to_menu~679-700_new_run~833-842, ~883-884, ~1266-1277_bank_run~1588-1590
- Modify:
tests/test_hitch_tracker.gd, rewritingtest_telemetry_node_queues_a_hitch_with_context(lines 86-100) - Test:
tests/test_telemetry_reporters_queue.gd
Interfaces:
-
Consumes:
EventQueuefrom Task 2. -
Produces:
- Telemetry:
Telemetry.setup(sim: Sim, queue: EventQueue) -> voidTelemetry._send_sample() -> void
- GameplayTelemetry:
GameplayTelemetry.setup(queue: EventQueue = null) -> voidGameplayTelemetry.report_run(sim, mode, decoy_type, region, tutorial_done, build_picks := [], is_auto := false, meta_spent := 0, meta_levels := "") -> void(the oldsessionparam is gone)static GameplayTelemetry.run_body(...) -> DictionaryGameplayTelemetry.send_feedback(kind, note, sim, input_style) -> void(thesessionparam is gone)static feedback_body(...)keeps its exact signature, because 3 existing tests call it.
DashEncounterTelemetry.setup(queue: EventQueue = null) -> void- main:
var event_queue: EventQueue- the session is set with
event_queue.set_session(_run_session)at each run start and""at the menu.
- Telemetry:
-
Payload rule:
dnever repeats envelope fields. There is nobuild/channel/channel_probe/session/devinsided; the Worker merges the envelope back in for the legacy tables. -
All reporters now share one session per run, including encounters (previously a per-boot id). Feedback carries the run’s session, or
""from a menu. -
Step 1: Write the failing tests
tests/test_telemetry_reporters_queue.gd
extends GutTest
# Telemetry v2 Task 3: every reporter pushes onto the EventQueue with the right type, and the# payload never repeats the envelope (session/build/channel/dev live on the event itself).
func _queue() -> EventQueue: var q := EventQueue.new() add_child_autofree(q) q.setup("inst", true) q.enable_for_test() q.set_session("run1") return q
func _sim() -> Sim: return Sim.new(1, SimContentFixture.db())
func _only(q: EventQueue) -> Dictionary: var evs := q.core.pending_events() assert_eq(evs.size(), 1, "exactly one event queued") return evs[0] if evs.size() > 0 else {}
func _no_envelope_fields(d: Dictionary) -> void: for k in ["build", "channel", "channel_probe", "session", "dev"]: assert_false(d.has(k), "payload must not repeat envelope field %s" % k)
func test_a_perf_sample_is_a_sample_event() -> void: var q := _queue() var t := Telemetry.new() add_child_autofree(t) t.setup(_sim(), q) t._enabled = true # headless gate forced on t._send_sample() var e := _only(q) assert_eq(e.t, "sample") assert_eq(e.session, "run1") assert_true(e.d.has("fps")) assert_true(e.d.has("max_frame_ms")) assert_true(e.d.has("config_id")) _no_envelope_fields(e.d)
func test_a_run_summary_is_a_run_event() -> void: var q := _queue() var g := GameplayTelemetry.new() add_child_autofree(g) g.setup(q) g._enabled = true g.report_run(_sim(), "survival", "basic", "", false, ["blade", "vitality"], false, 5, "vitality:1") var e := _only(q) assert_eq(e.t, "run") assert_eq(e.d.mode, "survival") assert_eq(e.d.build_picks, "blade,vitality") assert_eq(int(e.d.meta_spent), 5) _no_envelope_fields(e.d)
func test_feedback_is_a_feedback_event_and_flushes_now() -> void: var q := _queue() var g := GameplayTelemetry.new() add_child_autofree(g) g.setup(q) g._enabled = true g.send_feedback("bug", "it crashed", null, "touch") var e := _only(q) assert_eq(e.t, "feedback") assert_eq(e.d.kind, "bug") assert_eq(e.d.note, "it crashed") assert_eq(e.d.context.input, "touch") assert_eq(q.sent_bodies.size(), 1, "feedback is flushed straight away") _no_envelope_fields(e.d)
func test_an_encounter_is_an_encounter_event() -> void: var q := _queue() var d := DashEncounterTelemetry.new() add_child_autofree(d) d.setup(q) d._enabled = true d._send({"enemy_type": "charger", "chain": false, "outcome": "clear", "player_dashed": true, "closest_approach_px": 30.0, "duration_s": 1.2, "samples": []}) var e := _only(q) assert_eq(e.t, "encounter") assert_eq(e.session, "run1", "encounters now join the run's session") assert_eq(e.d.enemy_type, "charger") _no_envelope_fields(e.d)
func test_reporters_stay_silent_headless_without_the_gate_forced() -> void: var q := _queue() var g := GameplayTelemetry.new() add_child_autofree(g) g.setup(q) # headless: _enabled stays false g.report_run(_sim(), "survival", "basic", "", false) g.send_feedback("bug", "hello there", null, "pad") assert_eq(q.core.pending_count(), 0)- Step 2: Rewrite the hitch node test in
tests/test_hitch_tracker.gd
Replace test_telemetry_node_queues_a_hitch_with_context (lines 84-100, including its comment) with:
# The Telemetry node's per-frame path (disabled headless, so force it on) pushes a stall with its# context onto the EventQueue as a "hitch" event.func test_telemetry_node_queues_a_hitch_with_context() -> void: HitchTracker.reset_seen() # so the tags below are first-use ("new:") in this process var q := EventQueue.new() add_child_autofree(q) q.setup("inst", true) q.enable_for_test() var t := Telemetry.new() add_child_autofree(t) t.setup(Sim.new(1, SimContentFixture.db()), q) t._enabled = true t.state_fn = func() -> String: return "levelup" t.hitches.frame(Time.get_ticks_usec() - 200_000) # the previous frame, 200ms ago t.note_events([{"kind": "reaction", "name": "WARDEN"}]) t._track_frame() var evs := q.core.pending_events() assert_eq(evs.size(), 1) var e: Dictionary = evs[0] assert_eq(e.t, "hitch") assert_gte(float(e.d.frame_ms), 200.0) assert_eq(e.d.state, "levelup") assert_string_contains(String(e.d.recent), "reaction:WARDEN") assert_string_contains(String(e.d.recent), "state:levelup")- Step 3: Run the tests to verify they fail
Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_telemetry_reporters_queue -gexit
Expected: FAIL. setup() takes the wrong arguments, and _send_sample doesn’t exist.
- Step 4: Rewrite
net/telemetry.gd
class_name Telemetryextends Node
# Render-side perf reporter (NOT /sim). Every INTERVAL seconds pushes a "sample" event, and every# stalled frame a "hitch" event, onto the persistent EventQueue (telemetry v2), which owns the# network, the disk queue and retries. Created fresh per run by main. Silent in editor/headless and# never affects play.
const INTERVAL := 5.0const PIPELINE_MONITORS := [ Performance.PIPELINE_COMPILATIONS_CANVAS, Performance.PIPELINE_COMPILATIONS_MESH, Performance.PIPELINE_COMPILATIONS_SURFACE, Performance.PIPELINE_COMPILATIONS_DRAW, Performance.PIPELINE_COMPILATIONS_SPECIALIZATION,]
var low_fx: bool = false # set by main: true whenever adaptive quality has stepped below fullvar quality: int = 0 # current adaptive quality tier (0 = full, higher = more stripped)
var _sim: Simvar _queue: EventQueuevar _accum: float = 0.0var _enabled: bool = false
# Hitch capture (2026-09-26): every stalled frame, with what happened just before it.var hitches := HitchTracker.new()var state_fn: Callable # set by main: returns "play" / "levelup" / "pause" / ...var _pipelines_last: int = -1var _last_state: String = ""
func setup(sim: Sim, queue: EventQueue) -> void: _sim = sim _queue = queue _enabled = queue != null and EventQueue.live_platform()
func _notification(what: int) -> void: if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT \ or what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN: hitches.skip_next()
# Main forwards each frame's drained render fx events, so a stall can be tied to the effect that# was first drawn just before it (first use of a shader is the usual suspect).func note_events(events: Array) -> void: if not _enabled: return for ev in events: var kind := String(ev.get("kind", "")) if kind == "reaction" and ev.has("name"): kind = "reaction:" + String(ev.name) hitches.note(kind)
func note(tag: String) -> void: if _enabled: hitches.note(tag)
static func pipelines_total() -> int: var n := 0 for m in PIPELINE_MONITORS: n += int(Performance.get_monitor(m)) return n
func _push(t: String, d: Dictionary) -> void: if _queue != null: _queue.push(t, d)
func _track_frame() -> void: var f := hitches.frame(Time.get_ticks_usec()) var pipes := pipelines_total() var new_pipes := 0 if _pipelines_last < 0 else pipes - _pipelines_last _pipelines_last = pipes if new_pipes > 0: hitches.note("pipelines") var state := String(state_fn.call()) if state_fn.is_valid() else "" if state != _last_state: _last_state = state hitches.note("state:" + state) # a panel opening / boss arriving, right before a stall if not bool(f.hitch) or not hitches.can_send(): return hitches.sent += 1 _push("hitch", _hitch_dict(float(f.ms), new_pipes, state))
func _hitch_dict(frame_ms: float, new_pipes: int, state: String) -> Dictionary: return { "frame_ms": frame_ms, # TIME_PROCESS = the whole frame's process time, NOT script-only (see dev-tools.md Telemetry v2). "proc_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, "phys_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0, "pipelines": new_pipes, "run_time": _sim.run_time, "enemies": _sim.enemies.count, "draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), "nodes": Performance.get_monitor(Performance.OBJECT_NODE_COUNT), "state": state, "recent": hitches.recent_tags(), "quality": quality, }
func _send_sample() -> void: var win := hitches.take_window() var fps := float(Engine.get_frames_per_second()) _push("sample", { "config_id": RemoteConfig.active_id, "fps": fps, "frame_ms": 1000.0 / maxf(fps, 1.0), "sim_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0, "render_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS (legacy name) "draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), "prims": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME), "enemies": _sim.enemies.count, "zones": _sim.zones.size(), "webs": _sim.webs.size(), "run_time": _sim.run_time, "low_fx": low_fx, "quality": quality, "max_frame_ms": win.max_frame_ms, # worst single frame in this 5s window "hitches": win.hitches, # frames over HitchTracker.HITCH_MS in it })
func _process(dt: float) -> void: if not _enabled or _sim == null: return _track_frame() _accum += dt if _accum < INTERVAL: return _accum = 0.0 _send_sample()- Step 5: Rewrite
net/gameplay_telemetry.gd
class_name GameplayTelemetryextends Node
# Render-side (NOT /sim) gameplay reporter. Pushes a per-run SUMMARY ("run" event) at run end and# pause-menu feedback ("feedback" event) onto the EventQueue (telemetry v2), which owns the network,# retries and the disk queue. Active on real devices only; never affects play.
var _queue: EventQueuevar _enabled: bool = false# The config id boot() blacklisted (RemoteConfigState.newly_blacklisted), reported on the first# run summary after boot only, then cleared -- so it doesn't repeat on every run report this boot.var _pending_blacklisted: String = ""
func setup(queue: EventQueue = null) -> void: _queue = queue _enabled = queue != null and EventQueue.live_platform()
# Called once at boot (main.gd) when RemoteConfigState.newly_blacklisted is non-empty.func note_blacklisted(config_id: String) -> void: _pending_blacklisted = config_id
# The run summary payload. `build_picks` is every upgrade taken this run; `is_auto` (2026-07-17)# is main's `_auto`, i.e. "no human ever touched the controls this run" (the envelope's `auto`# carries the same value and wins at the Worker). Session/build/channel/dev ride on the envelope.static func run_body(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool, build_picks: Array, is_auto: bool, meta_spent: int, meta_levels: String, blacklisted: String) -> Dictionary: var kbt := {} for k in sim.kills_by_type: kbt[str(k)] = sim.kills_by_type[k] var t: float = maxf(sim.run_time, 0.001) return { "mode": mode, "config_id": RemoteConfig.active_id, "config_hash": RemoteConfig.active_hash, "config_blacklisted": blacklisted if blacklisted != "" else null, "run_time": sim.run_time, "level": sim.player.level, "kills": sim.kills, "gold": sim.run_gold, "dmg_dealt": sim.dmg_dealt_total, "dps": sim.dmg_dealt_total / t, "reactions": sim.reactions_total, "kills_by_type": kbt, "region": region, "decoy": decoy_type, "weapons": sim.active_weapon_ids.size(), "tutorial_done": tutorial_done, "death_cause": sim.death_cause, "peak_enemies": sim.peak_enemies, "damage_taken": sim.damage_taken_total, "weapons_list": ",".join(sim.active_weapon_ids), "build_picks": ",".join(build_picks), "ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions", "is_auto": is_auto, # Co-op (2026-09-27): `level` and the weapon fields stay P1's. "pilots": sim.pilots.size(), "level2": sim.player2.level if sim.player2 != null else null, # Shop state (2026-09-27): read at run end, so a mid-run pause-menu purchase is included. "meta_spent": meta_spent, "meta_levels": meta_levels, }
func report_run(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool, build_picks: Array = [], is_auto: bool = false, meta_spent: int = 0, meta_levels: String = "") -> void: if not _enabled or _queue == null or sim == null: return var blacklisted := _pending_blacklisted _pending_blacklisted = "" # report at most once per boot _queue.push("run", run_body(sim, mode, decoy_type, region, tutorial_done, build_picks, is_auto, meta_spent, meta_levels, blacklisted))
# Anonymous player feedback from the pause menu (2026-09-26). Signature kept for the tests that# build it directly (test_feedback_panel / test_build_channel / test_config_stamp); send_feedback# strips the envelope fields before queueing.static func feedback_body(kind: String, note: String, session: String, device: String, sim: Sim, input_style: String) -> Dictionary: var ctx := {"input": input_style} if sim != null: ctx["run_time"] = snappedf(sim.run_time, 0.1) ctx["level"] = sim.player.level ctx["kills"] = sim.kills ctx["hp_frac"] = snappedf(sim.player.hp / maxf(sim.player.max_hp, 1.0), 0.01) ctx["weapons"] = ",".join(sim.active_weapon_ids) ctx["area"] = sim.current_area return {"build": Sim_Const.BUILD, "channel": BuildChannel.current(), "channel_probe": BuildChannel.probe(), "session": session, "dev": device, "platform": OS.get_name(), "kind": kind, "note": note, "context": ctx, "config_id": RemoteConfig.active_id}
func send_feedback(kind: String, note: String, sim: Sim, input_style: String) -> void: if not _enabled or _queue == null: return var body := feedback_body(kind, note, "", "", sim, input_style) for k: String in ["build", "channel", "channel_probe", "session", "dev"]: body.erase(k) _queue.push("feedback", body) _queue.flush_now() # the player just pressed send: don't sit on it for 10 s- Step 6: Move
net/dash_encounter_telemetry.gdonto the queue
Replace:
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/encounter"with nothing (delete the line). Replace:
var _http: HTTPRequestvar _session: String = ""var _device: String = ""var _enabled: bool = falsewith:
var _queue: EventQueuevar _enabled: bool = falseReplace the whole setup():
func setup() -> void: _enabled = not OS.has_feature("editor") and DisplayServer.get_name() != "headless" if not _enabled: return _session = "%08x%08x" % [randi(), randi()] _device = TelemetryDevice.id() _http = HTTPRequest.new() add_child(_http)with:
# Telemetry v2: encounters ride the EventQueue, so they carry the RUN's session (they used to have# their own per-boot id and could not be joined to runs).func setup(queue: EventQueue = null) -> void: _queue = queue _enabled = queue != null and EventQueue.live_platform()Replace the whole _send():
func _send(enc: Dictionary) -> void: if not _enabled or _queue == null: return _queue.push("encounter", { "is_auto": false, "config_id": RemoteConfig.active_id, "enemy_type": enc.enemy_type, "chain": enc.chain, "outcome": enc.outcome, "player_dashed": enc.player_dashed, "closest_approach_px": enc.closest_approach_px, "duration_s": enc.duration_s, "samples": enc.samples, })- Step 7: Wire
main.gd
(a) After var dash_encounter_telemetry: DashEncounterTelemetry (~line 104), add:
var event_queue: EventQueue # telemetry v2: the one persistent pipe every reporter pushes through(b) In _ready, directly after MetaStore.ensure_install_id(meta) (~line 318), add:
event_queue = EventQueue.new() # persistent (never swept): the telemetry v2 event pipe add_child(event_queue) event_queue.setup(meta.install_id, true) # Envelope `auto`: the attract-mode AI is flying the live run (false at the menu). event_queue.auto_fn = func() -> bool: return sim != null and _auto(c) In _ready, change gameplay_telemetry.setup() to gameplay_telemetry.setup(event_queue), and dash_encounter_telemetry.setup() to dash_encounter_telemetry.setup(event_queue).
(d) In _setup_showcase (~line 424), add as the first line of the body:
event_queue.muted = true # showcase runs never report (telemetry v2)(e) In the pause-menu wiring (~line 530), replace:
gameplay_telemetry.send_feedback(kind, note, _run_session, sim, ButtonPrompt.current_style()))with:
gameplay_telemetry.send_feedback(kind, note, sim, ButtonPrompt.current_style()))(f) In BOTH child-sweep loops, in _return_to_menu (~line 686) and _new_run (~line 835), change the first condition line:
if c == gameplay_telemetry or c == dash_encounter_telemetry or c == quality_manager or c == control_client \to:
if c == gameplay_telemetry or c == dash_encounter_telemetry or c == event_queue or c == quality_manager or c == control_client \(g) In _return_to_menu, directly before sim = null, add:
event_queue.set_session("") # menu-time events (menu, shop_buy, feedback) belong to no run(h) In _new_run’s replay block (~line 883), after dash_encounter_telemetry._enabled = false, add:
event_queue.muted = true # replay/capture never reports (telemetry v2); holds for the session(i) In _new_run, replace:
telemetry.setup(sim, _run_session)with:
event_queue.set_session(_run_session) # every reporter's events join this run telemetry.setup(sim, event_queue)(j) In _bank_run, replace:
gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done, _run_session, _applied_upgrades, _auto, meta.gold_spent, meta.levels_code()) # gold actually paid: stable under a price patchwith:
gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done, _applied_upgrades, _auto, meta.gold_spent, meta.levels_code()) # gold actually paid: stable under a price patch- Step 8: Check that no caller still uses the old signatures
Run: /usr/bin/grep -rn "_run_session, sim\|report_run(.*_run_session\|telemetry.setup(sim, _run_session\|_hitch_queue\|HITCH_ENDPOINT\|FEEDBACK_ENDPOINT" --include="*.gd" .
Expected: no output.
- Step 9: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_telemetry_reporters_queue -gexit && godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_hitch_tracker -gexit && godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dash_encounter_telemetry -gexit
Expected: PASS for all three.
- Step 10: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS. The boot smoke proves main still boots with the queue wired.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): perf, hitch, run, encounter and feedback reporters push through EventQueue
Telemetry v2 Task 3. One session id per run across every reporter (encounters used tomint their own), install + launch ids on every event, payloads no longer repeat theenvelope. Replay/capture/showcase mute the queue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add net/telemetry.gd net/gameplay_telemetry.gd net/dash_encounter_telemetry.gd main.gd tests/test_hitch_tracker.gd tests/test_telemetry_reporters_queue.gdgit commit -F /tmp/bh-msg.txtTask 4: Lifecycle events, exit marker and prev_exit, run_start and run_end, shop_buy
Section titled “Task 4: Lifecycle events, exit marker and prev_exit, run_start and run_end, shop_buy”Files:
- Create:
net/exit_marker.gd,net/lifecycle_telemetry.gd - Modify:
net/local_json.gd(addremove) - Modify:
ui/meta_shop_panel.gd: signal list ~line 24, and_activate~line 1186 - Modify:
main.gd:- vars
_ready_show_start_menu_return_to_menu_new_run(top,_run_banked = false, after_auto = ...)- the three
_bank_run()call sites _bank_run_process- meta_shop wiring
- Test:
tests/test_exit_marker.gd,tests/test_lifecycle_telemetry.gd,tests/test_lifecycle_main.gd,tests/test_meta_shop_panel.gd(one new test)
Interfaces:
-
Consumes:
EventQueue.push,flush_now,launch_id,live_platformSim.tutorial: TutorialLessons(index,step,current_lesson())
-
Produces:
ExitMarker:FILE := "exit_marker.json"static classify(marker: Dictionary, is_web: bool) -> String:""|"crash"|"killed_in_background"|"web_closed"static prev_exit_payload(marker, is_web) -> Dictionary
LifecycleTelemetry(Node):setup(queue)boot(first_launch: bool, share: bool)boot_with_marker(marker, first_launch, share, is_web)static launch_payload(first_launch, share) -> Dictionarymarker_dict() -> Dictionarywrite_marker()on_menu()poll_tutorial(sim: Sim)on_background()on_foreground()var info_fn: Callablevar marker_writes: int
LocalJson.remove(name: String) -> voidMetaShopPanelsignal purchased(id: String, cost: int, gold_after: int)- main:
_bank_run(reason := "death")_send_run_end(reason: String)_lifecycle_info() -> Dictionaryvar lifecycle: LifecycleTelemetryvar _run_end_sent: bool
-
prev_exit.d.reasonadds"web_closed"next to the spec’s two. A web tab close never deliversWM_CLOSE, so without it every web session would read as a crash. -
Step 1: Write the failing tests
tests/test_exit_marker.gd
extends GutTest
# ExitMarker (telemetry v2): how did the last launch end? The marker is rewritten while the app# runs and removed only on a clean quit, so finding one at boot means the last launch died.
func test_no_marker_means_a_clean_last_exit() -> void: assert_eq(ExitMarker.classify({}, false), "") assert_true(ExitMarker.prev_exit_payload({}, false).is_empty())
func test_a_backgrounded_marker_is_a_background_kill() -> void: assert_eq(ExitMarker.classify({"backgrounded": true}, false), "killed_in_background")
func test_a_foreground_marker_is_a_crash() -> void: assert_eq(ExitMarker.classify({"backgrounded": false}, false), "crash")
func test_web_never_gets_a_close_notification_so_it_is_web_closed() -> void: assert_eq(ExitMarker.classify({"backgrounded": false}, true), "web_closed")
func test_the_payload_carries_the_dead_launch_and_session() -> void: var m := {"launch": "L0", "session": "S0", "build": 209, "run_time": 81.5, "state": "levelup", "mode": "survival", "recent": "new:death", "backgrounded": false, "ts": 1700000000} var p := ExitMarker.prev_exit_payload(m, false) assert_eq(p.reason, "crash") assert_eq(p.prev_launch, "L0") assert_eq(p.prev_session, "S0") assert_eq(int(p.prev_build), 209) assert_almost_eq(float(p.run_time), 81.5, 0.001) assert_eq(p.state, "levelup") assert_eq(p.mode, "survival") assert_eq(p.recent, "new:death") assert_eq(int(p.marker_ts), 1700000000)- Step 2: Write the failing tests
tests/test_lifecycle_telemetry.gd
extends GutTest
# LifecycleTelemetry (telemetry v2): launch / prev_exit / background / foreground / menu /# tutorial_step events and the exit-marker contents. File IO is off headless (not live), so the# marker is exercised through marker_dict() and boot_with_marker().
var q: EventQueuevar lc: LifecycleTelemetry
func before_each() -> void: q = EventQueue.new() add_child_autofree(q) q.setup("inst", true) q.enable_for_test() lc = LifecycleTelemetry.new() add_child_autofree(lc) lc.setup(q) lc.info_fn = func() -> Dictionary: return {"session": "run9", "run_time": 12.5, "state": "play", "mode": "survival", "recent": "death"}
func _events(t: String) -> Array: return q.core.pending_events().filter(func(e: Dictionary) -> bool: return e.t == t)
func test_boot_without_a_marker_sends_launch_only() -> void: lc.boot_with_marker({}, true, true, false) assert_eq(q.core.pending_events().map(func(e: Dictionary) -> String: return String(e.t)), ["launch"]) var d: Dictionary = _events("launch")[0].d assert_eq(d.first_launch, true) assert_eq(d.share_diagnostics, true) for k in ["os", "os_version", "model", "channel_probe"]: assert_true(d.has(k), k)
func test_boot_with_a_backgrounded_marker_adds_prev_exit() -> void: lc.boot_with_marker({"launch": "L0", "session": "S0", "backgrounded": true}, false, true, false) var p: Array = _events("prev_exit") assert_eq(p.size(), 1) assert_eq(p[0].d.reason, "killed_in_background") assert_eq(p[0].d.prev_session, "S0")
func test_background_and_foreground_are_deduped_and_paired() -> void: lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT) lc._notification(Node.NOTIFICATION_APPLICATION_PAUSED) # iOS sends both: one event assert_eq(_events("background").size(), 1) assert_eq(_events("background")[0].d.state, "play") assert_true(bool(lc.marker_dict().backgrounded), "the marker records the app went to background") assert_eq(q.sent_bodies.size(), 1, "backgrounding flushes straight away") lc._notification(Node.NOTIFICATION_APPLICATION_RESUMED) lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_IN) assert_eq(_events("foreground").size(), 1) assert_false(bool(lc.marker_dict().backgrounded))
func test_foreground_without_a_background_sends_nothing() -> void: lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_IN) assert_eq(_events("foreground").size(), 0)
func test_marker_dict_holds_what_prev_exit_needs() -> void: var m := lc.marker_dict() assert_eq(m.launch, q.launch_id()) assert_eq(m.session, "run9") assert_almost_eq(float(m.run_time), 12.5, 0.001) assert_eq(m.state, "play") assert_eq(m.mode, "survival") assert_eq(m.recent, "death") assert_eq(int(m.build), Sim_Const.BUILD) assert_false(bool(m.backgrounded))
func test_menu_event() -> void: lc.on_menu() assert_eq(_events("menu").size(), 1)
func test_tutorial_steps_are_sent_once_per_change() -> void: var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json")) sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true) assert_not_null(sim.tutorial, "sanity: the capped tutorial has a lesson track") lc.poll_tutorial(sim) lc.poll_tutorial(sim) var steps: Array = _events("tutorial_step") assert_eq(steps.size(), 1, "no change, no second event") assert_eq(steps[0].d.lesson, "wake") sim.tutorial.index = 1 sim.tutorial.step = 0 lc.poll_tutorial(sim) steps = _events("tutorial_step") assert_eq(steps.size(), 2) assert_eq(steps[1].d.lesson, "blaster") lc.poll_tutorial(null) assert_eq(_events("tutorial_step").size(), 2, "no tutorial, no event")- Step 3: Write the failing tests
tests/test_lifecycle_main.gd
extends GutTest
# main.gd wiring for telemetry v2 lifecycle events: run_start, run_end (every exit reason main can# see), menu. Headless boot auto-starts a survival run; the queue is switched to test mode after.
func _boot() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m.event_queue.enable_for_test() return m
func _of(m: Node, t: String) -> Array: return m.event_queue.core.pending_events().filter(func(e: Dictionary) -> bool: return e.t == t)
func test_quit_to_menu_sends_run_end_quit_menu_then_menu() -> void: var m = await _boot() m._return_to_menu() var ends := _of(m, "run_end") assert_eq(ends.size(), 1) assert_eq(ends[0].d.reason, "quit_menu") assert_true(ends[0].d.has("run_time") and ends[0].d.has("level")) assert_eq(_of(m, "menu").size(), 1)
func test_a_death_sends_run_end_death_once_even_if_the_player_then_quits() -> void: var m = await _boot() m._bank_run("death") m._return_to_menu() var ends := _of(m, "run_end") assert_eq(ends.size(), 1) assert_eq(ends[0].d.reason, "death")
func test_restarting_a_live_run_sends_dev_restart_then_run_start() -> void: var m = await _boot() m._on_mode_chosen("survival") var ends := _of(m, "run_end") assert_eq(ends.size(), 1) assert_eq(ends[0].d.reason, "dev_restart") var starts := _of(m, "run_start") assert_eq(starts.size(), 1) assert_eq(starts[0].d.mode, "survival") assert_eq(starts[0].d.ruleset, "survival") assert_true(starts[0].d.has("meta_spent") and starts[0].d.has("banked_gold") and starts[0].d.has("ship")) assert_eq(starts[0].session, m._run_session, "run_start carries the new run's session")- Step 4: Add the shop signal test to
tests/test_meta_shop_panel.gd(append at the end)
# Telemetry v2 shop_buy: a purchase reports what it cost and the gold left.func test_a_purchase_emits_purchased_with_price_and_gold_after() -> void: var meta := MetaState.new() meta.banked_gold = 100000 var p: MetaShopPanel = await _open(meta) var price := meta.cost(_def(_defs(), "vitality")) watch_signals(p) p._activate("vitality") assert_signal_emitted_with_parameters(p, "purchased", ["vitality", price, 100000 - price]) p.hide_panel()- Step 5: Run the tests to verify they fail
Run: godot --headless --path . --import; for t in test_exit_marker test_lifecycle_telemetry test_lifecycle_main test_meta_shop_panel; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit; done
Expected:
-
FAIL on unknown classes
ExitMarker/LifecycleTelemetry,event_queuepushes with norun_end, and a missingpurchasedsignal. -
The pre-existing
test_meta_shop_paneltests still pass. -
Step 6: Add
LocalJson.removetonet/local_json.gd(append)
static func remove(name: String) -> void: if not io_enabled: return for p in _paths(name): if FileAccess.file_exists(p): DirAccess.remove_absolute(ProjectSettings.globalize_path(p))- Step 7: Create
net/exit_marker.gd
class_name ExitMarkerextends RefCounted
# Telemetry v2: how did the LAST launch end? LifecycleTelemetry rewrites exit_marker.json while# the app runs (every 5 s, on run start, on background/foreground) and removes it on a clean quit# (WM_CLOSE_REQUEST). Finding it at boot means the last launch died. iOS/tvOS apps are normally# killed in the background (never a WM_CLOSE), which is why `backgrounded` is recorded.# Pure: no file IO here. Separate from RemoteConfigState's crash marker (that one drives# config blacklisting and only arms for non-default configs).
const FILE := "exit_marker.json"
static func classify(marker: Dictionary, is_web: bool) -> String: if marker.is_empty(): return "" if is_web: return "web_closed" # a tab close never delivers WM_CLOSE: not evidence of a crash if bool(marker.get("backgrounded", false)): return "killed_in_background" return "crash" # died in the foreground: crash, watchdog or OOM
static func prev_exit_payload(marker: Dictionary, is_web: bool) -> Dictionary: var reason := classify(marker, is_web) if reason == "": return {} return {"reason": reason, "prev_launch": String(marker.get("launch", "")), "prev_session": String(marker.get("session", "")), "prev_build": int(marker.get("build", 0)), "run_time": float(marker.get("run_time", 0.0)), "state": String(marker.get("state", "")), "mode": String(marker.get("mode", "")), "recent": String(marker.get("recent", "")), "marker_ts": int(marker.get("ts", 0))}- Step 8: Create
net/lifecycle_telemetry.gd
class_name LifecycleTelemetryextends Node
# Telemetry v2 lifecycle reporter (render-side, persistent). It sends:# - launch, and prev_exit when the last launch died (ExitMarker)# - background / foreground (deduped: iOS sends FOCUS_OUT and PAUSED together)# - menu, and tutorial_step on each lesson/step change (a render-side poll: no sim change)# It also keeps the exit marker current. main supplies what the marker records via `info_fn`.# File IO only when live (not editor/headless), so GUT never touches the real marker.
const MARKER_S := 5.0
var info_fn: Callable # main._lifecycle_info: {session, run_time, state, mode, recent}var marker_writes: int = 0 # diagnostic / test seamvar _queue: EventQueuevar _live: bool = falsevar _accum: float = 0.0var _backgrounded: bool = falsevar _tutorial_key: String = ""
func setup(queue: EventQueue) -> void: _queue = queue _live = EventQueue.live_platform()
func _push(t: String, d: Dictionary) -> void: if _queue != null: _queue.push(t, d)
func boot(first_launch: bool, share: bool) -> void: var marker: Dictionary = {} if _live: marker = LocalJson.read(ExitMarker.FILE) boot_with_marker(marker, first_launch, share, OS.has_feature("web")) write_marker()
func boot_with_marker(marker: Dictionary, first_launch: bool, share: bool, is_web: bool) -> void: _push("launch", launch_payload(first_launch, share)) var prev := ExitMarker.prev_exit_payload(marker, is_web) if not prev.is_empty(): _push("prev_exit", prev)
# Locale-free by design. channel_probe rides here once per launch instead of on every event.static func launch_payload(first_launch: bool, share: bool) -> Dictionary: return {"os": OS.get_name(), "os_version": OS.get_version(), "model": OS.get_model_name(), "first_launch": first_launch, "share_diagnostics": share, "channel_probe": BuildChannel.probe()}
func _info() -> Dictionary: if info_fn.is_valid(): var v: Variant = info_fn.call() if v is Dictionary: return v return {}
func marker_dict() -> Dictionary: var info := _info() return {"launch": _queue.launch_id() if _queue != null else "", "session": String(info.get("session", "")), "run_time": float(info.get("run_time", 0.0)), "state": String(info.get("state", "")), "mode": String(info.get("mode", "")), "recent": String(info.get("recent", "")), "build": Sim_Const.BUILD, "backgrounded": _backgrounded, "ts": int(Time.get_unix_time_from_system())}
func write_marker() -> void: marker_writes += 1 if _live: LocalJson.write(ExitMarker.FILE, marker_dict())
func _process(dt: float) -> void: _accum += dt if _accum >= MARKER_S: _accum = 0.0 write_marker()
func on_menu() -> void: _push("menu", {})
func poll_tutorial(sim: Sim) -> void: if sim == null or sim.tutorial == null: _tutorial_key = "" return var t: TutorialLessons = sim.tutorial var key := "%d:%d" % [t.index, t.step] if key == _tutorial_key: return _tutorial_key = key _push("tutorial_step", {"lesson": String(t.current_lesson().get("id", "done")), "lesson_i": t.index, "step": t.step, "run_time": snappedf(sim.run_time, 0.1)})
func on_background() -> void: if _backgrounded: return _backgrounded = true var info := _info() _push("background", {"state": String(info.get("state", "")), "run_time": float(info.get("run_time", 0.0))}) write_marker() if _queue != null: _queue.flush_now() # the process may never come back
func on_foreground() -> void: if not _backgrounded: return _backgrounded = false var info := _info() _push("foreground", {"state": String(info.get("state", "")), "run_time": float(info.get("run_time", 0.0))}) write_marker()
func _notification(what: int) -> void: if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT: on_background() elif what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN: on_foreground() elif what == NOTIFICATION_WM_CLOSE_REQUEST: if _live: LocalJson.remove(ExitMarker.FILE) # a clean quit: no prev_exit next boot- Step 9: Emit
purchasedfromui/meta_shop_panel.gd
After signal bay_requested ... (line 24), add:
signal purchased(id: String, cost: int, gold_after: int) # telemetry v2 shop_buyIn _activate, replace:
if _meta.buy(def): var nudge := _after_drone_buy(def) # may auto-equip a new class: before the savewith:
var price := _meta.cost(def) # read before buy(): buying raises the level and the price if _meta.buy(def): purchased.emit(id, price, _meta.banked_gold) var nudge := _after_drone_buy(def) # may auto-equip a new class: before the save- Step 10: Wire
main.gd
(a) Vars, after var event_queue: EventQueue:
var lifecycle: LifecycleTelemetry # telemetry v2: launch/prev_exit/background/menu/tutorial_step + exit markervar _run_end_sent := false # this run already reported its run_end (one per run)(b) In _ready, replace:
meta = MetaStore.load_state() # persistent progression, survives _new_run() MetaStore.ensure_install_id(meta) # never writes over a save this build failed to readwith:
meta = MetaStore.load_state() # persistent progression, survives _new_run() # First launch = nothing on this device yet. Read before ensure_install_id mints the id. var first_launch := meta.install_id == "" and meta.banked_gold == 0 and meta.gold_spent == 0 \ and not meta.tutorial_done MetaStore.ensure_install_id(meta) # never writes over a save this build failed to readAfter the event_queue.auto_fn = ... line from Task 3, add:
lifecycle = LifecycleTelemetry.new() # persistent: launch / prev_exit / lifecycle events add_child(lifecycle) lifecycle.setup(event_queue) lifecycle.info_fn = _lifecycle_info lifecycle.boot(first_launch, true)(c) In _ready, right after meta_shop = MetaShopPanel.new() (~line 370), add:
meta_shop.purchased.connect(func(id: String, cost: int, gold_after: int) -> void: event_queue.push("shop_buy", {"id": id, "cost": cost, "gold_after": gold_after, "level": meta.level_of(id), "in_run": sim != null}))(d) Both child-sweep conditions (from Task 3 (f)) gain or c == lifecycle after c == event_queue.
(e) In _show_start_menu, directly after the perf_bench/auto_showcase early return block, add:
if lifecycle != null: lifecycle.on_menu()(f) In _return_to_menu, as the first line of the body:
_send_run_end("quit_menu") # no-op if this run already ended (death / story / tutorial cap)(g) In _new_run, as the first line of the body (before the child sweep; sim is still the old run):
_send_run_end("dev_restart") # a live run replaced without ending (dev tools / tests); no-op after a real endand next to _run_banked = false, add:
_run_end_sent = false(h) In _new_run, directly after _auto = not _is_story() (~line 1289), add:
event_queue.push("run_start", {"mode": _mode, "ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions", "meta_spent": meta.gold_spent if meta != null else 0, "banked_gold": meta.banked_gold if meta != null else 0, "meta_levels": meta.levels_code() if meta != null else "", "ship": meta.selected_ship if meta != null else ""}) lifecycle.write_marker() # the marker now names this run(i) The three _bank_run() call sites in _physics_process become:
- tutorial cap (~line 1499):
_bank_run("tutorial_cap") - story complete (~line 1516):
_bank_run("story_complete") - game over (~line 1539):
_bank_run("death")
(j) Change _bank_run’s signature and add the run_end at the end of its body:
func _bank_run(reason: String = "death") -> void:Directly after the gameplay_telemetry.report_run(...) call, add:
_send_run_end(reason)(k) Add these two functions after _bank_run:
# Telemetry v2: exactly one run_end per run, with why it ended. A banked run also writes a `runs`# row (report_run); quit_menu / dev_restart write only this event (runs stays banked-runs-only).func _send_run_end(reason: String) -> void: if _run_end_sent or sim == null: return _run_end_sent = true event_queue.push("run_end", {"reason": reason, "mode": _mode, "run_time": snappedf(sim.run_time, 0.1), "level": sim.player.level, "kills": sim.kills}) event_queue.flush_now()
# What the exit marker records (LifecycleTelemetry.info_fn).func _lifecycle_info() -> Dictionary: var recent := "" if sim != null and is_instance_valid(telemetry): recent = telemetry.hitches.recent_tags() return {"session": _run_session if sim != null else "", "run_time": sim.run_time if sim != null else 0.0, "state": _telemetry_state(), "mode": _mode if sim != null else "", "recent": recent}(l) In _process, directly BEFORE if sim == null: / return # start menu is up; no run yet, add:
if lifecycle != null: lifecycle.poll_tutorial(sim) # telemetry v2 tutorial_step (null-safe: resets at the menu)- Step 11: Run the tests to verify they pass
Run: godot --headless --path . --import; for t in test_exit_marker test_lifecycle_telemetry test_lifecycle_main test_meta_shop_panel test_main; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done
Expected: PASS for all five. test_main is included because _bank_run/_new_run/_return_to_menu changed.
- Step 12: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS. The tutorial trace must still print lessons: ok; poll_tutorial only reads.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): lifecycle events, exit marker + prev_exit, run_start/run_end reasons, shop_buy
Telemetry v2 Task 4. launch (+ prev_exit when the last launch died: crash vskilled_in_background vs web_closed), background/foreground, menu, tutorial_step,run_start, run_end{death|story_complete|tutorial_cap|quit_menu|dev_restart}, shop_buy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add net/exit_marker.gd net/lifecycle_telemetry.gd net/local_json.gd ui/meta_shop_panel.gd main.gd tests/test_exit_marker.gd tests/test_lifecycle_telemetry.gd tests/test_lifecycle_main.gd tests/test_meta_shop_panel.gdgit commit -F /tmp/bh-msg.txtTask 5: Run timeline (XP by enemy type, boss log, level-ups offered and picked, damage by weapon)
Section titled “Task 5: Run timeline (XP by enemy type, boss log, level-ups offered and picked, damage by weapon)”Files:
- Modify:
sim/sim.gd:- fields after
var damage_by_weapon(~line 355) spawn_story_boss(~line 1038)_award_xp(~line 2541)_sweep_dead(~lines 2590-2625)
- fields after
- Modify:
sim/boss_warden.gd:85,sim/boss2.gd:50,sim/funzo.gd:91,sim/graviton.gd:61,sim/eye.gd:68 - Create:
net/run_timeline.gd - Modify:
net/gameplay_telemetry.gd(report_rungainstimeline) - Modify:
main.gd:- vars
_new_run(_applied_upgrades = [], ~line 1329)_open_levelup(~line 1960)_on_upgrade_chosen(~line 2605)_bank_run
- Test:
tests/test_run_timeline_sim.gd,tests/test_run_timeline.gd
Interfaces:
-
Consumes:
GameplayTelemetry.report_runfrom Task 3EnemyPool.type_name(t: int) -> StringSim.damage_by_weapon
-
Produces:
- Sim:
Sim.xp_by_type: Dictionary(type_id → float XP actually awarded)Sim.boss_log: Array([type_id: int, spawn_t: float, kill_t: float],kill_t = -1.0while alive)Sim.note_boss_spawn(type_id: int) -> voidSim._award_xp(...) -> float(was-> void)
- RunTimeline:
RunTimeline.note_offered(run_time: float, level: int, ids: Array) -> voidRunTimeline.note_picked(id: String) -> voidRunTimeline.to_dict(sim: Sim) -> Dictionary(keyslevelups,xp_by_type,damage_by_weapon,bosses)
GameplayTelemetry.report_run(..., meta_levels := "", timeline: Dictionary = {})→d.timeline
- Sim:
-
Determinism:
- None of the new sim fields are read by the sim or touched by
state_checksum()/snapshot_string(). _award_xponly gains a return value.- No RNG draw and no ordering change.
- None of the new sim fields are read by the sim or touched by
-
Note:
spawn_story_bossdoes NOT bump_boss_spawn_count(only the five survival directors do), so it gets its ownnote_boss_spawncall. -
Step 1: Write the failing sim tests
tests/test_run_timeline_sim.gd
extends GutTest
# Telemetry v2 run timeline, sim half: xp_by_type and boss_log are filled through the REAL# spawn -> damage -> _sweep_dead path (a hand-set counter would prove nothing -- see# memory bullet-heaven-story-mode-xp-was-zero), and they never feed the checksum.
func test_xp_by_type_records_the_xp_a_real_kill_awarded() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.player.pos = Vector2.ZERO sim._spawn_one(EnemyPool.TYPE_SWARMER, Vector2(200, 0)) # run_time 0 < ELITE_START: never elite var i := sim.enemies.count - 1 assert_eq(sim.enemies.type_id[i], EnemyPool.TYPE_SWARMER) var expected := sim._xp_worth(i) * sim.area_reward_mult * sim._xp_depth_mult() assert_gt(expected, 0.0, "sanity: the spawn path set biomass") sim.projectiles.add_proj(sim.enemies.pos[i], Vector2.ZERO, 6.0, 1.0e9, 1.0) sim.elemental_system.resolve_collisions(sim) sim._sweep_dead() assert_eq(sim.kills_by_type.get(EnemyPool.TYPE_SWARMER, 0), 1, "sanity: it died through the sweep") assert_almost_eq(float(sim.xp_by_type.get(EnemyPool.TYPE_SWARMER, 0.0)), expected, 0.001)
func test_boss_log_records_spawn_and_kill_times() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.run_time = 30.0 sim.boss_warden.spawn(sim) assert_eq(sim.boss_log.size(), 1) assert_eq(int(sim.boss_log[0][0]), EnemyPool.TYPE_BOSS) assert_almost_eq(float(sim.boss_log[0][1]), 30.0, 0.001) assert_lt(float(sim.boss_log[0][2]), 0.0, "alive: no kill time yet") var bi := -1 for k in sim.enemies.count: if sim.enemies.type_id[k] == EnemyPool.TYPE_BOSS: bi = k assert_gte(bi, 0, "sanity: the Warden is in the pool") sim.run_time = 72.5 sim.elemental_system.damage_enemy(sim, bi, 1.0e12, true) sim._sweep_dead() assert_almost_eq(float(sim.boss_log[0][2]), 72.5, 0.001) assert_gt(float(sim.xp_by_type.get(EnemyPool.TYPE_BOSS, 0.0)), 0.0)
func test_every_survival_boss_director_logs_its_spawn() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.boss2_director.spawn(sim, Vector2(300, 0)) sim.funzo_director.spawn(sim, Vector2(-300, 0)) sim.graviton_director.spawn(sim, Vector2(0, 300)) sim.eye_director.spawn(sim, Vector2(0, -300)) var types := sim.boss_log.map(func(b: Array) -> int: return int(b[0])) assert_eq(types, [EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO, EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE])
func test_a_story_boss_logs_its_spawn() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.spawn_story_boss(Vector2(250, 0), 0.25) assert_eq(sim.boss_log.size(), 1) assert_eq(int(sim.boss_log[0][0]), EnemyPool.TYPE_BOSS)
func test_timeline_counters_never_feed_the_checksum() -> void: var sim := Sim.new(1234, SimContentFixture.db()) for k in 120: sim.tick_single(InputState.new()) var before := sim.state_checksum() var snap := sim.snapshot_string() sim.xp_by_type[EnemyPool.TYPE_SWARMER] = 999.0 sim.boss_log.append([EnemyPool.TYPE_BOSS, 1.0, 2.0]) assert_eq(sim.state_checksum(), before) assert_eq(sim.snapshot_string(), snap)- Step 2: Write the failing timeline tests
tests/test_run_timeline.gd
extends GutTest
# RunTimeline (render-side): level-ups offered vs picked, and the run-end timeline dict that goes# out in the "run" event (runs.timeline_json).
func test_offered_then_picked_is_one_entry() -> void: var tl := RunTimeline.new() tl.note_offered(12.34, 2, ["a", "b", "c"]) tl.note_picked("b") assert_eq(tl.levelups, [[12.3, 2, ["a", "b", "c"], "b"]])
func test_a_pick_without_an_offer_is_ignored_and_a_second_pick_does_not_overwrite() -> void: var tl := RunTimeline.new() tl.note_picked("x") assert_eq(tl.levelups.size(), 0) tl.note_offered(1.0, 2, ["a"]) tl.note_picked("a") tl.note_picked("z") assert_eq(tl.levelups[0][3], "a")
func test_levelups_are_capped() -> void: var tl := RunTimeline.new() for k in RunTimeline.MAX_LEVELUPS + 10: tl.note_offered(float(k), k, ["a"]) assert_eq(tl.levelups.size(), RunTimeline.MAX_LEVELUPS)
func test_to_dict_names_types_and_rounds() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.xp_by_type = {EnemyPool.TYPE_SWARMER: 12.345} sim.damage_by_weapon = {"blade": 901.6} sim.boss_log = [[EnemyPool.TYPE_BOSS, 80.04, 141.26], [EnemyPool.TYPE_EYE, 300.0, -1.0]] var tl := RunTimeline.new() tl.note_offered(5.0, 2, ["a", "b"]) tl.note_picked("a") var d := tl.to_dict(sim) assert_eq(d.levelups, [[5.0, 2, ["a", "b"], "a"]]) assert_eq(d.xp_by_type, {EnemyPool.type_name(EnemyPool.TYPE_SWARMER): 12.3}) assert_eq(d.damage_by_weapon, {"blade": 902.0}) assert_eq(d.bosses, [[EnemyPool.type_name(EnemyPool.TYPE_BOSS), 80.0, 141.3], [EnemyPool.type_name(EnemyPool.TYPE_EYE), 300.0, null]])
func test_the_run_event_carries_the_timeline() -> void: var q := EventQueue.new() add_child_autofree(q) q.setup("inst", true) q.enable_for_test() var g := GameplayTelemetry.new() add_child_autofree(g) g.setup(q) g._enabled = true g.report_run(Sim.new(1, SimContentFixture.db()), "survival", "basic", "", false, [], false, 0, "", {"levelups": [[1.0, 2, ["a"], "a"]]}) var e: Dictionary = q.core.pending_events()[0] assert_eq(e.d.timeline.levelups.size(), 1)
func test_main_records_the_offer_and_the_pick() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") m.sim.player.pending_levelups = 1 m._physics_process(1.0 / 60.0) assert_true(m._paused_for_levelup, "sanity: the panel opened") var offered: Array = m._current_choice_ids.duplicate() m._on_upgrade_chosen(offered[0]) assert_eq(m._timeline.levelups.size(), 1) assert_eq(m._timeline.levelups[0][2], offered) assert_eq(m._timeline.levelups[0][3], offered[0])- Step 3: Run the tests to verify they fail
Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_run_timeline -gexit
Expected: FAIL (xp_by_type/boss_log/RunTimeline not declared). -gselect=test_run_timeline matches both files.
- Step 4: Add the sim counters in
sim/sim.gd
After var damage_by_weapon: Dictionary = {} (~line 355), add:
# Telemetry v2 run timeline (NOT checksummed, never read by the sim): the XP actually awarded per# enemy type (after area/depth multipliers; written in _sweep_dead), and one# [type_id, spawn_t, kill_t] per boss spawn (kill_t = -1.0 while alive; written by the boss spawn# functions and _sweep_dead). Same class as kills_by_type.var xp_by_type: Dictionary = {} # type_id -> floatvar boss_log: Array = [] # [[type_id: int, spawn_t: float, kill_t: float]]
func note_boss_spawn(type_id: int) -> void: boss_log.append([type_id, run_time, -1.0])
func _note_boss_kill(type_id: int) -> void: for k in range(boss_log.size() - 1, -1, -1): var e: Array = boss_log[k] if int(e[0]) == type_id and float(e[2]) < 0.0: e[2] = run_time return- Step 5: Make
_award_xpreturn what it awarded
Replace:
func _award_xp(amount: float, pos: Vector2) -> void: if amount <= 0.0: return var v := amount * area_reward_mult * _xp_depth_mult() if AUTO_COLLECT_XP: for pilot in pilots: if pilot.hp > 0.0: _bank_xp(pilot, v) else: gems.add(pos, Vector2.ZERO, GEM_RADIUS, v)with:
func _award_xp(amount: float, pos: Vector2) -> float: if amount <= 0.0: return 0.0 var v := amount * area_reward_mult * _xp_depth_mult() if AUTO_COLLECT_XP: for pilot in pilots: if pilot.hp > 0.0: _bank_xp(pilot, v) else: gems.add(pos, Vector2.ZERO, GEM_RADIUS, v) return v # telemetry v2 xp_by_type (once per kill, not per pilot)Then run /usr/bin/grep -n "_award_xp(" sim/*.gd. Expected: the definition, plus exactly one call in _sweep_dead. If there are more calls, they keep compiling, because a discarded return value is legal.
- Step 6: Record XP and boss deaths in
_sweep_dead
Replace:
if dead_type in [EnemyPool.TYPE_BOSS, EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO, EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE]: boss_died = true # dimension-choice popup rolls after the sweepwith:
if dead_type in [EnemyPool.TYPE_BOSS, EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO, EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE]: boss_died = true # dimension-choice popup rolls after the sweep _note_boss_kill(dead_type) # telemetry v2 boss time-to-killReplace:
_award_xp(xp_amount, dead_pos)with:
var got := _award_xp(xp_amount, dead_pos) xp_by_type[dead_type] = float(xp_by_type.get(dead_type, 0.0)) + got # telemetry v2- Step 7: Log boss spawns
In each file, add ONE line directly after its sim._boss_spawn_count += 1:
| File (line) | Line to add |
|---|---|
sim/boss_warden.gd (85) |
sim.note_boss_spawn(EnemyPool.TYPE_BOSS) # telemetry v2 boss_log |
sim/boss2.gd (50) |
sim.note_boss_spawn(EnemyPool.TYPE_BOSS2) # telemetry v2 boss_log |
sim/funzo.gd (91) |
sim.note_boss_spawn(EnemyPool.TYPE_FUNZO) # telemetry v2 boss_log |
sim/graviton.gd (61) |
sim.note_boss_spawn(EnemyPool.TYPE_GRAVITON) # telemetry v2 boss_log |
sim/eye.gd (68) |
sim.note_boss_spawn(EnemyPool.TYPE_EYE) # telemetry v2 boss_log |
The indentation must match the _boss_spawn_count line above it. Check with /usr/bin/grep -n -A1 "_boss_spawn_count += 1" sim/boss_warden.gd sim/boss2.gd sim/funzo.gd sim/graviton.gd sim/eye.gd.
In sim/sim.gd spawn_story_boss, after boss.max_hp = hp ..., add:
note_boss_spawn(EnemyPool.TYPE_BOSS) # telemetry v2 (story Wardens don't bump _boss_spawn_count)- Step 8: Create
net/run_timeline.gd
class_name RunTimelineextends RefCounted
# Telemetry v2 run timeline (render-side). main records each level-up panel: when, at what level,# the ids offered, the id picked (including autopilot picks, which go through _on_upgrade_chosen).# to_dict() adds the sim's counters and becomes `timeline` in the "run" event -> runs.timeline_json.
const MAX_LEVELUPS := 200 # keeps the JSON well under the Worker's 60000-char timeline cap
var levelups: Array = [] # [run_time, level, [offered ids], picked id ("" until picked)]
func note_offered(run_time: float, level: int, ids: Array) -> void: if levelups.size() >= MAX_LEVELUPS: return levelups.append([snappedf(run_time, 0.1), level, ids.duplicate(), ""])
func note_picked(id: String) -> void: if levelups.is_empty(): return var last: Array = levelups[-1] if String(last[3]) == "": last[3] = id
func to_dict(sim: Sim) -> Dictionary: var xp := {} for k in sim.xp_by_type: var n := EnemyPool.type_name(int(k)) xp[n] = snappedf(float(xp.get(n, 0.0)) + float(sim.xp_by_type[k]), 0.1) var dmg := {} for k in sim.damage_by_weapon: dmg[str(k)] = roundf(float(sim.damage_by_weapon[k])) var bosses := [] for b: Array in sim.boss_log: var kill: Variant = null # still alive at run end if float(b[2]) >= 0.0: kill = snappedf(float(b[2]), 0.1) bosses.append([EnemyPool.type_name(int(b[0])), snappedf(float(b[1]), 0.1), kill]) return {"levelups": levelups.duplicate(true), "xp_by_type": xp, "damage_by_weapon": dmg, "bosses": bosses}- Step 9: Send it with the run
In net/gameplay_telemetry.gd, change the report_run signature and body to:
func report_run(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool, build_picks: Array = [], is_auto: bool = false, meta_spent: int = 0, meta_levels: String = "", timeline: Dictionary = {}) -> void: if not _enabled or _queue == null or sim == null: return var blacklisted := _pending_blacklisted _pending_blacklisted = "" # report at most once per boot var body := run_body(sim, mode, decoy_type, region, tutorial_done, build_picks, is_auto, meta_spent, meta_levels, blacklisted) if not timeline.is_empty(): body["timeline"] = timeline # telemetry v2 -> runs.timeline_json _queue.push("run", body)In main.gd:
(a) Add a var after var _run_end_sent := false:
var _timeline := RunTimeline.new() # telemetry v2: this run's level-ups offered/picked(b) In _new_run, next to _applied_upgrades = [] (~line 1329), add:
_timeline = RunTimeline.new()(c) In _open_levelup, directly after _current_choice_ids = ids, add:
_timeline.note_offered(sim.run_time, _leveling_pilot.level if _leveling_pilot != null else sim.player.level, ids)(d) In _on_upgrade_chosen, directly after _applied_upgrades.append(id), add:
_timeline.note_picked(id)(e) In _bank_run, change the report call to pass the timeline:
gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done, _applied_upgrades, _auto, meta.gold_spent, meta.levels_code(), # gold actually paid: stable under a price patch _timeline.to_dict(sim))- Step 10: Run the tests to verify they pass
Run: godot --headless --path . --import; for t in test_run_timeline test_determinism_survival test_collision_damage test_wyrm test_main; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done
Expected: PASS for all of them. test_determinism_survival passing with its pinned hash is the proof that the baseline did not move.
- Step 11: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): run timeline — XP by enemy type, boss spawn/kill log, level-ups offered vs picked
Telemetry v2 Task 5. Sim gains xp_by_type + boss_log (counters outside the checksum, no rng,determinism baseline unchanged). damage_by_weapon now sent. All of it rides the run eventas `timeline` -> runs.timeline_json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add sim/sim.gd sim/boss_warden.gd sim/boss2.gd sim/funzo.gd sim/graviton.gd sim/eye.gd net/run_timeline.gd net/gameplay_telemetry.gd main.gd tests/test_run_timeline_sim.gd tests/test_run_timeline.gdgit commit -F /tmp/bh-msg.txtTask 6: Perf — GPU and render-CPU time, a hitch sampler instead of “first 40”, honest labels
Section titled “Task 6: Perf — GPU and render-CPU time, a hitch sampler instead of “first 40”, honest labels”Files:
- Create:
net/hitch_sampler.gd - Modify:
net/hitch_tracker.gd(remove the per-session cap) - Modify (full rewrite shown):
net/telemetry.gd - Modify:
main.gd(flush held hitches before the session changes) - Modify:
telemetry/src/worker.js(dashboard perf query, row and labels) - Modify:
telemetry/schema.sql(the hitches comment) - Modify:
tests/test_hitch_tracker.gd(deletetest_session_cap, lines 79-82) - Test:
tests/test_hitch_sampler.gd,tests/test_telemetry_reporters_queue.gd(one new test)
Interfaces:
-
Consumes:
Telemetryfrom Task 3. -
Produces:
- HitchSampler:
const WINDOW_S := 30.0,WORST_PER_WINDOW := 2,MAX_PER_RUN := 200static must_send(h: Dictionary) -> booloffer(h: Dictionary, now_s: float) -> Array[Dictionary]poll(now_s: float) -> Array[Dictionary]drain() -> Array[Dictionary]var sent: int
- Telemetry:
static Telemetry.render_times(vp: RID) -> Dictionary({gpu_ms, render_cpu_ms}, values null when ≤ 0)Telemetry.flush_held() -> void
- HitchSampler:
-
HitchTracker.MAX_PER_SESSION,HitchTracker.sentandHitchTracker.can_send()are deleted. Before deleting, check with/usr/bin/grep -rn "can_send\|MAX_PER_SESSION\|hitches.sent" --include="*.gd" .. After this task it must return nothing. -
render_ms/proc_mskeep their names and values (both arePerformance.TIME_PROCESS, the whole frame’s process time). Only the labels change. -
Units:
RenderingServer.viewport_get_measured_render_time_gpu/cpuandget_frame_setup_time_cpureturn milliseconds [Likely, per the Godot 4 docs]. The units are confirmed on one device sample in Chris-gated step G10 (sanity:render_cpu_msshould land in roughly 0.5-20 on the ATV). Correction (Task 6 review, 2026-09-29):gpu_msis always null on tvOS/iOS and web — the Metal driver in thegodot-tvosfork has no timestamp queries (drivers/metal/rendering_device_driver_metal.cpp,timestamp_query_pool_get_resultszeroes the buffer) and GLES only queries timestamps on desktop GL.gpu_msis kept (free; fills on desktop Vulkan/D3D12/GL). The render-cost metric on our platforms isrender_cpu_ms, summed over the root viewport and every UPDATE_ALWAYS SubViewport (nebula backdrop, 3D ship); as shipped,render_timestakesArray[RID]andrender_times_from(gpu, viewports_cpu, setup_cpu)is the pure null rule. -
Step 1: Write the failing sampler tests
tests/test_hitch_sampler.gd
extends GutTest
# HitchSampler (telemetry v2): first-use / pipeline-compile hitches always go; ordinary ones only# the worst 2 per 30 s window; never more than 200 per run.
func _h(ms: float, recent := "", pipelines := 0) -> Dictionary: return {"frame_ms": ms, "recent": recent, "pipelines": pipelines}
func test_must_send_is_first_use_or_a_pipeline_compile() -> void: assert_true(HitchSampler.must_send(_h(70.0, "death,new:reaction:WARDEN"))) assert_true(HitchSampler.must_send(_h(70.0, "", 2))) assert_false(HitchSampler.must_send(_h(300.0, "death,state:play")))
func test_a_must_send_hitch_goes_immediately() -> void: var s := HitchSampler.new() var out := s.offer(_h(80.0, "new:death"), 0.0) assert_eq(out.size(), 1) assert_eq(s.sent, 1)
func test_ordinary_hitches_keep_the_worst_two_until_the_window_closes() -> void: var s := HitchSampler.new() assert_eq(s.offer(_h(70.0), 0.0).size(), 0) assert_eq(s.offer(_h(200.0), 5.0).size(), 0) assert_eq(s.offer(_h(90.0), 10.0).size(), 0) assert_eq(s.offer(_h(150.0), 20.0).size(), 0) assert_eq(s.poll(29.9).size(), 0, "window still open") var out := s.poll(30.0) assert_eq(out.map(func(h: Dictionary) -> float: return float(h.frame_ms)), [200.0, 150.0]) assert_eq(s.poll(31.0).size(), 0, "a new window starts empty")
func test_drain_hands_back_what_is_held() -> void: var s := HitchSampler.new() s.offer(_h(70.0), 0.0) assert_eq(s.drain().size(), 1) assert_eq(s.drain().size(), 0)
func test_never_more_than_200_per_run() -> void: var s := HitchSampler.new() var total := 0 for k in 250: total += s.offer(_h(80.0, "new:x%d" % k), float(k)).size() assert_eq(total, HitchSampler.MAX_PER_RUN) assert_eq(s.sent, HitchSampler.MAX_PER_RUN)Append to tests/test_telemetry_reporters_queue.gd:
func test_a_sample_carries_gpu_and_render_cpu_keys() -> void: var q := _queue() var t := Telemetry.new() add_child_autofree(t) t.setup(_sim(), q) t._enabled = true t._send_sample() var e := _only(q) assert_true(e.d.has("gpu_ms"), "present (null when the renderer can't measure, e.g. headless)") assert_true(e.d.has("render_cpu_ms"))
func test_render_times_turn_zero_into_null() -> void: # Headless never measures, so both read 0 and must go out as null, not 0. var rt := Telemetry.render_times(get_viewport().get_viewport_rid()) assert_null(rt.gpu_ms) assert_null(rt.render_cpu_ms)- Step 2: Run the tests to verify they fail
Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_hitch_sampler -gexit
Expected: FAIL (HitchSampler not declared).
- Step 3: Create
net/hitch_sampler.gd
class_name HitchSamplerextends RefCounted
# Which hitch frames become "hitch" events (telemetry v2). It replaces "the first 40 per run", which# stored 3% of real stalls and only the early ones. Pure: Telemetry feeds it wall-clock seconds.# - A hitch whose recent tags carry a first-use marker ("new:") or that compiled pipelines always# goes: those are the fixable ones.# - Otherwise only the worst WORST_PER_WINDOW per WINDOW_S window, sent when the window closes.# - Never more than MAX_PER_RUN in total (one sampler per run: Telemetry is per-run).
const WINDOW_S := 30.0const WORST_PER_WINDOW := 2const MAX_PER_RUN := 200
var sent: int = 0var _window_start: float = -1.0var _held: Array[Dictionary] = [] # worst first
static func must_send(h: Dictionary) -> bool: return String(h.get("recent", "")).contains("new:") or int(h.get("pipelines", 0)) > 0
func offer(h: Dictionary, now_s: float) -> Array[Dictionary]: var out := poll(now_s) if must_send(h): var one: Array[Dictionary] = [h] out.append_array(_take(one)) return out _held.append(h) _held.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a.frame_ms) > float(b.frame_ms)) if _held.size() > WORST_PER_WINDOW: _held.resize(WORST_PER_WINDOW) return out
func poll(now_s: float) -> Array[Dictionary]: if _window_start < 0.0: _window_start = now_s if now_s - _window_start < WINDOW_S: var none: Array[Dictionary] = [] return none _window_start = now_s return drain()
func drain() -> Array[Dictionary]: var held := _held _held = [] return _take(held)
func _take(hs: Array[Dictionary]) -> Array[Dictionary]: var room := maxi(MAX_PER_RUN - sent, 0) var out: Array[Dictionary] = [] for h in hs: if out.size() >= room: break out.append(h) sent += out.size() return out-
Step 4: Remove the old cap from
net/hitch_tracker.gd -
Delete the line
const MAX_PER_SESSION := 40 # hard cap on hitch rows one run can send. -
Delete the line
var sent: int = 0. -
Delete the whole
can_send()function at the end of the file. -
In the header comment, after
# Pure logic, no Node / HTTP: the Telemetry node feeds it and ships what it returns., add the line# Which hitches are sent is HitchSampler's job (telemetry v2).
In tests/test_hitch_tracker.gd, delete test_session_cap (the 4 lines starting func test_session_cap() -> void:).
- Step 5: Rewrite
net/telemetry.gd(the Task 3 file plus render times and the sampler)
class_name Telemetryextends Node
# Render-side perf reporter (NOT /sim). Every INTERVAL seconds pushes a "sample" event, and# HitchSampler-selected stalled frames as "hitch" events, onto the persistent EventQueue# (telemetry v2), which owns the network, the disk queue and retries. Created fresh per run by# main. Silent in editor/headless and never affects play.# proc_ms / render_ms are Performance.TIME_PROCESS: the WHOLE frame's process time, not our# script alone and not rendering. Real render cost is gpu_ms / render_cpu_ms (measured viewport# render time; null where the renderer can't measure, e.g. web compatibility).
const INTERVAL := 5.0const PIPELINE_MONITORS := [ Performance.PIPELINE_COMPILATIONS_CANVAS, Performance.PIPELINE_COMPILATIONS_MESH, Performance.PIPELINE_COMPILATIONS_SURFACE, Performance.PIPELINE_COMPILATIONS_DRAW, Performance.PIPELINE_COMPILATIONS_SPECIALIZATION,]
var low_fx: bool = false # set by main: true whenever adaptive quality has stepped below fullvar quality: int = 0 # current adaptive quality tier (0 = full, higher = more stripped)
var _sim: Simvar _queue: EventQueuevar _accum: float = 0.0var _enabled: bool = false
# Hitch capture (2026-09-26): every stalled frame, with what happened just before it.var hitches := HitchTracker.new()var sampler := HitchSampler.new() # telemetry v2: which of them are sentvar state_fn: Callable # set by main: returns "play" / "levelup" / "pause" / ...var _pipelines_last: int = -1var _last_state: String = ""
func setup(sim: Sim, queue: EventQueue) -> void: _sim = sim _queue = queue _enabled = queue != null and EventQueue.live_platform()
func _ready() -> void: if _enabled: # Idempotent; per-run node, so re-asserted each run. Costs a GPU timestamp query per frame. RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)
func _exit_tree() -> void: flush_held() # app quitting mid-run: main flushes explicitly before a session change
# Held (not-yet-sent) worst-of-window hitches go out now. main calls this before the run's# session id changes (menu / next run), so they still carry this run's session.func flush_held() -> void: for h in sampler.drain(): _push("hitch", h)
func _notification(what: int) -> void: if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT \ or what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN: hitches.skip_next()
# Main forwards each frame's drained render fx events, so a stall can be tied to the effect that# was first drawn just before it (first use of a shader is the usual suspect).func note_events(events: Array) -> void: if not _enabled: return for ev in events: var kind := String(ev.get("kind", "")) if kind == "reaction" and ev.has("name"): kind = "reaction:" + String(ev.name) hitches.note(kind)
func note(tag: String) -> void: if _enabled: hitches.note(tag)
static func pipelines_total() -> int: var n := 0 for m in PIPELINE_MONITORS: n += int(Performance.get_monitor(m)) return n
# Measured viewport render time (ms). 0 = not measured (unsupported renderer / not enabled) -> null.static func render_times(vp: RID) -> Dictionary: var gpu := RenderingServer.viewport_get_measured_render_time_gpu(vp) var cpu := RenderingServer.viewport_get_measured_render_time_cpu(vp) + RenderingServer.get_frame_setup_time_cpu() var out := {"gpu_ms": null, "render_cpu_ms": null} if gpu > 0.0: out["gpu_ms"] = gpu if cpu > 0.0: out["render_cpu_ms"] = cpu return out
func _push(t: String, d: Dictionary) -> void: if _queue != null: _queue.push(t, d)
func _track_frame() -> void: var f := hitches.frame(Time.get_ticks_usec()) var pipes := pipelines_total() var new_pipes := 0 if _pipelines_last < 0 else pipes - _pipelines_last _pipelines_last = pipes if new_pipes > 0: hitches.note("pipelines") var state := String(state_fn.call()) if state_fn.is_valid() else "" if state != _last_state: _last_state = state hitches.note("state:" + state) # a panel opening / boss arriving, right before a stall var now_s := float(Time.get_ticks_msec()) / 1000.0 var out: Array[Dictionary] = [] if bool(f.hitch): out = sampler.offer(_hitch_dict(float(f.ms), new_pipes, state), now_s) else: out = sampler.poll(now_s) for h in out: _push("hitch", h)
func _hitch_dict(frame_ms: float, new_pipes: int, state: String) -> Dictionary: return { "frame_ms": frame_ms, "proc_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS "phys_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0, "pipelines": new_pipes, "run_time": _sim.run_time, "enemies": _sim.enemies.count, "draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), "nodes": Performance.get_monitor(Performance.OBJECT_NODE_COUNT), "state": state, "recent": hitches.recent_tags(), "quality": quality, }
func _send_sample() -> void: var win := hitches.take_window() var fps := float(Engine.get_frames_per_second()) var d := { "config_id": RemoteConfig.active_id, "fps": fps, "frame_ms": 1000.0 / maxf(fps, 1.0), "sim_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0, "render_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS (legacy name) "draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), "prims": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME), "enemies": _sim.enemies.count, "zones": _sim.zones.size(), "webs": _sim.webs.size(), "run_time": _sim.run_time, "low_fx": low_fx, "quality": quality, "max_frame_ms": win.max_frame_ms, # worst single frame in this 5s window "hitches": win.hitches, # frames over HitchTracker.HITCH_MS in it } d.merge(render_times(get_viewport().get_viewport_rid()) if is_inside_tree() else {"gpu_ms": null, "render_cpu_ms": null}) _push("sample", d)
func _process(dt: float) -> void: if not _enabled or _sim == null: return _track_frame() _accum += dt if _accum < INTERVAL: return _accum = 0.0 _send_sample()- Step 6: Flush held hitches before the session changes (
main.gd)
In _return_to_menu, directly after the Task 4 line _send_run_end("quit_menu"), add:
if is_instance_valid(telemetry): telemetry.flush_held() # worst-of-window hitches still carry this run's sessionIn _new_run, directly after the Task 4 line _send_run_end("dev_restart"), add the same two lines.
(The death path needs nothing: the session only changes at the menu or the next _new_run, and both now flush first.)
- Step 7: Fix the dashboard and schema labels, and add the GPU column
In telemetry/src/worker.js, in the agg query, replace:
ROUND(AVG(sim_ms),2) avg_sim_ms, ROUND(AVG(render_ms),2) avg_render_ms,with:
ROUND(AVG(sim_ms),2) avg_sim_ms, ROUND(AVG(render_ms),2) avg_render_ms, ROUND(AVG(gpu_ms),2) avg_gpu_ms,In dash()’s row, replace:
r.avg_sim_ms, r.avg_render_ms, r.avg_draws, r.max_enemies, r.pct_below_40fps + "%",with:
r.avg_sim_ms, r.avg_render_ms, r.avg_gpu_ms ?? "", r.avg_draws, r.max_enemies, r.pct_below_40fps + "%",Replace the perf table header:
<table><tr><th>build<th>samples<th>sessions<th>avg fps<th>min fps<th>frame ms<th>sim ms<th>render ms<th>draws<th>max enemies<th><40fps<th>qual a/m</tr>with:
<table><tr><th>build<th>samples<th>sessions<th>avg fps<th>min fps<th>frame ms<th>sim ms<th>process ms<th>gpu ms<th>draws<th>max enemies<th><40fps<th>qual a/m</tr>In the intro paragraph, replace sim/render ms are CPU time per frame; with:
sim ms = physics step time; process ms (column formerly "render ms") = Performance.TIME_PROCESS, the whole frame's process time, NOT rendering; gpu ms = measured viewport GPU time (blank = pre-v2 build or a renderer that can't measure, e.g. web);In the hitch paragraph, replace proc = CPU script ms that frame (high = our code; low with a big frame = render/driver). with:
proc = Performance.TIME_PROCESS for that frame: the whole frame's process time, not our script alone, so it cannot split "our code" from "render/driver" (fixed 2026-09-29; use samples gpu ms for render cost).In telemetry/schema.sql, replace:
-- proc_ms high = script/CPU work in that frame; proc_ms low with a big frame_ms = render/driver.with:
-- proc_ms = Performance.TIME_PROCESS: the WHOLE frame's process time, not script-only (the old-- "high = our code" reading was wrong; corrected 2026-09-29). Render cost: samples.gpu_ms/render_cpu_ms.- Step 8: Run the tests to verify they pass
Run: godot --headless --path . --import; for t in test_hitch_sampler test_hitch_tracker test_telemetry_reporters_queue; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done; node --test telemetry/test/*.test.mjs
Expected: PASS everywhere. The Task 3 hitch-node test still passes: it calls reset_seen(), so its hitch carries new: and is must-send.
- Step 9: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): gpu_ms/render_cpu_ms on samples, hitch sampler, honest proc/render labels
Telemetry v2 Task 6. Hitches: first-use/pipeline stalls always sent, else worst 2 per 30 s,200/run cap (was the first 40). Samples gain measured viewport GPU/CPU render time (nullwhen unmeasurable). Dashboard/schema no longer claim proc_ms is "our code".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add net/hitch_sampler.gd net/hitch_tracker.gd net/telemetry.gd main.gd telemetry/src/worker.js telemetry/schema.sql tests/test_hitch_sampler.gd tests/test_hitch_tracker.gd tests/test_telemetry_reporters_queue.gdgit commit -F /tmp/bh-msg.txtTask 7: “Share diagnostics” toggle, install id rotation, and the privacy answers
Section titled “Task 7: “Share diagnostics” toggle, install id rotation, and the privacy answers”Files:
- Modify:
sim/meta_state.gd(theshare_diagnosticsfield plus to_dict/from_dict; data only, never read bytick) - Modify:
meta/meta_store.gd(ensure_install_idrespects the toggle; newset_share_diagnostics) - Modify:
net/remote_config.gd(staticfetch_url,set_install_id) - Modify:
ui/pause_menu.gd(the toggle button) - Modify:
main.gd(pass the state in, handle the toggle, passshareto setup/boot) - Modify:
tests/test_pause_menu.gd:44(button count 8/10 → 9/11) - Modify (full rewrite shown):
marketing/appstore/compliance.md - Test:
tests/test_meta_state.gd,tests/test_diagnostics_toggle.gd(new)
Interfaces:
-
Consumes:
EventQueue.set_enabled(on: bool, install_id: String)andEventQueue.setup(install_id, share)(Task 2).LifecycleTelemetry.boot(first_launch, share)(Task 4).EventQueueCore.pending_count()(Task 2).
-
Produces:
MetaState.share_diagnostics: bool(default true).MetaStore.set_share_diagnostics(m: MetaState, on: bool) -> void.RemoteConfig.fetch_url(build: int, channel: String, install: String) -> String(static),RemoteConfig.set_install_id(id: String) -> void.PauseMenu.share_diagnostics: bool(set before add_child),signal diagnostics_toggled(on: bool),static PauseMenu.diagnostics_label(on: bool) -> String,PauseMenu._diag_btn: Button.main._on_diagnostics_toggled(on: bool) -> void(Task 9 adds one line to it).
-
Behaviour:
- Off: the queue is wiped at once,
pushbecomes a no-op, the install id is cleared from the save, and/configis fetched withoutinstall=. - On again: a NEW install id is minted, so the old and new periods can’t be joined.
- The toggle survives restarts (it is in the save).
- A save this build could not read is never written: same
last_load_compatiblerule asensure_install_id.
- Off: the queue is wiped at once,
-
Determinism:
share_diagnosticsis never read bySim, so the baseline cannot move. The gate proves it. -
Step 1: Write the failing tests
Append to tests/test_meta_state.gd:
# ── Share diagnostics (telemetry v2, 2026-09-29) ──────────────────────────────────────────
func test_share_diagnostics_defaults_on_and_round_trips() -> void: var m := MetaState.new() assert_true(m.share_diagnostics) m.share_diagnostics = false var back := MetaState.new() back.from_dict(m.to_dict()) assert_false(back.share_diagnostics)
func test_a_save_without_the_key_means_on() -> void: var m := MetaState.new() var d := m.to_dict() d.erase("share_diagnostics") var back := MetaState.new() back.from_dict(d) assert_true(back.share_diagnostics)Create tests/test_diagnostics_toggle.gd:
extends GutTest
# "Share diagnostics" (telemetry v2 Task 7). MetaStore disk IO is off for the whole GUT run# (tests/gut_pre_run.gd), so set_share_diagnostics' save is counted but never touches meta.json.
func test_ensure_install_id_does_nothing_while_sharing_is_off() -> void: var m := MetaState.new() m.share_diagnostics = false MetaStore.ensure_install_id(m) assert_eq(m.install_id, "")
func test_turning_off_clears_the_id_and_on_mints_a_new_one() -> void: var m := MetaState.new() MetaStore.ensure_install_id(m) var first := m.install_id assert_eq(first.length(), 32) MetaStore.set_share_diagnostics(m, false) assert_false(m.share_diagnostics) assert_eq(m.install_id, "") MetaStore.set_share_diagnostics(m, true) assert_true(m.share_diagnostics) assert_eq(m.install_id.length(), 32) assert_ne(m.install_id, first, "rotated: the two periods can't be joined")
func test_fetch_url_leaves_install_out_when_empty() -> void: var with_id := RemoteConfig.fetch_url(207, "dev", "abc") assert_eq(with_id, RemoteConfig.ENDPOINT + "?build=207&channel=dev&install=abc") var without := RemoteConfig.fetch_url(207, "dev", "") assert_eq(without, RemoteConfig.ENDPOINT + "?build=207&channel=dev")
func test_pause_menu_toggle_flips_label_and_emits() -> void: var p := PauseMenu.new() p.share_diagnostics = true add_child_autofree(p) await get_tree().process_frame assert_eq(p._diag_btn.text, "Diagnostics: On") assert_true(p._buttons.has(p._diag_btn), "reachable by up/down nav") watch_signals(p) p._diag_btn.emit_signal("pressed") assert_signal_emitted_with_parameters(p, "diagnostics_toggled", [false]) assert_eq(p._diag_btn.text, "Diagnostics: Off")
func test_pause_menu_starts_from_the_saved_state() -> void: var p := PauseMenu.new() p.share_diagnostics = false add_child_autofree(p) await get_tree().process_frame assert_eq(p._diag_btn.text, "Diagnostics: Off")
func test_glass_feedback_and_diagnostics_share_one_row_that_fits() -> void: var was := VisualLook.glass_ui VisualLook.glass_ui = true var p := PauseMenu.new() add_child_autofree(p) await get_tree().process_frame var row := p._diag_btn.get_parent() assert_true(row is HBoxContainer, "paired with Send feedback so the column stays under 720p") assert_lte((row as Control).get_combined_minimum_size().x, 380.0, "labels don't widen the row") VisualLook.glass_ui = was
func test_main_toggle_off_wipes_the_queue_and_on_rotates_the_id() -> void: var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m.event_queue.enable_for_test() m.meta.share_diagnostics = true MetaStore.ensure_install_id(m.meta) var old_id: String = m.meta.install_id m.event_queue.push("menu", {}) assert_eq(m.event_queue.core.pending_count(), 1) m._on_diagnostics_toggled(false) assert_eq(m.event_queue.core.pending_count(), 0, "queued events never leave the device") assert_false(m.event_queue.is_enabled()) assert_eq(m.meta.install_id, "") assert_eq(m.event_queue.push("menu", {}), "", "push is a no-op while off") m._on_diagnostics_toggled(true) assert_true(m.event_queue.is_enabled()) assert_ne(m.meta.install_id, old_id) assert_eq(String(m.event_queue.core.ctx.install), m.meta.install_id) assert_eq(m.remote_config._install_id, m.meta.install_id)In tests/test_pause_menu.gd:44, change 8 if not BuildConfig.dev_tools() else 10 to 9 if not BuildConfig.dev_tools() else 11, and the message to "build label did not get counted into the nav button list (Diagnostics toggle adds one)".
- Step 2: Run the tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_diagnostics_toggle -gexit
Expected: FAIL (share_diagnostics is not a member of MetaState / PauseMenu).
- Step 3:
sim/meta_state.gd
After the var install_id: String = "" line (line 57), add:
var share_diagnostics: bool = true # pause-menu "Diagnostics" toggle (telemetry v2); off = no telemetry, no install idIn to_dict(), change "install_id": install_id, to "install_id": install_id, "share_diagnostics": share_diagnostics,.
In from_dict(), directly after install_id = String(d.get("install_id", "")), add:
share_diagnostics = bool(d.get("share_diagnostics", true)) # older saves: on (matches past behaviour)No SCHEMA_VERSION bump: the key is additive and defaults on.
- Step 4:
meta/meta_store.gd
Replace ensure_install_id with:
# Remote config (spec 2026-09-27 §3.3): give `m` a random 128-bit anonymous id if it has none,# persisting it only when the save on disk was absent or loaded cleanly (last load). A save this# build could not read keeps an in-memory id for the session and is never overwritten by boot.# Telemetry v2: no id at all while "Share diagnostics" is off.static func ensure_install_id(m: MetaState) -> void: if m.install_id != "" or not m.share_diagnostics: return m.install_id = Crypto.new().generate_random_bytes(16).hex_encode() if last_load_compatible: save_state(m)
# The pause-menu toggle. Off forgets the id; on mints a NEW one, so data from before and after an# off period can't be joined (spec: rotation on re-enable).static func set_share_diagnostics(m: MetaState, on: bool) -> void: m.share_diagnostics = on m.install_id = "" if on: m.install_id = Crypto.new().generate_random_bytes(16).hex_encode() if last_load_compatible: save_state(m)- Step 5:
net/remote_config.gd
In _fetch(), replace:
if _http.request("%s?build=%d&channel=%s&install=%s" % [ENDPOINT, _build, BuildChannel.current(), _install_id.uri_encode()]) != OK:with:
if _http.request(fetch_url(_build, BuildChannel.current(), _install_id)) != OK:After setup(), add:
# No install id (diagnostics off) = no install parameter at all, not an empty one.static func fetch_url(build: int, channel: String, install: String) -> String: var url := "%s?build=%d&channel=%s" % [ENDPOINT, build, channel] if install != "": url += "&install=" + install.uri_encode() return url
# The Diagnostics toggle: later refresh() calls use the rotated (or cleared) id.func set_install_id(id: String) -> void: _install_id = id- Step 6:
ui/pause_menu.gd
After signal feedback_submitted(kind: String, note: String), add:
signal diagnostics_toggled(on: bool) # the "Diagnostics" toggle (telemetry v2); main persists itAfter var hidden_entries: Array[String] = [] ..., add:
var share_diagnostics := true # set before add_child: the saved state the toggle starts fromvar _diag_btn: ButtonReplace:
if not hidden_entries.has("feedback"): box.add_child(_make_btn("Send feedback", func() -> void: _open_feedback(), Color(0.4, 0.95, 0.85)))with the block below. _make_btn appends to _buttons when it is called, and _diag_btn is created first (both branches need it), so it is taken out of _buttons and re-appended at the end. That keeps up/down nav in the visual order: Send feedback, then Diagnostics.
_diag_btn = _make_btn(diagnostics_label(share_diagnostics), func() -> void: _toggle_diagnostics(), Color(0.55, 0.62, 0.75)) _buttons.erase(_diag_btn) # re-appended below, after Send feedback, so nav follows the layout if not hidden_entries.has("feedback"): var fb := _make_btn("Send feedback", func() -> void: _open_feedback(), Color(0.4, 0.95, 0.85)) if _glass: # Paired on one row (telemetry v2): a full-width Diagnostics row would push the column past # 660 px with dev rows (test_glass_column_with_dev_rows_fits_720p). Nav order: fb, then diag. var pair := HBoxContainer.new() pair.alignment = BoxContainer.ALIGNMENT_CENTER pair.add_theme_constant_override("separation", GLASS_SEPARATION) for b: Button in [fb, _diag_btn]: b.custom_minimum_size = Vector2((380.0 - GLASS_SEPARATION) / 2.0, GLASS_BTN_H) b.add_theme_font_size_override("font_size", GLASS_DEV_BTN_FONT) pair.add_child(b) box.add_child(pair) else: box.add_child(fb) box.add_child(_diag_btn) else: box.add_child(_diag_btn) _buttons.append(_diag_btn)(_make_btn’s pressed/hover lambdas look the button up with _buttons.find(b) at press time, so moving it in the array is safe.)
Add, next to _open_feedback:
static func diagnostics_label(on: bool) -> String: return "Diagnostics: On" if on else "Diagnostics: Off"
func _toggle_diagnostics() -> void: share_diagnostics = not share_diagnostics _diag_btn.text = diagnostics_label(share_diagnostics) diagnostics_toggled.emit(share_diagnostics)If test_glass_feedback_and_diagnostics_share_one_row_that_fits fails on width (the 18 px title font being wider than 187 px for “Diagnostics: Off”), shorten the labels to "Data: On" / "Data: Off" in diagnostics_label and the tests. Do not shrink the font below 18 (TV legibility). After the change, capture the pause screen with the bh-visual-qa agent at iPhone, iPad and ATV sizes.
- Step 7:
main.gd
(a) In _toggle_pause, directly after pause_menu.hidden_entries = tutorial_pause_hidden(_mode, sim) ..., add:
pause_menu.share_diagnostics = meta.share_diagnostics # before add_child (builds the toggle)and after the pause_menu.feedback_submitted.connect(...) statement, add:
pause_menu.diagnostics_toggled.connect(_on_diagnostics_toggled)(b) In _ready, change the Task 3 line event_queue.setup(meta.install_id, true) to:
event_queue.setup(meta.install_id, meta.share_diagnostics)and the Task 4 line lifecycle.boot(first_launch, true) to:
lifecycle.boot(first_launch, meta.share_diagnostics)(first_launch reads install_id == "", which is also true for a player who switched diagnostics off. That’s harmless: with sharing off, the launch event is never sent.)
(c) Add the handler next to _lifecycle_info:
# Pause-menu "Diagnostics" toggle (telemetry v2). Off: forget the install id, wipe anything queued,# stop pushing, and fetch remote config anonymously. On: a fresh id everywhere.func _on_diagnostics_toggled(on: bool) -> void: MetaStore.set_share_diagnostics(meta, on) event_queue.set_enabled(on, meta.install_id) remote_config.set_install_id(meta.install_id)- Step 8: Rewrite
marketing/appstore/compliance.md
# Dark Cosmos — ASC compliance answers
Updated 2026-09-29 for telemetry v2 (spec docs/superpowers/specs/2026-09-29-telemetry-v2-design.md).These answers must be live in App Store Connect BEFORE the first build carrying telemetry v2 issubmitted (the label is per app, not per build).
## App Privacy (Data Collection)Does the app collect data? YES. Anonymous diagnostics, on by default, with an in-game opt-out(pause menu > "Diagnostics: On/Off"). Turning it off stops all sending, deletes anything queued onthe device, and discards the install id (a new one is made if it is turned back on).
Data types:- Identifiers > Device ID: a random install id (128-bit, generated on the device, stored in the save file, never derived from hardware or IDFA/IDFV; rotated whenever Diagnostics is turned back on).- Diagnostics > Crash Data: whether the previous launch ended abnormally (crash / killed in background) with the last game state; on iOS, Apple MetricKit crash and hang reports; on iOS only if enabled, crash reports sent to Sentry (processor).- Diagnostics > Performance Data: frame rate, frame/physics/process/GPU timings, draw calls, primitive counts, frame hitches and what preceded them, adaptive quality tier, app build number, coarse device model (e.g. "iOS/iPhone15,2").- Usage Data > Product Interaction: app launch/background/foreground, menu visits, tutorial steps, shop purchases (item + price), run start/end with reason, level-ups offered and picked, XP by enemy type, boss spawn/kill times, damage by weapon, plus the existing per-run summary (run length, level, kills, gold, damage dealt/taken, weapons, upgrade picks, death cause, etc.).- User Content > Other User Content: the optional "Send feedback" note the player types on the pause menu, with its build/device/run context.
For ALL of the above: Linked to the user's identity? NO (no account, name, email or advertising id;the install id is random and not tied to any identity). Used for tracking? NO. Purposes: AppFunctionality + Analytics.
Processors: Cloudflare (Workers + D1, stores all of the above); Sentry (iOS crash reports only, andonly once it is switched on; not enabled at the time of writing).
## Age Rating- Cartoon or Fantasy Violence: INFREQUENT/MILD (abstract neon shapes, no blood/gore)- Realistic Violence / sexual / profanity / drugs / gambling / horror: NONE- Unrestricted web access: NOExpected: 9+.
## Export complianceUses non-exempt encryption? NO (ITSAppUsesNonExemptEncryption = false).
## URLsSupport URL / Marketing URL: https://darkcosmos.lumara.digitalPrivacy Policy URL: https://darkcosmos.lumara.digital/privacy/ (must be updated to match this filebefore submission; draft in docs/superpowers/plans/2026-09-29-telemetry-v2.md, Chris-gated section)Keep the Sentry wording even while Sentry stays dormant (Task 9). Declaring a processor that is off is safe; switching it on without the declaration is not. [Likely]: Apple’s label covers data “collected”, so “Crash Data” is required as soon as MetricKit reports leave the device.
- Step 9: Run the tests to verify they pass
Run: godot --headless --path . --import; for t in test_diagnostics_toggle test_meta_state test_pause_menu test_remote_config_wiring test_remote_config_refresh; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done
Expected: PASS, including test_glass_column_with_dev_rows_fits_720p (the diagnostics button shares the feedback row, so the column height is unchanged).
- Step 10: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS (the baseline is unchanged: Sim never reads share_diagnostics).
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): Share diagnostics toggle, install id rotation, privacy answers
Telemetry v2 Task 7. Pause menu gains "Diagnostics: On/Off" (paired with Send feedback on theglass look). Off wipes the event queue, stops pushing, clears the install id and drops it from/config; on mints a fresh id. compliance.md now declares Device ID, Crash Data, feedback text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add sim/meta_state.gd meta/meta_store.gd net/remote_config.gd ui/pause_menu.gd main.gd marketing/appstore/compliance.md tests/test_meta_state.gd tests/test_diagnostics_toggle.gd tests/test_pause_menu.gdgit commit -F /tmp/bh-msg.txtTask 8: MetricKit on iOS (native sink, re-apply script, Godot pickup)
Section titled “Task 8: MetricKit on iOS (native sink, re-apply script, Godot pickup)”Files:
- Create:
platform/tvos/patches/ios/metrickit-sink.mm - Create:
scripts/release/apply-ios-metrickit.sh - Create:
scripts/test/apply-ios-metrickit.test.mjs - Create:
net/metrickit_pickup.gd - Modify:
net/lifecycle_telemetry.gd(pick up at boot and on foreground) - Modify:
scripts/release/build-ios.sh(apply after the export) - Modify:
scripts/dev/install-dev.sh(apply in the iOS branch) - Modify:
platform/tvos/patches/README.md(one row, one paragraph) - Local only (gitignored, never
git add -f):.claude/skills/bh-appstore-release/SKILL.md,.claude/skills/bh-deploy/SKILL.md - Test:
tests/test_metrickit_pickup.gd
Interfaces:
-
Consumes:
EventQueue.push,EventQueue.is_enabled()(Task 2);LifecycleTelemetry._live,_queue,boot()andon_foreground()(Task 4). -
Produces:
MetricKitPickup.PREFIX = "mk-",MetricKitPickup.MAX_JSON = 200000.static MetricKitPickup.files_in(dir: String) -> PackedStringArray.static MetricKitPickup.kind_of(file: String) -> String.static MetricKitPickup.payload_for(file: String, text: String) -> Dictionary.static MetricKitPickup.pick_up(dir: String, queue: EventQueue) -> int(returns the number sent).- Event
metrickit,d = {kind, file, bytes, raw}, whererawis the payload’s JSON text. It is sent as a STRING, never re-parsed: Godot’s JSON parser turns every number into a float, which corrupts the 64-bit load addresses in MetricKit call-stack trees.truncated: truereplacesrawaboveMAX_JSON.
-
Why the sink is APPENDED, not a replacement file:
- The iOS export writes
build-ios/BulletHeaven/dummy.cppcontaining Godot’s owngodot_apple_embedded_plugins_initialize/deinitializestubs (verified in the current shell). - The script renames it to
dummy.mm, types itsourcecode.cpp.objcppin the pbxproj (the tvOS recipe frombuild-tvos.sh), and appends the sink. The stubs survive, whatever a future export generates.
- The iOS export writes
-
Why an explicit link directive:
- The file is ObjC++. Clang module autolinking for
#import <MetricKit/MetricKit.h>is not reliable in ObjC++ without-fcxx-modules[Likely]. - So the sink carries
__asm__(".linker_option \"-framework\", \"MetricKit\""). Step 7 verifies the link withotool -L. - Fallback if the directive is rejected: add
-framework MetricKitto bothOTHER_LDFLAGSlines with sed in the apply script. - AS BUILT (review fix a092a21): MetricKit is weak-linked, and
ldignores-weak_frameworkin an object-file.linker_option. So the__asm__line is gone: the apply script appends-weak_framework MetricKitto bothOTHER_LDFLAGSlines, and the sink returns early ifNSClassFromString(@"MXMetricManager")is nil. Step 7’sotool -Lshould show MetricKit as(weak). Dedupe keys on the payload’s SHA-256, not the time window. The pickup hangs offEventQueueviaMetricKitPickup.attach()(called inmain._readyafterlifecycle.boot), notLifecycleTelemetry. It skips its scan in perf_bench/auto_showcase dev sessions.
- The file is ObjC++. Clang module autolinking for
-
Payload files live at the Caches root (deviation 4). The Godot side reads
OS.get_cache_dir(). It is [Likely] the sameNSCachesDirectoryon iOS. The on-device check is in the Chris-gated section, step G6. -
Privacy: the sink always writes, since native code can’t see the toggle. The pickup deletes every file and only sends when
queue.is_enabled(). With diagnostics off nothing leaves the device, and nothing piles up either. -
Timing: MetricKit delivers roughly once a day, on the launch after the report window closes. Diagnostics (crash/hang) arrive on the next launch after the event. Pickup runs at boot and on every foreground.
-
Step 1: Write the failing tests
Create scripts/test/apply-ios-metrickit.test.mjs:
import { test } from "node:test";import assert from "node:assert/strict";import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";import { execFileSync } from "node:child_process";import { tmpdir } from "node:os";import { join, dirname } from "node:path";import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));const SCRIPT = join(here, "..", "release", "apply-ios-metrickit.sh");
// The four dummy.cpp lines of a Godot 4.7 iOS pbxproj (verified against platform/tvos/build-ios).const PBX = [ "\t\t1FF8DBB11FBA9DE1009DE660 /* dummy.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */; };", "\t\t1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = dummy.cpp; sourceTree = \"<group>\"; };", "\t\t\t\t1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */,", "\t\t\t\t1FF8DBB11FBA9DE1009DE660 /* dummy.cpp in Sources */,",].join("\n") + "\n";const STUB = "void godot_apple_embedded_plugins_initialize() {\n}\n\nvoid godot_apple_embedded_plugins_deinitialize() {\n}\n";
function shell() { const dir = mkdtempSync(join(tmpdir(), "bh-mk-")); mkdirSync(join(dir, "BulletHeaven")); mkdirSync(join(dir, "BulletHeaven.xcodeproj")); writeFileSync(join(dir, "BulletHeaven", "dummy.cpp"), STUB); writeFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), PBX); return dir;}const run = (dir) => execFileSync("bash", [SCRIPT, dir], { encoding: "utf8" });const count = (s, needle) => s.split(needle).length - 1;
test("renames dummy.cpp to dummy.mm, keeps the plugin stubs, appends the sink once", () => { const dir = shell(); run(dir); const mm = readFileSync(join(dir, "BulletHeaven", "dummy.mm"), "utf8"); assert.ok(!existsSync(join(dir, "BulletHeaven", "dummy.cpp"))); assert.ok(mm.includes("godot_apple_embedded_plugins_initialize"), "export's stubs survive"); assert.equal(count(mm, "BH_METRICKIT_SINK"), 1);});
test("retypes the file reference as objcpp and leaves no dummy.cpp behind", () => { const dir = shell(); run(dir); const pb = readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8"); assert.equal(count(pb, "dummy.cpp"), 0); assert.match(pb, /1FF8DBB01FBA9DE1009DE660 \/\* dummy\.mm \*\/ = \{isa = PBXFileReference; lastKnownFileType = sourcecode\.cpp\.objcpp; path = dummy\.mm;/);});
test("is idempotent", () => { const dir = shell(); run(dir); run(dir); const mm = readFileSync(join(dir, "BulletHeaven", "dummy.mm"), "utf8"); assert.equal(count(mm, "BH_METRICKIT_SINK"), 1);});
test("fails loudly when the shell has no dummy file", () => { const dir = shell(); execFileSync("rm", [join(dir, "BulletHeaven", "dummy.cpp")]); assert.throws(() => run(dir));});Create tests/test_metrickit_pickup.gd:
extends GutTest
# MetricKitPickup (telemetry v2 Task 8): payload files from the iOS MetricKit sink become# "metrickit" events and are always deleted, sent or not.
const DIR := "user://mk_test"
func before_each() -> void: DirAccess.make_dir_recursive_absolute(DIR) for f in DirAccess.get_files_at(DIR): DirAccess.remove_absolute(DIR.path_join(f))
func after_each() -> void: for f in DirAccess.get_files_at(DIR): DirAccess.remove_absolute(DIR.path_join(f)) DirAccess.remove_absolute(DIR)
func _drop(name: String, text: String) -> void: var f := FileAccess.open(DIR.path_join(name), FileAccess.WRITE) f.store_string(text) f.close()
func _queue() -> EventQueue: var q := EventQueue.new() add_child_autofree(q) q.setup("inst", true) q.enable_for_test() return q
func test_only_mk_json_files_are_picked_up() -> void: _drop("mk-diagnostic-1-2.json", "{}") _drop("telemetry_queue.txt", "x") _drop("mk-notes.txt", "x") assert_eq(MetricKitPickup.files_in(DIR), PackedStringArray(["mk-diagnostic-1-2.json"]))
func test_kind_comes_from_the_name() -> void: assert_eq(MetricKitPickup.kind_of("mk-diagnostic-1727600000000-42.json"), "diagnostic") assert_eq(MetricKitPickup.kind_of("mk-metric-1727600000000-42.json"), "metric") assert_eq(MetricKitPickup.kind_of("mk-.json"), "unknown")
func test_raw_json_travels_as_text_so_big_addresses_survive() -> void: var text := "{\"address\":18446744073709551615}" var d := MetricKitPickup.payload_for("mk-diagnostic-1-2.json", text) assert_eq(d.raw, text, "never re-parsed: Godot would round the address to a float") assert_eq(d.kind, "diagnostic") assert_eq(int(d.bytes), text.length())
func test_oversize_payloads_are_stubbed_not_cut() -> void: var d := MetricKitPickup.payload_for("mk-metric-1-2.json", "x".repeat(MetricKitPickup.MAX_JSON + 1)) assert_true(bool(d.truncated)) assert_false(d.has("raw"))
func test_enabled_queue_gets_one_event_per_file_and_files_go() -> void: var q := _queue() _drop("mk-diagnostic-1-2.json", "{\"a\":1}") _drop("mk-metric-3-4.json", "{\"b\":2}") assert_eq(MetricKitPickup.pick_up(DIR, q), 2) var evs := q.core.pending_events() assert_eq(evs.size(), 2) assert_eq(String(evs[0].t), "metrickit") assert_eq(MetricKitPickup.files_in(DIR).size(), 0)
func test_diagnostics_off_deletes_without_sending() -> void: var q := _queue() q.set_enabled(false, "") _drop("mk-diagnostic-1-2.json", "{}") assert_eq(MetricKitPickup.pick_up(DIR, q), 0) assert_eq(q.core.pending_count(), 0) assert_eq(MetricKitPickup.files_in(DIR).size(), 0, "nothing piles up while off")- Step 2: Run them to verify they fail
Run: node --test scripts/test/apply-ios-metrickit.test.mjs; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_metrickit_pickup -gexit
Expected: both FAIL (script missing; MetricKitPickup not declared).
- Step 3: Create
platform/tvos/patches/ios/metrickit-sink.mm
// ===== BH_METRICKIT_SINK (telemetry v2, docs/superpowers/plans/2026-09-29-telemetry-v2.md Task 8) =====// Appended by scripts/release/apply-ios-metrickit.sh to the Godot-generated dummy.cpp (renamed// dummy.mm, typed objcpp); the export's plugin stubs above stay untouched. iOS only: MetricKit// does not exist on tvOS. Each payload is written as <Caches>/mk-<kind>-<ms>-<rand>.json and// net/metrickit_pickup.gd sends + deletes it. Nothing here touches Godot.#import <TargetConditionals.h>#if TARGET_OS_IOS#import <Foundation/Foundation.h>#import <MetricKit/MetricKit.h>
// ObjC++ does not reliably autolink frameworks from #import; ask the linker directly.__asm__(".linker_option \"-framework\", \"MetricKit\"");
@interface BHMetricKitSink : NSObject <MXMetricManagerSubscriber>@end
@implementation BHMetricKitSink
// pastPayloads can hand back reports didReceive already delivered on an earlier launch; remember// the last 64 by kind + report window so each one is written once.+ (BOOL)firstTime:(NSString *)key { NSUserDefaults *u = [NSUserDefaults standardUserDefaults]; NSMutableArray *seen = [[u arrayForKey:@"bh_mk_seen"] mutableCopy] ?: [NSMutableArray array]; if ([seen containsObject:key]) return NO; [seen addObject:key]; while (seen.count > 64) [seen removeObjectAtIndex:0]; [u setObject:seen forKey:@"bh_mk_seen"]; return YES;}
+ (void)take:(NSData *)json kind:(NSString *)kind begin:(NSDate *)b end:(NSDate *)e { if (json.length == 0) return; NSString *key = [NSString stringWithFormat:@"%@:%.0f:%.0f", kind, b.timeIntervalSince1970, e.timeIntervalSince1970]; if (![self firstTime:key]) return; NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; long long ms = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); NSString *name = [NSString stringWithFormat:@"mk-%@-%lld-%u.json", kind, ms, arc4random()]; [json writeToFile:[dir stringByAppendingPathComponent:name] atomically:YES];}
+ (void)takeMetrics:(NSArray<MXMetricPayload *> *)payloads { for (MXMetricPayload *p in payloads) [self take:[p JSONRepresentation] kind:@"metric" begin:p.timeStampBegin end:p.timeStampEnd];}
+ (void)takeDiagnostics:(NSArray<MXDiagnosticPayload *> *)payloads { for (MXDiagnosticPayload *p in payloads) [self take:[p JSONRepresentation] kind:@"diagnostic" begin:p.timeStampBegin end:p.timeStampEnd];}
- (void)didReceiveMetricPayloads:(NSArray<MXMetricPayload *> *)payloads { [BHMetricKitSink takeMetrics:payloads];}
- (void)didReceiveDiagnosticPayloads:(NSArray<MXDiagnosticPayload *> *)payloads { [BHMetricKitSink takeDiagnostics:payloads];}
@end
static BHMetricKitSink *bh_mk_sink = nil;
// Runs before main(); the block runs once UIApplicationMain starts the main run loop.// Deployment target is iOS 15 (export_presets.cfg), so every API here is available unguarded.__attribute__((constructor)) static void bh_metrickit_start(void) { dispatch_async(dispatch_get_main_queue(), ^{ bh_mk_sink = [BHMetricKitSink new]; MXMetricManager *m = [MXMetricManager sharedManager]; [m addSubscriber:bh_mk_sink]; [BHMetricKitSink takeMetrics:m.pastPayloads]; [BHMetricKitSink takeDiagnostics:m.pastDiagnosticPayloads]; });}#endif// ===== end BH_METRICKIT_SINK =====- Step 4: Create
scripts/release/apply-ios-metrickit.sh(thenchmod +x)
#!/usr/bin/env bash# Telemetry v2 Task 8: re-apply the iOS MetricKit sink to a freshly exported iOS Xcode shell.# A full --export-release regenerates build-ios/ (dummy.cpp included), so build-ios.sh and# install-dev.sh (iOS) run this every time. Idempotent.# Usage: scripts/release/apply-ios-metrickit.sh [shell dir] (default: platform/tvos/build-ios)set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd)SHELL_DIR=${1:-$ROOT/platform/tvos/build-ios}SRC=$ROOT/platform/tvos/patches/ios/metrickit-sink.mmD=$SHELL_DIR/BulletHeavenPB=$SHELL_DIR/BulletHeaven.xcodeproj/project.pbxprojG=/usr/bin/grep[ -f "$D/dummy.cpp" ] && mv "$D/dummy.cpp" "$D/dummy.mm"[ -f "$D/dummy.mm" ] || { echo "apply-ios-metrickit: no dummy.cpp or dummy.mm in $D"; exit 1; }$G -q BH_METRICKIT_SINK "$D/dummy.mm" || cat "$SRC" >> "$D/dummy.mm"sed -i '' 's/dummy\.cpp/dummy.mm/g' "$PB"UUID=$($G -o '[0-9A-F]\{24\} /\* dummy.mm \*/ = {isa = PBXFileReference' "$PB" | cut -c1-24)[ -n "$UUID" ] || { echo "apply-ios-metrickit: no dummy.mm file reference in $PB"; exit 1; }sed -i '' "/$UUID .*PBXFileReference/s/sourcecode\.cpp\.cpp/sourcecode.cpp.objcpp/" "$PB"[ "$($G -c dummy.cpp "$PB" || true)" = 0 ] || { echo "apply-ios-metrickit: dangling dummy.cpp reference"; exit 1; }$G "$UUID .*PBXFileReference" "$PB" | $G -q objcpp || { echo "apply-ios-metrickit: dummy.mm not typed objcpp"; exit 1; }$G -q godot_apple_embedded_plugins_initialize "$D/dummy.mm" || { echo "apply-ios-metrickit: plugin stubs lost"; exit 1; }echo "apply-ios-metrickit: ok ($D/dummy.mm)"Run: chmod +x scripts/release/apply-ios-metrickit.sh
- Step 5: Create
net/metrickit_pickup.gd
class_name MetricKitPickupextends RefCounted
# Telemetry v2 Task 8 (iOS only). The native sink (platform/tvos/patches/ios/metrickit-sink.mm)# writes each MetricKit payload as <Caches>/mk-<kind>-<ms>-<rand>.json. Each file becomes one# "metrickit" event and is then deleted, sent or not: with diagnostics off nothing is sent and# nothing piles up. The JSON goes as text (`raw`), never re-parsed, because Godot's parser turns# every number into a float and would corrupt the 64-bit addresses in call-stack trees.
const PREFIX := "mk-"const MAX_JSON := 200000 # escaped, it still fits EventQueueCore.BATCH_MAX_BYTES (400000)
static func files_in(dir: String) -> PackedStringArray: var out := PackedStringArray() for f in DirAccess.get_files_at(dir): if f.begins_with(PREFIX) and f.ends_with(".json"): out.append(f) out.sort() return out
# "mk-diagnostic-1727600000000-42.json" -> "diagnostic"static func kind_of(file: String) -> String: var parts := file.get_basename().split("-") if parts.size() >= 3 and parts[1] != "": return parts[1] return "unknown"
static func payload_for(file: String, text: String) -> Dictionary: var d := {"kind": kind_of(file), "file": file, "bytes": text.length()} if text.length() > MAX_JSON: d["truncated"] = true else: d["raw"] = text return d
static func pick_up(dir: String, queue: EventQueue) -> int: var sent := 0 for f in files_in(dir): var path := dir.path_join(f) if queue != null and queue.is_enabled(): queue.push("metrickit", payload_for(f, FileAccess.get_file_as_string(path))) sent += 1 DirAccess.remove_absolute(path) return sent(kind_of("mk-.json"): get_basename() gives "mk-", which splits to ["mk", ""]. That has size 2, so the result is "unknown".)
- Step 6: Hook it into
net/lifecycle_telemetry.gd,build-ios.sh,install-dev.sh
In net/lifecycle_telemetry.gd, add:
# iOS MetricKit reports (Task 8): the native sink drops files in the cache dir; send + delete them.func pick_up_metrickit() -> void: if _live and OS.get_name() == "iOS": MetricKitPickup.pick_up(OS.get_cache_dir(), _queue)and call pick_up_metrickit() as the last line of boot() and as the last line of on_foreground().
In scripts/release/build-ios.sh, directly after the line grep -iE "^ERROR|SCRIPT ERROR" /tmp/bh-ios-export.log | head -5 || true, add:
bash "$TVOS/../../scripts/release/apply-ios-metrickit.sh" "$TVOS/build-ios" # telemetry v2: MetricKit sinkIn scripts/dev/install-dev.sh, in the iOS branch, directly after the bash "$ROOT/scripts/dev/make-dev-icon.sh" ... line, add:
bash "$ROOT/scripts/release/apply-ios-metrickit.sh" "$ROOT/platform/tvos/build-ios" # idempotent; export-pack keeps the shell, a past full export may not have had it- Step 7: Run the tests, then prove it compiles and links
Run: node --test scripts/test/*.test.mjs; godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_metrickit_pickup -gexit
Expected: PASS.
Compile check against the real shell. This is a local build only: no signing, no upload, no device.
cd /Users/chris/Claude/bullet-heaven/platform/tvoscp -R build-ios /private/tmp/bh-ios-verify-shell # never touch the signed shell's statebash ../../scripts/release/apply-ios-metrickit.sh /private/tmp/bh-ios-verify-shellcd /private/tmp/bh-ios-verify-shellxcodebuild -scheme BulletHeaven -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \ -derivedDataPath /private/tmp/bh-ios-verify CODE_SIGNING_ALLOWED=NO build > /private/tmp/bh-ios-verify.log 2>&1 \ || { tail -30 /private/tmp/bh-ios-verify.log; exit 1; }APP=/private/tmp/bh-ios-verify/Build/Products/Release-iphoneos/BulletHeaven.app/BulletHeavenotool -L "$APP" | /usr/bin/grep MetricKitnm "$APP" | /usr/bin/grep -c BHMetricKitSinkExpected:
** BUILD SUCCEEDED **in the log.otool -Lprints/System/Library/Frameworks/MetricKit.framework/MetricKit.- The
nmcount is ≥ 1.
If the link line is missing, add -framework MetricKit to OTHER_LDFLAGS in the apply script with sed -i '' 's/OTHER_LDFLAGS = "\$(LD_CLASSIC_\$(XCODE_VERSION_ACTUAL)) ";/OTHER_LDFLAGS = "$(LD_CLASSIC_$(XCODE_VERSION_ACTUAL)) -framework MetricKit";/' "$PB", then re-run. Afterwards: rm -rf /private/tmp/bh-ios-verify-shell /private/tmp/bh-ios-verify.
- Step 8: Docs (patches README, local skills)
Add this row to the table in platform/tvos/patches/README.md:
| `ios/metrickit-sink.mm` | APPENDED to `../build-ios/BulletHeaven/dummy.cpp` (renamed `dummy.mm`, typed objcpp) | iOS MetricKit subscriber (telemetry v2): writes crash/hang/metric payloads to `<Caches>/mk-*.json` for `net/metrickit_pickup.gd`. Applied by `scripts/release/apply-ios-metrickit.sh` (run by `build-ios.sh` and `install-dev.sh`), never by hand-copy: the export's own plugin stubs in `dummy.cpp` must survive. |and under the table add:
The iOS sink is the one patch that is appended, not copied, so re-apply it with the script (idempotent), not `cp`.Local skill edits (both files are gitignored; edit in place, do not commit or force-add):
-
In
.claude/skills/bh-appstore-release/SKILL.md, in the iOS section after the export step, add the line:iOS: build-ios.sh now runs scripts/release/apply-ios-metrickit.sh after the export (MetricKit sink). If you export by hand, run it yourself before archiving; the archive must show MetricKit in otool -L. -
In
.claude/skills/bh-deploy/SKILL.md, in the iOS device deploy steps, add:iOS shell: scripts/release/apply-ios-metrickit.sh platform/tvos/build-ios (idempotent) before xcodebuild, so dev builds carry the MetricKit sink. -
Step 9: Gate and commit
Run: bash scripts/gate.sh → == GATE PASS.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): iOS MetricKit sink + pickup
Telemetry v2 Task 8. A native subscriber appended to the iOS export's dummy.cpp (as dummy.mm)writes MetricKit metric/diagnostic payloads to Caches; net/metrickit_pickup.gd sends each as a"metrickit" event (raw JSON text) and deletes it, or just deletes it with diagnostics off.build-ios.sh and install-dev.sh re-apply it via scripts/release/apply-ios-metrickit.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add platform/tvos/patches/ios/metrickit-sink.mm platform/tvos/patches/README.md scripts/release/apply-ios-metrickit.sh scripts/release/build-ios.sh scripts/dev/install-dev.sh scripts/test/apply-ios-metrickit.test.mjs net/metrickit_pickup.gd net/lifecycle_telemetry.gd tests/test_metrickit_pickup.gdgit commit -F /tmp/bh-msg.txtTask 9: Sentry on iOS, dormant until a DSN exists (with an abort path)
Section titled “Task 9: Sentry on iOS, dormant until a DSN exists (with an abort path)”Files:
- Create:
scripts/release/fetch-sentry.sh - Create:
net/sentry_boot.gd - Create:
tools/sentry_probe/probe.gd(a one-shot check; kept for the next version bump) - Create:
tests/test_sentry_boot.gd - Replace: the
platform/tvos/addonssymlink with a real directory holding agutsymlink (thesentry/inside it is gitignored) - Modify:
.gitignore,platform/tvos/project.godot([sentry]section),platform/tvos/export_presets.cfg(tvOS + tvOS-Devexclude_filter) - Modify:
tests/test_release_presets.gd,scripts/release/build-ios.sh,scripts/dev/install-dev.sh,main.gd
Interfaces:
- Consumes:
main._on_diagnostics_toggled(Task 7),EventQueue.live_platform()(Task 2),BuildChannel.current(). - Produces:
SentryBoot.DSN_FILE = "res://sentry_dsn.json".static SentryBoot.should_init(os_name: String, dsn: String, share: bool) -> bool.static SentryBoot.read_dsn(path: String = DSN_FILE) -> String.static SentryBoot.apply(share: bool, install_id: String) -> void.static var SentryBoot.initialised: bool.
- Facts verified 2026-09-29 against the release zip (
gh release download 2.2.0 -R getsentry/sentry-godot):- Asset:
sentry-godot-2.2.0+407729a.zip, 39,497,142 bytes, sha256539cca58ff4188dafcd1318e03396fa303ea1ae5b2eda6f96a055146af44b156. sentry.gdextensiondeclarescompatibility_minimum = "4.5"and has libraries for macos, windows, linux, android, ios (libsentry.ios.{debug,release}.xcframeworkplus aSentry.xcframeworkdependency) and web. There is no tvOS entry.- iOS + macOS binaries total 44.8 MB.
- Project settings in the binary include
sentry/options/auto_init,sentry/options/dsn,sentry/options/release,sentry/options/environment,sentry/options/send_default_pii. - Method-name strings include
init,close,set_tag,is_enabled.
- Asset:
- Not verified, so Step 4’s probe checks it before any wiring relies on it [Likely]:
SentrySDKis an Engine singleton.inittakes aCallable(options: SentryOptions).SentryOptionsexposesdsn/release/environment/send_default_piias properties.
- Vendoring decision (deviation from the spec’s “commit under
addons/sentry/and symlink it in”, see the handback notes):- The addon lives in
platform/tvos/addons/sentry/, gitignored.scripts/release/fetch-sentry.shfills it from the sha256-pinned release zip, keeping onlysentry.gdextensionplusbin/iosandbin/macos. - Why not commit it: 45 MB of binaries in git history for a dormant feature.
- Why not root
addons/: the root project runs the gate and the web export (see deviation 6). The root project never scansplatform/tvos/(it holds its ownproject.godot; verified: nores://platformentry in.godot/global_script_class_cache.cfg). - A clone without the fetch simply has no Sentry, so
SentryBootstays dormant.build-ios.shandinstall-dev.sh(iOS) fetch before exporting.
- The addon lives in
- Dormant means: no
sentry_dsn.json, soinitis never called, no signal handlers are installed and nothing is sent. The binary still ships inside iOS builds.
Abort criteria. If any of these trips: git revert this task’s commit, run rm -rf platform/tvos/addons/sentry (and delete the res://addons/sentry/sentry.gdextension line from platform/tvos/.godot/extension_list.cfg, or the editor logs “Error loading GDExtension” on every export until it rescans), and record “Sentry parked: <which check, what output>” in the Task 11 docs. Don’t force it (spec: “parked rather than forced”). The criteria:
- The Step 4 probe does not find the
SentrySDKsingleton or its methods. - The iOS no-signing
xcodebuildin Step 8 fails. - The tvOS-Dev pack contains
addons/sentry/bin, or the tvOS export log shows a GDExtension error. - The Dev app boot on the ATV logs a GDExtension/sentry error (Step 8, last part).
- The fetched files exceed 60 MB.
- Step 1: Write the failing tests
Create tests/test_sentry_boot.gd:
extends GutTest
# SentryBoot (telemetry v2 Task 9): iOS only, needs a DSN and diagnostics on; a no-op wherever the# GDExtension isn't loaded (the root project, which is where GUT runs).
const TMP := "user://sentry_dsn_test.json"
func after_each() -> void: DirAccess.remove_absolute(TMP) SentryBoot.initialised = false
func test_should_init_needs_ios_a_dsn_and_consent() -> void: assert_true(SentryBoot.should_init("iOS", "https://k@o1.ingest.sentry.io/1", true)) assert_false(SentryBoot.should_init("tvOS", "https://k@o1.ingest.sentry.io/1", true)) assert_false(SentryBoot.should_init("Web", "https://k@o1.ingest.sentry.io/1", true)) assert_false(SentryBoot.should_init("iOS", "", true)) assert_false(SentryBoot.should_init("iOS", "https://k@o1.ingest.sentry.io/1", false))
func test_read_dsn_absent_or_broken_is_empty() -> void: assert_eq(SentryBoot.read_dsn("user://no_such_file.json"), "") var f := FileAccess.open(TMP, FileAccess.WRITE) f.store_string("not json") f.close() assert_eq(SentryBoot.read_dsn(TMP), "")
func test_read_dsn_trims() -> void: var f := FileAccess.open(TMP, FileAccess.WRITE) f.store_string("{\"dsn\": \" https://k@o1.ingest.sentry.io/1 \"}") f.close() assert_eq(SentryBoot.read_dsn(TMP), "https://k@o1.ingest.sentry.io/1")
func test_apply_without_the_extension_is_a_no_op() -> void: assert_false(Engine.has_singleton(SentryBoot.SINGLETON), "root project never loads sentry-godot") SentryBoot.apply(true, "inst") assert_false(SentryBoot.initialised)Append to tests/test_release_presets.gd. First add an "exclude" field to the dictionary _presets() builds:
"exclude": str(cf.get_value(s, "exclude_filter", "")),(Put it directly after the "features": ... line.) Then add:
func test_tvos_presets_exclude_sentry_and_ios_presets_keep_it() -> void: # sentry-godot has no tvOS library (telemetry v2 Task 9); iOS must still ship it. for p in _presets(): var parts: Array = Array(p.exclude.split(",")).map(func(x: String) -> String: return x.strip_edges()) if p.name.begins_with("tvOS"): assert_true("addons/sentry/*" in parts, "%s must exclude addons/sentry/*" % p.name) else: assert_false("addons/sentry/*" in parts, "%s must not exclude sentry" % p.name)- Step 2: Run them to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_sentry_boot -gexit; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit
Expected: FAIL (SentryBoot not declared; tvOS presets lack the exclude).
- Step 3: Fetch script, directory swap, ignores, the auto_init setting, preset excludes
Create scripts/release/fetch-sentry.sh (then chmod +x):
#!/usr/bin/env bash# Telemetry v2 Task 9: put the pinned sentry-godot GDExtension (iOS + macOS libraries only) into# platform/tvos/addons/sentry/. That dir is gitignored: ~45 MB of binaries stay out of git. Pinned# by sha256, idempotent. build-ios.sh and install-dev.sh (iOS) run it before exporting. No tvOS# library exists upstream; the tvOS presets exclude addons/sentry/*.set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd)VER=2.2.0ASSET="sentry-godot-2.2.0+407729a.zip"URL="https://github.com/getsentry/sentry-godot/releases/download/$VER/sentry-godot-2.2.0%2B407729a.zip"SHA=539cca58ff4188dafcd1318e03396fa303ea1ae5b2eda6f96a055146af44b156DEST=$ROOT/platform/tvos/addons/sentryCACHE=${BH_SENTRY_CACHE:-$HOME/Library/Caches/bh-sentry}sha() { shasum -a 256 "$1" | cut -d' ' -f1; }if [ -f "$DEST/.pinned" ] && [ "$(cat "$DEST/.pinned")" = "$SHA" ]; then echo "fetch-sentry: $VER already in place"; exit 0; fimkdir -p "$CACHE"Z=$CACHE/$ASSETif [ ! -f "$Z" ] || [ "$(sha "$Z")" != "$SHA" ]; then curl -fsSL -o "$Z" "$URL"; fi[ "$(sha "$Z")" = "$SHA" ] || { echo "fetch-sentry: checksum mismatch for $Z, refusing"; exit 1; }TMP=$(mktemp -d)unzip -q "$Z" 'addons/sentry/sentry.gdextension' 'addons/sentry/bin/ios/*' 'addons/sentry/bin/macos/*' -d "$TMP"rm -rf "$DEST"mv "$TMP/addons/sentry" "$DEST"rm -rf "$TMP"echo "$SHA" > "$DEST/.pinned"echo "fetch-sentry: $VER -> $DEST ($(du -sh "$DEST" | cut -f1))"Swap the symlink for a real directory. Nothing under root addons/ changes.
cd /Users/chris/Claude/bullet-heavengit rm --cached platform/tvos/addonsrm platform/tvos/addons # the symlink itself, not its targetmkdir platform/tvos/addonsln -s ../../../addons/gut platform/tvos/addons/gutls platform/tvos/addons/gut/gut_cmdln.gd # must resolvechmod +x scripts/release/fetch-sentry.shbash scripts/release/fetch-sentry.shdu -sm platform/tvos/addons/sentry # abort criterion 5: must be <= 60Append to .gitignore:
# sentry-godot (telemetry v2 Task 9): fetched + sha256-pinned by scripts/release/fetch-sentry.shplatform/tvos/addons/sentry/# Sentry DSN, supplied by Chris; absent = Sentry dormantplatform/tvos/sentry_dsn.jsonCheck: git check-ignore platform/tvos/addons/sentry/sentry.gdextension platform/tvos/sentry_dsn.json prints both paths, and git status --short platform/tvos/addons shows only addons/gut.
Append to platform/tvos/project.godot:
[sentry]
options/auto_init=false(SentryBoot decides when to init. The windowed exports rewrite project.godot and the scripts git checkout -- project.godot afterwards, so this must be COMMITTED before any export in Step 8, or it is silently lost.)
In platform/tvos/export_presets.cfg, add addons/sentry/* to the tvOS and tvOS-Dev presets only:
python3 - <<'EOF'import rep = "platform/tvos/export_presets.cfg"s = open(p).read()out, cur = [], Nonefor line in s.splitlines(keepends=True): m = re.match(r'^name="(.*)"', line) if m: cur = m.group(1) if line.startswith('exclude_filter="') and cur in ("tvOS", "tvOS-Dev") and "addons/sentry/*" not in line: line = line.rstrip('\n').rstrip('"') + ', addons/sentry/*"\n' out.append(line)open(p, "w").write("".join(out))EOF/usr/bin/grep -n "^name=\|^exclude_filter" platform/tvos/export_presets.cfgExpected: tvOS and tvOS-Dev show exclude_filter="tests/*, telemetry/*, tools/*, addons/sentry/*", and iOS / iOS-Dev are unchanged.
- Step 4: Probe the real API before wiring it
Create tools/sentry_probe/probe.gd:
extends SceneTree
# One-shot: does the fetched sentry-godot expose what net/sentry_boot.gd calls? Run in the tvOS# project on macOS (the macOS library loads there; nothing is initialised: auto_init=false).# godot --headless --path platform/tvos -s res://tools/sentry_probe/probe.gd
func _init() -> void: var ok := Engine.has_singleton("SentrySDK") print("singleton SentrySDK: ", ok) if ok: var names: Array = ClassDB.class_get_method_list("SentrySDK").map(func(m: Dictionary) -> String: return m.name) for want in ["init", "close", "set_tag", "is_enabled"]: print("method %s: %s" % [want, names.has(want)]) for m in ClassDB.class_get_method_list("SentrySDK"): if m.name == "init": print("init args: ", m.args) var props: Array = ClassDB.class_get_property_list("SentryOptions").map(func(p: Dictionary) -> String: return p.name) for want in ["dsn", "release", "environment", "send_default_pii"]: print("option %s: %s" % [want, props.has(want)]) quit(0 if ok else 1)platform/tvos/tools is a symlink to root tools/, so the file is visible to both projects. Nothing in the root project loads it.
Run: godot --headless --path platform/tvos -s res://tools/sentry_probe/probe.gd; cd platform/tvos && git checkout -- project.godot; cd ../..
Expected:
singleton SentrySDK: true- all four methods
true init argsshows one Callable argument- all four options
true
If a name differs, change SentryBoot.apply to the probed names. If the singleton is missing, stop: abort criterion 1.
- Step 5: Create
net/sentry_boot.gd
class_name SentryBootextends RefCounted
# Telemetry v2 Task 9: iOS crash reporting through sentry-godot, DORMANT until Chris adds# platform/tvos/sentry_dsn.json ({"dsn": "..."}; gitignored). The GDExtension only exists in the# iOS/tvOS project (fetched by scripts/release/fetch-sentry.sh, excluded from the tvOS presets),# never in the root project, so it is reached only through the Engine singleton: a static# reference to SentrySDK would not compile here. Diagnostics off = never initialised / closed.
const DSN_FILE := "res://sentry_dsn.json"const SINGLETON := "SentrySDK"
static var initialised: bool = false
static func should_init(os_name: String, dsn: String, share: bool) -> bool: return os_name == "iOS" and dsn != "" and share
static func read_dsn(path: String = DSN_FILE) -> String: if not FileAccess.file_exists(path): return "" var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if parsed is Dictionary: return String((parsed as Dictionary).get("dsn", "")).strip_edges() return ""
# At boot and on every Diagnostics toggle.static func apply(share: bool, install_id: String) -> void: if not Engine.has_singleton(SINGLETON): return var sdk: Object = Engine.get_singleton(SINGLETON) if not share: if initialised: sdk.call("close") initialised = false return if initialised: return var dsn := read_dsn() if not should_init(OS.get_name(), dsn, share): return var release := "dark-cosmos@%d" % Sim_Const.BUILD var env := BuildChannel.current() sdk.call("init", func(options: Object) -> void: options.set("dsn", dsn) options.set("release", release) options.set("environment", env) options.set("send_default_pii", false)) sdk.call("set_tag", "install", install_id) initialised = true(The lambda reads the locals dsn / release / env and never assigns them, so capture-by-value is correct here.)
- Step 6: Wire it (
main.gd,build-ios.sh,install-dev.sh)
In main._ready, directly after the Task 4 lifecycle.boot(...) line, add:
if EventQueue.live_platform(): SentryBoot.apply(meta.share_diagnostics, meta.install_id) # iOS + DSN + consent only; else no-opIn _on_diagnostics_toggled (Task 7), add as the last line:
SentryBoot.apply(on, meta.install_id)In scripts/release/build-ios.sh, directly after cd "$TVOS", add:
bash "$TVOS/../../scripts/release/fetch-sentry.sh" # telemetry v2: pinned sentry-godot (iOS libs)In scripts/dev/install-dev.sh, in the iOS branch, as the first line (before the --export-pack "iOS-Dev" line), add:
bash "$ROOT/scripts/release/fetch-sentry.sh"(--export-pack does not re-add a GDExtension to the Xcode shell. A Dev iPhone build only carries Sentry once build-ios/ has come from a full export with the addon present. That’s fine while Sentry is dormant. It’s written down in Task 11.)
- Step 7: Run the tests, then gate and commit
Run: godot --headless --path . --import; for t in test_sentry_boot test_release_presets test_diagnostics_toggle; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done
Expected: PASS.
Run: bash scripts/gate.sh → == GATE PASS.
cat > /tmp/bh-msg.txt <<'EOF'feat(telemetry): dormant iOS Sentry (sentry-godot 2.2.0, pinned fetch, tvOS excluded)
Telemetry v2 Task 9. platform/tvos/addons becomes a real dir (gut symlink + gitignored sentry/filled by scripts/release/fetch-sentry.sh, sha256-pinned, iOS+macOS libs only). auto_init off;net/sentry_boot.gd inits only on iOS with a DSN file and diagnostics on, closes on opt-out.tvOS presets exclude addons/sentry/* (no tvOS library upstream).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add .gitignore platform/tvos/addons platform/tvos/project.godot platform/tvos/export_presets.cfg scripts/release/fetch-sentry.sh scripts/release/build-ios.sh scripts/dev/install-dev.sh net/sentry_boot.gd tools/sentry_probe/probe.gd tests/test_sentry_boot.gd tests/test_release_presets.gd main.gdgit status --short # confirm: platform/tvos/addons shows the symlink removal + addons/gut only, nothing under addons/sentrygit commit -F /tmp/bh-msg.txt- Step 8: Prove the abort criteria don’t trip (local builds; device boot is the one on-device step)
iOS compiles and links with the addon:
cd /Users/chris/Claude/bullet-heaven/platform/tvos/opt/homebrew/bin/godot --path . --rendering-method mobile --export-release "iOS" /private/tmp/bh-ios-sentry/BulletHeaven.xcodeproj > /private/tmp/bh-ios-sentry-export.log 2>&1 || tail -20 /private/tmp/bh-ios-sentry-export.loggit checkout -- project.godot/usr/bin/grep -i "gdextension\|sentry" /private/tmp/bh-ios-sentry-export.log | headbash ../../scripts/release/apply-ios-metrickit.sh /private/tmp/bh-ios-sentrycd /private/tmp/bh-ios-sentryxcodebuild -scheme BulletHeaven -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \ -derivedDataPath /private/tmp/bh-ios-sentry-dd CODE_SIGNING_ALLOWED=NO build > build.log 2>&1 || tail -30 build.logls /private/tmp/bh-ios-sentry-dd/Build/Products/Release-iphoneos/BulletHeaven.app/Frameworks/ | /usr/bin/grep -i sentryExpected:
- The export succeeds into a scratch dir, so the signed
build-ios/shell is untouched. BUILD SUCCEEDED.Frameworks/lists the sentry framework(s).
If the export fails with the stock 4.7.1 template, that is criterion 2.
The tvOS pack stays clean:
cd /Users/chris/Claude/bullet-heaven/platform/tvos~/Claude/godot-tvos/godot/bin/godot.macos.editor.arm64 --headless --path . --export-pack "tvOS-Dev" /private/tmp/bh-tv-sentry.pck > /private/tmp/bh-tv-sentry.log 2>&1 || tail -20 /private/tmp/bh-tv-sentry.loggit checkout -- project.godot 2>/dev/null || truestrings /private/tmp/bh-tv-sentry.pck | /usr/bin/grep -c "addons/sentry/bin" # must be 0strings /private/tmp/bh-tv-sentry.pck | /usr/bin/grep "sentry.gdextension" | head -3 # extension_list reference?/usr/bin/grep -i "gdextension\|sentry" /private/tmp/bh-tv-sentry.log | headExpected:
- The count is
0. - No GDExtension error in the log.
- If
sentry.gdextensionstill shows up (via.godot/extension_list.cfginside the pck) [Guessing whether Godot 4.7 filters that list by exclude_filter], the runtime will try to load a missing extension on tvOS. Run the ATV check below and treat any sentry/GDExtension error line as criterion 4.
ATV boot check. This installs the Dev app on Chris’s Apple TV, the routine bh-deploy path. It needs the ATV awake; if it isn’t reachable, leave this box unticked and add the check to the Chris-gated list:
cd /Users/chris/Claude/bullet-heaven && bash scripts/dev/install-dev.sh atv --no-bumpxcrun devicectl device process launch --console --device 8C229574-499D-51EE-9B4F-87DEB2865DB3 digital.lumara.bulletheaven.dev 2>&1 | head -200 | /usr/bin/grep -i "gdextension\|sentry\|error" | headExpected: no sentry/GDExtension lines. Stop the console with Ctrl-C after the menu appears; head ends it in a script. If a check trips, follow the abort path in this task’s header. Afterwards: rm -rf /private/tmp/bh-ios-sentry /private/tmp/bh-ios-sentry-dd /private/tmp/bh-tv-sentry.pck /private/tmp/bh-tv-sentry.log.
As built (2026-09-29, branch tv2/task9), for Task 11 to document:
- Probe (stock 4.7.1, tvOS project on macOS):
SentrySDKsingleton present;init,close,set_tag,is_enabledpresent;init(configuration_callback: Callable),set_tag(key: String, value: String);SentryOptionshasdsn,release,environment,send_default_pii. No name changes were needed. - Fetched addon (iOS + macOS libs only): 44 MB. iOS no-signing
xcodebuild: BUILD SUCCEEDED,Frameworks/gainslibsentry.ios.release.framework(1.8 MB) +SentryObjC.framework(5.1 MB). tvOS-Dev pack: 0addons/sentry/bin, nosentry.gdextension, noextension_list.cfg; full tvOS export: no sentry in the pbxproj,dylibs/empty. platform/tvos/project.godot’s trailing#comments became;: with#, Godot swallowed the appended[sentry]section into one garbage key andauto_init=falsewas never set.- The Sentry editor plugin force-enables
always_track_call_stacksin every export once the addon is fetched; tvOS gets a.tvos=falseoverride (see G12 for iOS). SentryBoothas test seams (sdk_override,os_override,dsn_path,user_dir,cache_dir,user_factory,simulate_relaunch_for_test(),reset_for_test()); the toggle tests drivemain._on_diagnostics_toggledagainst a recording fake SDK.- Review round: Sentry user id = install id (
ClassDB.instantiate("SentryUser")+set_user, probed); opt-out (and a boot while off) deletessentry.dat, Cocoa’sINSTALLATIONandio.sentry/(iOS only: a Mac’s Caches dir is shared); no re-init in the launch that opted out (Cocoa caches its installation id in memory for sessions; sentry-godot exposes no auto-session-tracking option), so opt-in resumes at the next launch..ios=falsecall-stack override +scripts/release/sentry-guard.sh(dsn-vs-override,ios-shell). tvOS presets also excludesentry_dsn.json; all four excludeaddons/gut/*(this removed theCannot set object scriptexport error from GUT’sgut_loader_the_scene.tscn). - Defect for the Task 11 docs: checking out a pre-Task-9 commit restores
platform/tvos/addonsas a symlink, which deletes the ignoredaddons/sentry/dir, and un-ignoressentry_dsn.json(the.gitignorelines are gone there). Re-runfetch-sentry.shafter coming back, and keep a DSN file out of anygit addon an old commit.
Task 10: dSYM verification (can Xcode Organizer symbolicate engine frames?)
Section titled “Task 10: dSYM verification (can Xcode Organizer symbolicate engine frames?)”Files:
- Modify:
docs/architecture/dev-tools.md(new subsection under the Task 11 “Telemetry v2” section; Task 11 writes the section around it, so this task adds it as its own### Crash symbolication (dSYMs)block at the end of the file)
Interfaces:
- Consumes: the three existing archives:
platform/tvos/build-ios/BulletHeaven.xcarchiveplatform/tvos/build/BulletHeaven.xcarchiveplatform/tvos/build/BulletHeaven-tvos.xcarchive- All three have
dSYMs/BulletHeaven.app.dSYM, verified 2026-09-29.
- Produces: a recorded yes/no per archive for:
- UUIDs match between app and dSYM.
- An engine symbol resolves to file:line.
- An engine symbol at least resolves to a function name.
- No code, so no TDD cycle. The “test” is the command output, pasted into the doc.
The spec wants Organizer crash logs usable. Organizer symbolicates with the dSYM uploaded alongside the archive. The fork builds the engine template without debug info unless the template was built with debug_symbols=yes (out of scope per the spec). So function names are [Likely] and file:line is [Guessing].
- Step 1: Run the check per archive
Same find dSYMs -path "*DWARF/*" pattern as .claude/skills/bh-appstore-release/SKILL.md:81:
cd /Users/chris/Claude/bullet-heaven/platform/tvosfor A in build-ios/BulletHeaven.xcarchive build/BulletHeaven.xcarchive build/BulletHeaven-tvos.xcarchive; do echo "=== $A" D=$(find "$A/dSYMs" -path "*DWARF/*" -type f | head -1) B="$A/Products/Applications/BulletHeaven.app/BulletHeaven" echo "-- uuids (must match)"; dwarfdump --uuid "$B"; dwarfdump --uuid "$D" echo "-- engine symbols in dSYM"; nm "$D" | /usr/bin/grep -c "Main9iteration" S=$(nm "$D" | /usr/bin/grep "__ZN4Main9iterationEv$" | awk '{print "0x"$1}' | head -1) echo "-- Main::iteration at $S" [ -n "$S" ] && atos -o "$D" -arch arm64 -l 0x100000000 "$S" echo "-- compile units with line tables"; dwarfdump --debug-line "$D" 2>/dev/null | /usr/bin/grep -c "include_directories\|file_names" || true strings "$B" | /usr/bin/grep -m1 custom_build || echo "(stock engine)"doneRead each result:
-
If the UUIDs differ, the archive was rebuilt after the dSYM, and Organizer can’t use it.
-
atosprintingMain::iteration() (in BulletHeaven) (main.cpp:NNNN)means full symbolication. -
Main::iteration() (in BulletHeaven) + 0means names only. -
A raw address means no symbols. A count of
0forMain9iterationmeans the engine was stripped before the dSYM was generated. -
Step 2: Record the result in
docs/architecture/dev-tools.md
Append (fill the table from the Step 1 output, verbatim values, no guesses):
### Crash symbolication (dSYMs) — checked 2026-09-29
Question: when a tester crash shows up in Xcode Organizer, will the engine frames be readable?Checked on the last archives with `dwarfdump --uuid`, `nm` and `atos` (commands: telemetry v2plan Task 10).
| Archive | Engine | UUIDs match | `Main::iteration` in dSYM | atos result ||---|---|---|---|---|| build-ios/BulletHeaven.xcarchive | (from `custom_build` line) | yes/no | count | (paste) || build/BulletHeaven.xcarchive | ... | ... | ... | ... || build/BulletHeaven-tvos.xcarchive | ... | ... | ... | ... |
What it means: (one line per outcome: full file:line / names only / nothing). If names only,file:line needs the export template rebuilt with debug symbols, which the telemetry v2 spec leftout of scope.Keep uploading dSYMs with every archive (release.sh does, via the xcodebuild export).Replace every placeholder cell with the real output before committing. The plan can’t pre-fill them; this is a measurement. The committed file must not contain “yes/no”, “count”, “(paste)” or “…”. Check that with /usr/bin/grep -n "yes/no\|(paste)\|\.\.\. |" docs/architecture/dev-tools.md, which must print nothing.
- Step 3: Commit
cat > /tmp/bh-msg.txt <<'EOF'docs(dev-tools): record dSYM symbolication check for the iOS/tvOS archives
Telemetry v2 Task 10: UUID match + engine symbol + atos result per archive, so we know what anOrganizer crash log will and won't show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add docs/architecture/dev-tools.mdgit commit -F /tmp/bh-msg.txtTask 11: Docs — dev-tools.md “Telemetry v2” section and the local bh-telemetry skill
Section titled “Task 11: Docs — dev-tools.md “Telemetry v2” section and the local bh-telemetry skill”Files:
- Modify:
docs/architecture/dev-tools.md(new## Telemetry v2 (2026-09-29)section, inserted directly ABOVE the### Crash symbolication (dSYMs)block from Task 10, so that block becomes its last subsection) - Local only (gitignored, never
git add -f):.claude/skills/bh-telemetry/SKILL.md
Interfaces:
-
Consumes: every name from Tasks 1-10. The text below uses them verbatim.
-
Produces: the orientation doc a future session reads before touching telemetry.
-
Step 1: Insert the section into
docs/architecture/dev-tools.md
## Telemetry v2 (2026-09-29)
Spec: `docs/superpowers/specs/2026-09-29-telemetry-v2-design.md`. Plan (with the deviations fromthe spec): `docs/superpowers/plans/2026-09-29-telemetry-v2.md`. Research that motivated it:`docs/research/2026-09-28-telemetry-gaps-and-options.md`.
**One pipe.** Every reporter pushes onto `EventQueue` (`net/event_queue.gd`, persistent node madein `main._ready`), never HTTP directly. `EventQueueCore` (pure) keeps pending events on disk(`telemetry_queue.txt`, user:// or the cache dir on tvOS; line = `<id>\t<type>\t<json>`), capsthem at 1 MB (oldest dropped first), and POSTs up to 20 per request to `POST /events`. Thefirst flush happens straight after boot; after that it flushes every 10 s or at 20 pending,immediately on background, run end and feedback, and backs off 10 s → 300 s on failure. A batchis dropped only on a 2xx whose `ok+dup+bad` equals what was sent, or on a 400/413. The Workerdedupes on the event id, so resends are safe.
**Envelope** (every event): `id, t, ts, install, launch, session, build, channel, dev, auto, d`.`install` = `MetaState.install_id` (random, rotated on re-enable); `launch` = per process;`session` = per run (shared by samples, hitches, run, run_end, encounters, feedback).
**Types.** `sample`, `hitch`, `run`, `encounter`, `feedback` also land in their old tables(plus `install_id`, `launch_id`, `event_id`); every type lands in `events` (for the legacy types`data` is NULL, since the row is in its own table). Lifecycle types: `launch` (carries`channel_probe`, `first_launch`, OS/model), `prev_exit` (how the LAST launch ended: `crash`,`killed_in_background`, `web_closed`, from the always-armed `exit_marker.json`), `menu`,`tutorial_step`, `shop_buy`, `run_start`, `run_end` (`reason`: `death`, `story_complete`,`tutorial_cap`, `quit_menu`, `dev_restart`), `background`, `foreground`, `metrickit` (iOS).`runs` stays "banked runs only"; a quit or a kill shows up as `run_end` / `prev_exit`, not a row.`runs.timeline_json` = level-ups (time, offered, picked), XP by enemy type, damage by weapon,boss spawn/kill times.
**Perf fields.** `samples.render_ms` and `hitches.proc_ms` are `Performance.TIME_PROCESS`, thewhole frame's process time, NOT "our script" and NOT rendering (the old dashboard reading waswrong). Real render cost: `samples.render_cpu_ms` (every viewport's measured render CPU time +frame setup). `samples.gpu_ms` is NULL on Metal (tvOS/iOS) and WebGL: those renderers have no GPUtimestamps; it only fills on desktop Vulkan/D3D12/GL. Hitches: `HitchSampler` sends every first-use (`new:`) or pipeline-compilestall, otherwise the worst 2 per 30 s, at most 200 per run.
**Querying** (from `telemetry/`, account id is not a secret):
export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler d1 execute bullet-heaven-telemetry --remote --command \ "SELECT type, COUNT(*) n FROM events WHERE ts > (strftime('%s','now')-86400)*1000 GROUP BY type" # sessions that ended without a banked run, and why npx wrangler d1 execute bullet-heaven-telemetry --remote --command \ "SELECT json_extract(data,'$.reason') reason, COUNT(*) FROM events WHERE type='run_end' GROUP BY 1" # crashes / background kills reported on the next launch npx wrangler d1 execute bullet-heaven-telemetry --remote --command \ "SELECT build, dev, json_extract(data,'$.reason'), json_extract(data,'$.state'), json_extract(data,'$.recent') FROM events WHERE type='prev_exit' ORDER BY ts DESC LIMIT 50"
**Share diagnostics.** Pause menu → "Diagnostics: On/Off" (`MetaState.share_diagnostics`,default on, saved). Off: queue wiped, pushes become no-ops, install id cleared and dropped from`GET /config`, Sentry closed, MetricKit files deleted unsent. On again: a fresh install id.Privacy answers: `marketing/appstore/compliance.md`.
**MetricKit (iOS only).** `platform/tvos/patches/ios/metrickit-sink.mm` is APPENDED to theexport's `dummy.cpp` (renamed `dummy.mm`) by `scripts/release/apply-ios-metrickit.sh` (run by`build-ios.sh` and `install-dev.sh`). It writes `<Caches>/mk-<kind>-<ms>-<rand>.json`, and`net/metrickit_pickup.gd` sends each file as a `metrickit` event (`d.raw` = the JSON text, notre-parsed: Godot floats would corrupt 64-bit addresses) at boot and on foreground. Not on tvOS(MetricKit doesn't exist there).
**Sentry (iOS only, dormant).** sentry-godot 2.2.0 is fetched (sha256-pinned) into the gitignored`platform/tvos/addons/sentry/` by `scripts/release/fetch-sentry.sh`. `[sentry] options/auto_init=false`;`net/sentry_boot.gd` initialises only on iOS, with `platform/tvos/sentry_dsn.json` present(gitignored, Chris supplies) and diagnostics on. The tvOS presets exclude `addons/sentry/*` (no tvOSlibrary upstream). A Dev iPhone build made with `--export-pack` only has Sentry if `build-ios/` lastcame from a full export with the addon fetched. (Replace this paragraph with "Sentry parked: ..."if Task 9's abort path was taken.)
**Deploy order is fixed.** D1 migration (`telemetry/migrations/2026-09-29-telemetry-v2.sql`, onestatement at a time) → `PRAGMA table_info` on all six tables → Worker deploy → smoke test. TheWorker's old routes write the new columns, so deploying first breaks every insert. A v2 clientthat ships before the Worker deploy keeps its events queued (404 = retry), so nothing is lost, butkeep builds away from testers until the smoke test passes.- Step 2: Update the local bh-telemetry skill (not committed)
In .claude/skills/bh-telemetry/SKILL.md, directly under the # Bullet Heaven — telemetry (query + add capture) heading’s intro paragraph, add:
**Telemetry v2 (2026-09-29) changed the capture side: read `docs/architecture/dev-tools.md`§ "Telemetry v2" first.** New capture = a new event type pushed through `EventQueue.push(type, d)`(no new route, no new HTTPRequest). It lands in `events.data` with zero Worker changes. Only adda legacy-style table when the data needs indexed columns. Lifecycle / crash / funnel questions:query `events` (`type IN ('launch','prev_exit','run_end','tutorial_step','shop_buy')`), not `runs`.Then check that the skill’s “adding new capture” section doesn’t still tell the reader to add a POST route for every new data point. If it does, prefix that section with: (Pre-v2 pattern. For new event types use EventQueue.push; see the note at the top.)
- Step 3: Check the frontmatter, then commit and push
docs/architecture/dev-tools.md already has its title: frontmatter; the insertion is below it. Run: head -3 docs/architecture/dev-tools.md → the first line is ---.
cat > /tmp/bh-msg.txt <<'EOF'docs(dev-tools): telemetry v2 orientation (queue, events, toggle, MetricKit, Sentry, deploy order)
Telemetry v2 Task 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gkEOFgit add docs/architecture/dev-tools.mdgit commit -F /tmp/bh-msg.txtgit pull --rebase && git push origin main # the project rule: push after every local mergeChris-gated (needs Chris’s same-day OK; no task above does any of this)
Section titled “Chris-gated (needs Chris’s same-day OK; no task above does any of this)”These are outward-facing or irreversible. An agent message is not approval. Ask Chris, get a yes in his own words for today, then run them in this order.
G1. D1 migration (live DB). Must come BEFORE G2. Each ALTER runs once; “duplicate column name” on a re-run is expected and harmless.
cd /Users/chris/Claude/bullet-heaven/telemetryexport CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc/usr/bin/grep -v '^--' migrations/2026-09-29-telemetry-v2.sql | /usr/bin/grep -v '^[[:space:]]*$' | while IFS= read -r stmt; do echo ">> $stmt" npx wrangler d1 execute bullet-heaven-telemetry --remote --command "$stmt" || echo "!! FAILED (fine only if 'duplicate column name')"donefor t in samples runs encounters feedback hitches events; do echo "== $t"; npx wrangler d1 execute bullet-heaven-telemetry --remote --command "PRAGMA table_info($t)" | /usr/bin/grep -o '"name": *"[a-z_]*"' | tr '\n' ' '; echodoneExpected:
sampleslistsinstall_id launch_id event_id auto gpu_ms render_cpu_ms.runslistsinstall_id launch_id event_id timeline_json.encounters,feedbackandhitcheslistinstall_id launch_id event_id.eventsexists withrts.
Stop if anything is missing.
G2. Worker deploy. Only after G1 verifies. Commit or stash anything uncommitted under telemetry/ first; a PreToolUse hook blocks a deploy with a dirty tree.
cd /Users/chris/Claude/bullet-heaven/telemetry && CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler deployG3. Smoke test, then clean up:
cd /Users/chris/Claude/bullet-heaven/telemetryexport CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfcID=$(openssl rand -hex 16); NOW=$(($(date +%s)*1000))BODY="{\"v\":1,\"events\":[{\"id\":\"$ID\",\"t\":\"sample\",\"ts\":$NOW,\"install\":\"smoketest\",\"launch\":\"smoketest\",\"session\":\"smoketest\",\"build\":0,\"channel\":\"dev\",\"dev\":\"smoketest\",\"auto\":0,\"d\":{\"fps\":60,\"gpu_ms\":1.5}}]}"curl -s -X POST https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events -H 'content-type: text/plain' -d "$BODY"; echo # {"ok":1,"dup":0,"bad":0}curl -s -X POST https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events -H 'content-type: text/plain' -d "$BODY"; echo # {"ok":0,"dup":1,"bad":0}npx wrangler d1 execute bullet-heaven-telemetry --remote --command "SELECT type, session FROM events WHERE session='smoketest'"npx wrangler d1 execute bullet-heaven-telemetry --remote --command "SELECT session, gpu_ms, install_id FROM samples WHERE session='smoketest'"npx wrangler d1 execute bullet-heaven-telemetry --remote --command "DELETE FROM events WHERE session='smoketest'"npx wrangler d1 execute bullet-heaven-telemetry --remote --command "DELETE FROM samples WHERE session='smoketest'"Expected:
- Exactly one
eventsrow and onesamplesrow, withgpu_ms1.5 andinstall_idsmoketest. - Both deleted afterwards.
Then open the dashboard and check the new “gpu ms” and “process ms” headers render.
G4. App Store Connect privacy label. Apply marketing/appstore/compliance.md:
- Device ID, Crash Data, Performance Data, Product Interaction, Other User Content.
- All Not Linked, no tracking.
This must be done BEFORE submitting the first build that carries telemetry v2 to review. TestFlight external testing also shows the label.
G5. Privacy notice on the site (~/Claude/bullet-heaven-site, deploy only via its scripts/deploy-site.sh). Draft text for /privacy/, to merge with whatever the page already says (read it first; keep its voice; Lumara copy rules apply, so no em-dashes):
Diagnostics. Dark Cosmos sends anonymous diagnostics to help us fix crashes and slowdowns: how the game performs on your device (frame rate and timings), how a session went (how long a run lasted, how it ended, which upgrades were picked, shop purchases, tutorial progress), and whether the game closed unexpectedly last time. On iPhone and iPad this can include Apple’s crash and performance reports (MetricKit) and, if we switch it on, crash reports processed by Sentry. Any note you choose to send with “Send feedback” is included too.
We use a random install ID so we can tell sessions from the same install apart. It is made on your device, it is not your Apple ID, advertising ID or device serial, and it is not linked to your name or any account. We do not sell this data or use it for advertising or tracking.
Turning it off. Pause the game and select “Diagnostics: On” to switch it to Off. Nothing more is sent, anything waiting to be sent is deleted from your device, and the install ID is discarded. If you turn it back on, a new, unrelated ID is created.
Data is stored with Cloudflare (and Sentry, for iOS crash reports, if enabled) and kept for up to [Chris: retention period] before deletion.
The retention period is Chris’s decision; there is no retention job yet (spec: out of scope). Say so if he asks.
G6. On-device MetricKit check (iPhone Dev app). After a full iOS export (build-ios.sh, or a full export followed by install-dev.sh iphone):
- Launch, background, and relaunch the next day.
xcrun devicectl device info files --device 12FDF0CC-72E2-581B-B306-714C6E2275AC --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Cachesshould showmk-*.jsonappearing and then being removed.- A
metrickitrow should show up inevents.
This confirms OS.get_cache_dir() == NSCachesDirectory on iOS (currently [Likely]).
G7. Sentry project. Only if Chris wants Sentry live:
- He creates the project (free Developer plan) and writes
platform/tvos/sentry_dsn.jsonas{"dsn": "<value>"}. It is gitignored. The value never goes into chat or a commit. - Then a full iOS export.
- Remove
settings/gdscript/always_track_call_stacks.ios=falsefromplatform/tvos/project.godot(Sentry needs script call stacks).scripts/release/sentry-guard.sh dsn-vs-override, run bybuild-ios.shandinstall-dev.sh(iOS), refuses a DSN while that override is present, andtest_release_presetsasserts the override exists iff there is no DSN file. - Confirm Sentry works: temporarily force a crash on the Dev app, and check it lands in Sentry with release
dark-cosmos@<BUILD>and user id = the app’s install id. - G13 below, on the same device.
G8. Skills in git. Tasks 8 and 11 edit .claude/skills/*/SKILL.md, which is globally gitignored. Ask Chris whether he wants them force-added (git add -f), or left local as today.
G9. TestFlight gate. Don’t upload a telemetry v2 build to TestFlight/App Store before G1-G4 are done. Also check the dashboard shows /events traffic from a Dev-app session first.
G10. Perf units on device (Dev app, Apple TV). After a Dev-app session on the ATV reaches the live Worker, check one sample: SELECT fps, render_ms, render_cpu_ms, gpu_ms FROM samples WHERE dev LIKE 'tvOS%' ORDER BY ts DESC LIMIT 5. Expect render_cpu_ms roughly 0.5-20 (milliseconds; a value in the thousands means the unit is wrong) and gpu_ms NULL (Metal has no timestamp queries: expected, not a bug).
G11. Task 9 abort criterion 4: Dev app boot on the ATV with the addon fetched. Not run by the Task 9 agent (no device installs). In the main checkout after merging Task 9 (so platform/tvos/addons/sentry/ exists, i.e. after bash scripts/release/fetch-sentry.sh): bash scripts/dev/install-dev.sh atv --no-bump, then xcrun devicectl device process launch --console --device 8C229574-499D-51EE-9B4F-87DEB2865DB3 digital.lumara.bulletheaven.dev 2>&1 | head -200 | /usr/bin/grep -i "gdextension\|sentry\|error" | head. Expected: no sentry/GDExtension lines (the agent’s tvOS-Dev pack held no addons/sentry/bin, no sentry.gdextension and no extension_list.cfg, and a full tvOS export’s pbxproj had no sentry reference, so [Likely] clean). A hit = Task 9 abort path.
G12. iOS call-stack tracking (Task 9 finding; decided). sentry-godot’s editor plugin force-sets debug/settings/gdscript/always_track_call_stacks=true in every export once the addon is fetched. Both .tvos=false and .ios=false overrides are in platform/tvos/project.godot, so a dormant Sentry costs no script time (decided in the Task 9 review). Side effect: the SDK logs ERROR: Sentry: Please enable always_track_call_stacks once per iOS launch while dormant (harmless). Enabling Sentry = remove the .ios line (G7 step 3).
G13. Sentry identity on device (Task 9, only once a DSN exists). Verified from source only (sentry-godot 2.2.0 / sentry-cocoa 9.28.0): SentryBoot sets the Sentry user id to the install id; opt-out closes Sentry and deletes <user dir>/sentry.dat, <Caches>/INSTALLATION and <Caches>/io.sentry/; opt-in resumes at the next launch. On the Dev iPhone: opt out, then xcrun devicectl device info files … --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Caches shows no INSTALLATION / io.sentry; relaunch, opt in, relaunch, force a crash; in Sentry the event’s user id and the session’s distinct id are the NEW install id / a fresh installation id, not the pre-opt-out ones. Also confirm nothing reaches Sentry between opt-out and the next opt-in (auto session tracking can’t be turned off through sentry-godot, and close() may flush a final session update).
Execution handoff
Section titled “Execution handoff”- Run with
superpowers:subagent-driven-development, one task per implementer (bh-implementer,model: "opus"), each in its own worktree frommain, withbh-reviewerbefore every merge. Tasks are strictly ordered 1 → 11. Each depends on names produced by the one before (see the Interfaces blocks). - After every task:
bash scripts/gate.shgreen, the seed-1234 baseline intests/test_determinism_survival.gdunchanged (never re-pinned for this work), and merge +git push origin mainstraight away (project rule). - Tasks 8 and 9 need the Mac’s Xcode, and Task 9 Step 8 needs the Apple TV awake. Run those two in the main checkout, not a remote agent.
- Task 10 is measurement only. Its table must hold real output.
- Nothing in “Chris-gated” is done by an agent without Chris’s same-day yes.
- UI change (Task 7): run
bh-visual-qaon the pause menu at iPhone, iPad and ATV sizes before merging.
Self-review (run 2026-09-29 against the spec)
Section titled “Self-review (run 2026-09-29 against the spec)”Spec coverage:
| Spec item | Where |
|---|---|
/events batch route, dedupe, limits, feedback rate limit, old routes kept |
Task 1 |
| Persistent on-disk queue, envelope, ids, flush/backoff, tvOS cache fallback | Task 2 |
All reporters through the queue; one session id; install/launch ids; auto on samples |
Task 3 |
Always-armed exit marker, prev_exit, lifecycle/funnel events, run_start/run_end reasons, shop_buy |
Task 4 |
| Mid-run timeline: level-ups offered/picked with time, XP by type, damage by weapon, boss TTK | Task 5 |
| GPU/render-CPU time; hitch sampler replacing first-40; mislabelled perf fixed | Task 6 |
Share-diagnostics toggle (pause menu, default on, persisted), queue wipe, id rotation, /config without install, compliance.md |
Task 7 |
MetricKit iOS (sink, pastPayloads, pickup at boot/foreground, README + skills, xcodebuild no-signing check) |
Task 8 |
| Sentry iOS-only, dormant, tvOS excluded, send_default_pii off, release/environment/install tag, park-if-broken | Task 9 |
| dSYMs usable for Organizer | Task 10 |
| Docs | Task 11 |
| Deploy order, smoke test, ASC label, privacy-notice draft | Chris-gated G1-G5 |
No spec item is without a task. Out-of-scope items stay out: retention dashboards, Analytics Engine, Sentry on web/tvOS, engine debug-symbol template, deleting old routes.
Placeholder scan:
- Task 10’s table is a measurement template. It carries an explicit grep that fails if a placeholder is committed.
- G5 carries one bracketed retention period that only Chris can decide.
- Nothing else is TBD.
Type/name consistency: checked these across tasks:
EventQueue.setup(install_id, share),set_enabled(on, install_id),is_enabled(),push(),flush_now(),launch_id(),enable_for_test(),muted.EventQueueCore.pending_count(),pending_events(),BATCH_MAX_BYTES.LifecycleTelemetry.boot(first_launch, share),on_foreground(),pick_up_metrickit().Telemetry.setup(sim, queue),flush_held(),render_times().HitchSampler.offer/poll/drain/sent.MetaStore.set_share_diagnostics,RemoteConfig.fetch_url/set_install_id.PauseMenu.share_diagnostics/diagnostics_toggled/_diag_btn.MetricKitPickup.pick_up(dir, queue),SentryBoot.apply(share, install_id),main._on_diagnostics_toggled(on).
Fixed during review:
- The Task 5 test used
sim.tick(...). It now usestick_single. - The Task 2 cap test used integer division. It now uses float maths.
- Deviation 2 said “any 4xx drops”. It now says 400/413 only, so a pre-deploy 404 retries.
- The MetricKit design parsed the payload JSON. It now sends raw text, because Godot’s float parsing corrupts 64-bit addresses.
- The file-structure row for
dummy.mmnow names the appendedmetrickit-sink.mm.
Known risks left open (flag, don’t fix):
- A burst of 4+ large MetricKit payloads (≤ 200 KB each) can hit the 1 MB queue cap and evict older events.
OS.get_cache_dir()vsNSCachesDirectoryis unproven until G6.- Whether Godot 4.7 keeps an excluded GDExtension in the tvOS pck’s
extension_list.cfgis unknown until Task 9 Step 8. - sentry-godot’s
init(Callable)andSentryOptionsproperty names are unproven until the Task 9 Step 4 probe. - The
.linker_optiondirective is unproven until the Task 8 Step 7otoolcheck. A fallback is given.