What changed, and why
The base Sentry wiring (#353) captures unhandled errors and auto-traces routes. This follow-up makes the signal everwhen already emits โ the [ai] cost line, the x-run-id correlation, the player behind a request โ queryable, correlated, and safe, without touching a single bracketed console line or any fail-open/fail-closed behavior.
Three additive pieces, each below: every priced model call becomes a span (metrics by task/model/lane); each request is tagged with its run and a PII-free player id; and a scrub drops player free-text and PII before anything leaves the process. No new dependency, no Docker or CI change โ instrumentation code and its tests.
The AI seam: one sink, many calls
The gateway already enriches every call's telemetry in one place โ emit(). It fanned out to a single swappable observer (the eval harness / unit tests). The change adds a second, persistent channel โ addAiCallObserver โ so a long-lived subscriber can coexist with that swappable slot instead of clobbering it:
Two channels: the swappable observer, and a Set of persistent sinks.
server/utils/ai-gateway.ts ยท 553 lines
โฏ 94 lines hidden (lines 1โ94)
โฏ 439 lines hidden (lines 115โ553)
emit() then fans out to the sinks with each one isolated โ the load-bearing bit:
A faulty sink is swallowed โ it can't break the cost line or the turn.
server/utils/ai-gateway.ts ยท 553 lines
โฏ 123 lines hidden (lines 1โ123)
โฏ 419 lines hidden (lines 135โ553)
Turning each call into a span
The plugin registers one sink at server startup. It maps the gateway's telemetry onto a Sentry span โ task, lane, model, tokens, cost, latency โ so the [ai] line becomes queryable trace data grouped into the turn's trace:
server/plugins/sentry-ai-spans.ts ยท 46 lines
Identity + run correlation, PII-free
The run-context middleware already reads the sanitized x-run-id per request. It now also stamps the request's Sentry isolation scope, so every error, trace, and log ties back to a run and a player:
run_id tag + an opaque device-id user on the per-request scope.
server/middleware/run-context.ts ยท 34 lines
โฏ 23 lines hidden (lines 1โ23)
โฏ 1 line hidden (lines 34โ34)
The scrub: free-text never leaves
The last gate before an event is sent. everwhen's moderation seam exists because the dispatch, figure name, and Archive query are sensitive free text โ and they all travel in request bodies. So the scrub drops the body, query string, cookies, and any email/ip, keeping only what a triager needs:
sentry.scrub.ts ยท 34 lines
It's wired as both hooks on each side (client shown; server identical):
beforeSend + beforeSendTransaction โ errors and transactions both.
sentry.client.config.ts ยท 25 lines
โฏ 20 lines hidden (lines 1โ20)
What the tests pin
Two discriminating specs, both failing on the pre-change code. The sink spec proves the two channels coexist, that unsubscribe works, and โ most important โ that a throwing sink is isolated from the other subscribers and the call:
A sink that throws: the call still resolves, every other subscriber still fires.
tests/unit/server/utils/ai-call-observer.spec.ts ยท 86 lines
โฏ 68 lines hidden (lines 1โ68)
โฏ 1 line hidden (lines 86โ86)
The scrub spec pins the exact contract โ the dispatch-bearing body is dropped, identity is reduced to an opaque id:
Body / query / cookies / sensitive headers gone; non-sensitive fields kept.