headroom/deploy/beacon/worker.js

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

362 lines
15 KiB
JavaScript
Raw Normal View History

fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
/**
* Headroom telemetry beacon receiver.
*
* This file is open source on purpose. It is the other half of the promise
* made in headroom/telemetry/session.py: users can read exactly what the
* client sends AND exactly what happens to it on arrival. "Trust us" is not a
* privacy policy.
*
* Deployed at otlp.headroomlabs.ai. Three jobs:
*
* 1. Allowlist. Drop every field not on ALLOWED_KEYS before anything is
* written. This is the only privacy control that works retroactively
* if a future client version ships a bug that leaks a field, we cannot
* patch the installs already in the wild, but we can stop storing it
* here in one deploy.
*
* 2. Flatten. OTLP AnyValue nesting is portable but miserable to query
* ({"kvlistValue":{"values":[{"key":"tokens",...}]}}). We keep OTLP on
* the wire so the backend stays vendor-swappable, and store plain JSON so
* DuckDB can read it without unwrapping anything.
*
* 3. Fan out. R2 for the durable corpus; optionally a metrics vendor for
* dashboards. Adding a destination is one more call here never a
* client release.
*
* What this deliberately does NOT do: log, store, or forward the source IP.
* Cloudflare offers it as cf-connecting-ip; it is the one field that would
* deanonymise install_id, so it is never read.
*/
feat(beacon): allowlist the routing summary key (#2818) One line in the receiver's allowlist. No client change; the proxy's own payload is untouched. ## Why A routing extension sees things the proxy alone cannot, and they are all measurements rather than opinions: - **Empirical `min_cacheable` per provider.** Fireworks, Together and DeepInfra publish no minimum and litellm carries no value for them, so a router has to guess. But the number is directly observable — send prefix length L, see whether the repeat reports cached tokens. Across enough installs the step function falls out. - **TTL survival.** Currently modelled as a constant. - **Conversation length distribution.** The horizon is the only free parameter in a cache-aware cost model, and it decides the answer: at a 900-token prefix, 1 remaining turn and 20 remaining turns route to different models. - **Predicted vs actual cache hits.** Every response carries `cache_read_input_tokens`. Comparing it to what was predicted is the only way to find out when the cost model is lying. ## What lands here `'routing'` added to `ALLOWED_KEYS`, and the comment above the list corrected — it claimed the set mirrors `_Session.payload()`, which is no longer the whole story now that an extension can emit its own event carrying one of these keys. The ordering constraint is the reason this is its own PR: **allowlisting is a write-side gate**, so anything sent before the key exists is dropped and unrecoverable. This has to be deployed before any client starts emitting it, not alongside. ## Shape of the block Same rule as every other key — counters and model ids, no free text: ```json "routing": { "harness": "claude-code", "decisions": 47, "would_change": 12, "enforced": 9, "holdout": 3, "at_free_boundary": 4, "cross_protocol": 0, "picked": {"claude-haiku-4-5": 12, "claude-opus-5": 35}, "requested": {"claude-opus-5": 47}, "mean_prefix_tokens": 7514, "measured_cost": 0.0236, "modelled_cost": 0.0376, "cache_read_tokens": 3200, "cache_write_tokens": 0, "predicted_hits": 4, "actual_hits": 4 } ``` `measured_cost` comes from the provider's own usage; `modelled_cost` from the router's cost function. They stay separate because the difference is the only thing that means anything. The extension's `reason` string is deliberately absent. It is code-generated, so it carries no user content, but it is unbounded — it stays out rather than being reasoned about. `holdout` is the count of turns deliberately left unrouted as a control. Without it the rest is observational: once a router is acting on every request, the corpus is entirely that router's own policy. `sample-event.json` is unchanged on purpose — it mirrors `_Session.payload()`, which does not produce this key, and adding it there would suggest the proxy emits it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:05:19 -07:00
// Mostly mirrors the payload built by _Session.payload(); an extension may
// also emit its own event carrying one of these top-level keys. A key absent
// here is dropped, not stored. Adding a metric means adding it here first —
// that friction is the point, and it is also the only privacy control that
// works retroactively, so it must land BEFORE any client starts sending the
// key or that traffic is silently discarded and unrecoverable.
fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
const ALLOWED_KEYS = [
'schema_version',
'session',
'tokens',
'rates',
'compression',
'skips',
'sources',
'providers',
'models',
'failures',
fix(beacon): split session failures by status code (#2815) ## Description The session beacon reports `failures` as a single count, incremented whenever a turn ends `>= 500` (`headroom/telemetry/session.py`). Across the current corpus that reads **3,969 failures on 595,445 turns (0.67%)** — and the number cannot answer the only question anyone asks of it: an Anthropic `529` is the provider shedding load and there is nothing to fix; a `500` is usually ours. Today the two are indistinguishable, so diagnosis falls back to inference from time-of-day curves and per-install concentration. This counts the status alongside the total. ```json "failures": 3, "failure_statuses": {"529": 2, "500": 1} ``` Motivating investigation on the live corpus (0.67% of turns, 6% of sessions, 63% of all failures from 48 installs, a 2.5% plateau at 08–11 UTC decaying to 0.03% during the fleet's busiest hour) strongly suggests provider-side 529 after retry exhaustion — but "strongly suggests" is exactly the gap this field closes. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **`headroom/telemetry/session.py`** — `_Session.failure_statuses`, incremented next to `failures` in `record_outcome`. Keys are the bare status string for the 5xx range, `"other"` beyond it. Emitted as a sibling of `failures` in `payload()`. - **`deploy/beacon/worker.js`** — `failure_statuses` added to `ALLOWED_KEYS`. Without this the ingest allowlist silently drops it. - **`deploy/beacon/sample-event.json`** — sample carries the new key in OTLP `kvlistValue` form. ### Why no slug bounding `skips` runs values through `_safe_slug` because they arrive as free strings. A status code is an `int` the proxy itself produced; the `500 <= status < 600` check is what keeps a garbage value from inventing map keys. Nothing here is user-derived, so the field stays content-free. ### Why `schema_version` stays 1 Additive, matching the precedent set by #2796, which added `tokens.tool_saved` and the two `all_layers_*` rates without a bump. Bumping signals a break to consumers when nothing about older rows becomes invalid. ## Testing - [x] Unit tests pass (`pytest`) — the module's own self-check, extended - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) — see note - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m headroom.telemetry.session ok $ ruff check headroom/telemetry/session.py All checks passed! $ ruff format --check headroom/telemetry/session.py 1 file already formatted $ mypy --python-version 3.12 headroom/telemetry/session.py Success: no issues found in 1 source file # --python-version 3.12 only to skip a pre-existing numpy-stub syntax error the # repo's python_version = "3.10" triggers locally; unrelated to this diff. $ node --check deploy/beacon/worker.js # ok $ python -c "import json; json.load(open('deploy/beacon/sample-event.json'))" # parses ``` The self-check in `headroom/telemetry/session.py` now records two 529s and one 500 and asserts both the total and the split: ```python assert emitted[-1]["failures"] == 3 assert emitted[-1]["failure_statuses"] == {"529": 2, "500": 1} ``` plus `assert event["failure_statuses"] == {}` on the clean-session path. ## Real Behavior Proof - **Environment:** macOS 25.4.0, Python 3.12 venv, this branch. - **Exact command / steps:** drive `SessionAggregator` with three failing outcomes and encode the payload through the same `_any_value` the wire uses. ```text payload: 3 {'529': 2, '500': 1} otlp : {"kvlistValue": {"values": [{"key": "529", "value": {"intValue": "2"}}, {"key": "500", "value": {"intValue": "1"}}]}} ``` The OTLP form matches `deploy/beacon/sample-event.json` byte-for-byte in shape, and `unwrap()` in `worker.js` turns `kvlistValue` back into a plain object, so it lands in R2 as `{"529": 2, "500": 1}` — the same shape as `skips`, which DuckDB reads as `MAP(VARCHAR, BIGINT)`. - **Observed result:** as above. Verified against the live corpus that schema evolution here is already routine — 3,836 of 3,884 existing rows have `rates.all_layers_saved_pct = NULL` from #2796 landing mid-corpus, and every report still runs. - **Not tested:** the deployed Worker (no staging R2 binding locally); `node --check` covers syntax only. The allowlist addition is one array entry consumed by the existing `pick()`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Screenshots (if applicable) N/A — wire-format change, covered by the output above. ## Additional Notes **Deploy order matters.** The Worker allowlist drops unknown keys, so `deploy/beacon/worker.js` must be deployed *before* a client release that emits the field — otherwise it is discarded at the door. No corruption either way, just missing data until the Worker catches up. **Old data is unaffected.** R2 objects are immutable NDJSON written per request; nothing rewrites history. The corpus reader already passes `union_by_name = true`, which fills the column with NULL for rows written before this ships.
2026-08-05 17:01:57 -07:00
'failure_statuses',
feat(beacon): allowlist the routing summary key (#2818) One line in the receiver's allowlist. No client change; the proxy's own payload is untouched. ## Why A routing extension sees things the proxy alone cannot, and they are all measurements rather than opinions: - **Empirical `min_cacheable` per provider.** Fireworks, Together and DeepInfra publish no minimum and litellm carries no value for them, so a router has to guess. But the number is directly observable — send prefix length L, see whether the repeat reports cached tokens. Across enough installs the step function falls out. - **TTL survival.** Currently modelled as a constant. - **Conversation length distribution.** The horizon is the only free parameter in a cache-aware cost model, and it decides the answer: at a 900-token prefix, 1 remaining turn and 20 remaining turns route to different models. - **Predicted vs actual cache hits.** Every response carries `cache_read_input_tokens`. Comparing it to what was predicted is the only way to find out when the cost model is lying. ## What lands here `'routing'` added to `ALLOWED_KEYS`, and the comment above the list corrected — it claimed the set mirrors `_Session.payload()`, which is no longer the whole story now that an extension can emit its own event carrying one of these keys. The ordering constraint is the reason this is its own PR: **allowlisting is a write-side gate**, so anything sent before the key exists is dropped and unrecoverable. This has to be deployed before any client starts emitting it, not alongside. ## Shape of the block Same rule as every other key — counters and model ids, no free text: ```json "routing": { "harness": "claude-code", "decisions": 47, "would_change": 12, "enforced": 9, "holdout": 3, "at_free_boundary": 4, "cross_protocol": 0, "picked": {"claude-haiku-4-5": 12, "claude-opus-5": 35}, "requested": {"claude-opus-5": 47}, "mean_prefix_tokens": 7514, "measured_cost": 0.0236, "modelled_cost": 0.0376, "cache_read_tokens": 3200, "cache_write_tokens": 0, "predicted_hits": 4, "actual_hits": 4 } ``` `measured_cost` comes from the provider's own usage; `modelled_cost` from the router's cost function. They stay separate because the difference is the only thing that means anything. The extension's `reason` string is deliberately absent. It is code-generated, so it carries no user content, but it is unbounded — it stays out rather than being reasoned about. `holdout` is the count of turns deliberately left unrouted as a control. Without it the rest is observational: once a router is acting on every request, the corpus is entirely that router's own policy. `sample-event.json` is unchanged on purpose — it mirrors `_Session.payload()`, which does not produce this key, and adding it there would suggest the proxy emits it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:05:19 -07:00
// Model-routing summary. Emitted by a routing extension rather than by the
// proxy itself -- see proxy/route_advice.py for the decision seam. Same rule
// as everything above: counters and model ids, no free text. Allowlisted
// here so the corpus can answer what the proxy alone cannot -- a provider's
// real minimum cacheable prefix, how long a cache actually survives, and how
// far predicted cache hits are from the ones that happened.
'routing',
fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
];
// Resource attributes we keep. Same rule: allowlist, not denylist.
const ALLOWED_RESOURCE = [
'service.name',
'service.version',
'headroom.install_id',
'headroom.install_mode',
'headroom.stack',
'os.type',
'host.arch',
];
// A beacon event is ~2KB. Anything far past that is a bug or an attack.
const MAX_BODY_BYTES = 64 * 1024;
/** OTLP AnyValue -> plain JS. The inverse of _any_value() in session.py. */
function unwrap(value) {
if (value == null) return null;
if ('stringValue' in value) return value.stringValue;
if ('boolValue' in value) return value.boolValue;
if ('intValue' in value) return Number(value.intValue);
if ('doubleValue' in value) return value.doubleValue;
if ('arrayValue' in value) return (value.arrayValue.values || []).map(unwrap);
if ('kvlistValue' in value) {
const out = {};
for (const kv of value.kvlistValue.values || []) out[kv.key] = unwrap(kv.value);
return out;
}
return null;
}
function pick(obj, allowed) {
const out = {};
if (!obj || typeof obj !== 'object') return out;
for (const key of allowed) {
if (key in obj) out[key] = obj[key];
}
return out;
}
/** OTLP ExportLogsServiceRequest -> flat, allowlisted records. */
function extract(payload) {
const records = [];
for (const rl of payload.resourceLogs || []) {
const resource = {};
for (const attr of rl.resource?.attributes || []) {
resource[attr.key] = unwrap(attr.value);
}
const cleanResource = pick(resource, ALLOWED_RESOURCE);
for (const sl of rl.scopeLogs || []) {
for (const rec of sl.logRecords || []) {
const body = unwrap(rec.body);
if (!body || typeof body !== 'object') continue;
records.push({
...pick(body, ALLOWED_KEYS),
resource: cleanResource,
// Server-stamped. A client clock can be wrong or forged; this is the
// timestamp partitioning and retention actually rely on.
received_at: new Date().toISOString(),
});
}
}
}
return records;
}
feat(beacon): hourly R2 compaction, per-strategy savings, and a stack that reports (#2853) Three beacon changes bundled because they are one story: the corpus got too slow to query, and then too coarse to answer the question it was collected for. ## 1. Hourly compaction (`deploy/beacon`) The beacon writes one ~1 KB object per heartbeat — **64,987 on 2026-08-06** and climbing. A full analysis `pull` was ~100k HTTPS round trips for 95 MB: minutes of pure per-object latency. Listing the bucket alone took 88 seconds. Moving the query server-side does not help — R2 SQL reads only Iceberg tables, and a pile of tiny files is the pathological case for every query engine. Compaction is the fix, and it is Iceberg's own answer to the same problem. An hourly cron collapses each **complete** hour of `sessions/` into one `rollup/dt=…/hh=…/data.ndjson`, keeping the highest-`seq` heartbeat per `(install, session)`. | measured on `dt=2026-08-06/hh=14` | before | after | |---|---|---| | objects | 3,938 | **1** | | rows | 3,938 | **1,061** | | analysis `pull` | minutes | **seconds** | Hourly rather than daily because every R2 binding call is a subrequest: a day is ~65k, an hour is ~4k. Newest-first, so a backlog drains from the present backwards and live data never starves behind it; a failing hour is logged and skipped rather than blocking every older hour behind it. **Raw objects are never deleted**, so any rollup is rebuildable by deleting it. Backfill runs to the **oldest surviving raw day**, not a fixed window. A fixed lookback strands everything older than it the moment analysis stops reading `sessions/`: the raw objects are still there, but nothing would ever compact them, so they disappear from every report. `oldestRawDay()` finds that floor in one delimited LIST, and the rollup listing starts from it — so the work is bounded by retention rather than by total history. Three failure modes the tests pin down, because each one is silent: - A **failed `get`** is transient, so the hour throws and writes nothing. A rollup is built once and trusted forever, so a short read would quietly become the permanent record. - A **corrupt record** loses only itself. This Worker wrote that content with `JSON.stringify`; it will never become valid, so blocking on it strands the hour instead of the record. - An **empty hour** writes an `empty` marker. Without one the hour stays "missing" and is re-listed on every run forever. `test-rollup.mjs` asserts the Worker's dedup picks exactly the same rows as the analysis-side `QUALIFY`. If those two ever disagree the reports go quietly wrong rather than loudly broken, which is why that check exists. Its stub paginates at 3 keys so the list cursor loop — load-bearing at the real ~4,000 objects/hour — runs in every case. ## 2. Per-strategy savings (`compression.by_strategy`) `compression.transforms` counts *invocations*, which cannot distinguish a compressor that saved 60% from one that ran constantly and saved nothing. The fleet's top transform by count contributes an unknown share of `tokens.saved`. It is worse than that in practice. Transform labels are slugged with `split(":", 1)[0]`, so every `router:<strategy>:<detail>` label collapses into a single `router` bucket. On 2026-08-08 that bucket held **19.1 M of the day's transform counts, across 8,528 of 9,616 sessions** — the compressors that do most of the work are indistinguishable from each other, by name as well as by yield: | transform | n | sessions | |---|---|---| | `router` | 19,108,118 | 8,528 | | `anthropic` | 688,124 | 4,734 | | `output_shaper` | 548,017 | 1,120 | No question about which strategy is earning its keep can be answered from that, which is what this field is for. The measurement already existed. `PrometheusMetrics.record_compression` is the configured `CompressionObserver` and already accumulates `tokens_saved_by_strategy` on the hot path — the numbers just never left the process. This forwards from that one chokepoint rather than adding a second observer and a second measurement pass. The paths that have **no** observer configured (MCP server, LangGraph, Strands hooks, the transform pipeline) get `BeaconCompressionObserver` passed directly. Compression runs on the executor thread *before* that request's outcome reaches `record()`, so events are **staged** into module state and drained by the next outcome. Staging is what makes two things true at once: - The first turn of a session still reports its numbers — otherwise every session's opening turn, and any session short enough to be one turn, would report nothing. - A compression event **never opens a session**. An abandoned request would otherwise emit a phantom `turns=0` row with all-zero tokens, inflating fleet session and install counts. Staging takes a dedicated mutex the request path never touches, so the fan-out stays off the aggregator's lock and `record_compression` keeps its "synchronous + lock-free" contract. ```json "by_strategy": [ {"strategy": "code_aware", "n": 1, "tokens_in": 800, "tokens_out": 800}, {"strategy": "smart_crusher", "n": 2, "tokens_in": 1500, "tokens_out": 700} ] ``` A **list of records, sorted by strategy** — not an object keyed by strategy. Keyed shapes change type as keys accumulate: DuckDB infers a STRUCT under ~24 keys and a MAP over it, so the analysis query breaks on the day the fleet picks up a 25th strategy. Sorted so heartbeats are byte-comparable. **These do not sum to `tokens.saved`,** and the field comment says so: strategies compose (the router routes, a strategy runs inside it) so the same text is measured more than once. A row means "of what this strategy was handed, it removed this much" — a per-strategy yield, not a share of the total. A strategy that saved nothing still appears; dropping it would make every strategy look effective. ## 3. `headroom.stack` `resource_attributes()` was called with no arguments at its one call site, so `headroom.stack` was absent from **all 24,040 sessions** in the corpus while `detect_stack` sat unused — dead code on both ends of a wire nobody connected. The fleet was unsegmentable by agent, which is the question the corpus is asked most often. Environment detection alone is not enough. It answers `wrap_claude` only under `headroom wrap`; every install that points an agent at a persistent proxy — the common deployment — reports `proxy`, which segments nothing. The per-request `X-Headroom-Stack` slugs are the only signal that names the harness there, so `record_stack()` stages them the same way and feeds `detect_stack`'s `by_stack` branch: ``` 9x wrap_claude, 1x wrap_cursor -> wrap_claude (dominant harness wins) 5x wrap_claude, 5x wrap_cursor -> mixed no per-request signal -> proxy (environment fallback) junk slug -> dropped before staging ``` ## Privacy The strategy string is slugged through the same `_safe_slug` as skip reasons and capped at `MAX_STRATEGIES`, because the observer protocol takes a free string and an extension could otherwise invent keys per request. Stack slugs are normalized and capped the same way. **Deliberately not collected:** the tool names in `smart_crush:<count>:<names>`. Those are user-defined MCP identifiers and can name internal tooling (`acme_deploy_prod`). They stay stripped by the existing `split(":", 1)[0]`, and this PR does not widen it. No new key was needed in `worker.js` — `by_strategy` nests under the already-allowlisted `compression`, and `headroom.stack` was already in `ALLOWED_RESOURCE`. Nothing here deletes or rewrites existing data: the Worker only ever writes, `sessions/` is never pruned by it, and readers merge old and new shapes with `union_by_name`, so pre-change heartbeats keep reading with the new fields null. ## Verification - `python -m headroom.telemetry.session` — self-check covers staging, the no-phantom-session case, drain exhaustiveness, a 0%-yield strategy staying visible, the cardinality cap, slug safety, and dominant/mixed/junk stack resolution - `node test-rollup.mjs /tmp/hr` — 3,938 real corpus objects → 1,061 sessions in 1 object, plus the pagination, partial-read, corrupt-record, empty-hour and `oldestRawDay` cases - 51 passing in `test_compression_observability`, `test_prometheus_obs_counters`, `test_telemetry_context`, `test_compression_strategy_outcomes` - Consumer side exists and is checked: `beacon.sh by_strategy` in headroom-beacon-stats reads the field end to end (sessions, installs, invocations, tokens in/out, yield %), verified against a synthetic parquet for the cases that matter — aggregation across installs, a 0%-yield strategy staying visible, pre-field sessions dropping out rather than erroring. It is guarded on the column, so it prints an instruction instead of a binder error until a release carrying this PR reaches the fleet. - Deployed and running against the live corpus on the `5 * * * *` trigger 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:42:12 -07:00
// ----------------------------------------------------------------- rollup --
//
// The corpus is one object per heartbeat, ~1KB each — 65k on 2026-08-06 and
// climbing. DuckDB reads them correctly, but a full `pull` is ~100k HTTPS round
// trips for 95MB: minutes of pure per-object latency, no real bytes or compute.
// Listing the bucket alone took 88 seconds.
//
// This job collapses each COMPLETE hour into one object under rollup/, keeping
// only the highest-seq heartbeat per (install, session). One measured hour
// (dt=2026-08-06/hh=14): 3,938 objects and 3,938 rows in, 1 object and 1,061
// rows out. Analysis reads rollup/**, never sessions/**. Raw is left exactly as
// written, so any rollup can be rebuilt by deleting it.
//
// Hourly rather than daily because every R2 binding call is a subrequest: a day
// is ~65k of them against a 10k-per-invocation ceiling, an hour is ~4k.
const READ_BUDGET = 60000; // objects per run; see [limits] in wrangler.toml
// A get costs ~45ms of round trip and almost no CPU, so this is what decides
// whether a run finishes: at 20 an hour took ~3 minutes, against a 15-minute
// wall clock for a cron invocation. Raise it if an hour ever stops fitting.
const FANOUT = 100; // concurrent R2 gets
const partition = (d) =>
`dt=${d.toISOString().slice(0, 10)}/hh=${d.toISOString().slice(11, 13)}`;
/**
* One hour of heartbeats -> one deduped NDJSON object.
*
* Returns `{ read, wrote }`. Spend is reported through the mutable `spend`
* accumulator so the caller still knows it even when this throws: the budget
* has to track real spend, and a flat guess lets a run that failed late
* overshoot the subrequest ceiling and get killed inside an hour that would
* otherwise have succeeded.
*
* Writes nothing unless the whole hour read cleanly. A rollup is built once and
* then treated as done forever, so a partial read would silently become the
* permanent record better to write nothing and let the next run retry.
*/
export async function rollupHour(env, part, spend = { read: 0 }) {
const best = new Map();
let failed = 0; // transient: retry the hour
let corrupt = 0; // permanent: record and move on
let cursor;
do {
const page = await env.CORPUS.list({ prefix: `sessions/${part}/`, cursor });
for (let i = 0; i < page.objects.length; i += FANOUT) {
// allSettled, not all: one transient R2 error among the ~4,000 gets in a
// real hour would otherwise reject the batch and discard the whole hour.
const settled = await Promise.allSettled(
page.objects
.slice(i, i + FANOUT)
.map((o) => env.CORPUS.get(o.key).then((r) => (r ? r.text() : null)))
);
for (const outcome of settled) {
spend.read++;
// A miss counts as a failure too. The key came from a LIST, so the
// object existed; treating it as empty would quietly shrink the rollup.
if (outcome.status !== 'fulfilled' || outcome.value === null) {
failed++;
continue;
}
for (const line of outcome.value.split('\n')) {
if (!line) continue;
let rec;
try {
rec = JSON.parse(line);
} catch {
// Counted and logged, but NOT a reason to abandon the hour. A
// failed get is transient and worth retrying; content this Worker
// itself wrote with JSON.stringify does not become valid later, so
// blocking on it would strand the hour until its raw objects
// expire and then lose the whole hour instead of one record.
corrupt++;
continue;
}
// A session heartbeats every 5 minutes carrying CUMULATIVE totals, so
// the highest seq IS the whole session and every earlier row is a
// strict subset. Sessions straddle hours, so readers still dedupe
// across rollups on this same key — this only shrinks each hour.
const id = `${rec.resource?.['headroom.install_id']} ${rec.session?.id}`;
const prev = best.get(id);
if (!prev || (rec.session?.seq ?? 0) > (prev.session?.seq ?? 0)) {
best.set(id, rec);
}
}
}
}
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);
if (failed) {
throw new Error(`${part}: ${failed} of ${spend.read} objects unreadable`);
}
if (corrupt) {
console.error(`rollup ${part}: skipped ${corrupt} unparseable record(s)`);
}
// A genuinely empty hour gets a marker rather than a zero-byte NDJSON that
// every reader would have to special-case. Without it the hour stays
// "missing" and is re-listed on every run for the life of the bucket.
if (best.size === 0) {
await env.CORPUS.put(`rollup/${part}/empty`, '');
return { read: spend.read, wrote: 0 };
}
await env.CORPUS.put(
`rollup/${part}/data.ndjson`,
[...best.values()].map((r) => JSON.stringify(r)).join('\n'),
{ httpMetadata: { contentType: 'application/x-ndjson' } }
);
return { read: spend.read, wrote: best.size };
}
/** Oldest `dt=` day still under sessions/, or null. One delimited LIST. */
export async function oldestRawDay(env) {
const page = await env.CORPUS.list({ prefix: 'sessions/', delimiter: '/' });
const days = (page.delimitedPrefixes || [])
.map((p) => p.slice('sessions/dt='.length).replace(/\/$/, ''))
.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d))
.sort();
return days.length ? days[0] : null;
}
fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
export default {
feat(beacon): hourly R2 compaction, per-strategy savings, and a stack that reports (#2853) Three beacon changes bundled because they are one story: the corpus got too slow to query, and then too coarse to answer the question it was collected for. ## 1. Hourly compaction (`deploy/beacon`) The beacon writes one ~1 KB object per heartbeat — **64,987 on 2026-08-06** and climbing. A full analysis `pull` was ~100k HTTPS round trips for 95 MB: minutes of pure per-object latency. Listing the bucket alone took 88 seconds. Moving the query server-side does not help — R2 SQL reads only Iceberg tables, and a pile of tiny files is the pathological case for every query engine. Compaction is the fix, and it is Iceberg's own answer to the same problem. An hourly cron collapses each **complete** hour of `sessions/` into one `rollup/dt=…/hh=…/data.ndjson`, keeping the highest-`seq` heartbeat per `(install, session)`. | measured on `dt=2026-08-06/hh=14` | before | after | |---|---|---| | objects | 3,938 | **1** | | rows | 3,938 | **1,061** | | analysis `pull` | minutes | **seconds** | Hourly rather than daily because every R2 binding call is a subrequest: a day is ~65k, an hour is ~4k. Newest-first, so a backlog drains from the present backwards and live data never starves behind it; a failing hour is logged and skipped rather than blocking every older hour behind it. **Raw objects are never deleted**, so any rollup is rebuildable by deleting it. Backfill runs to the **oldest surviving raw day**, not a fixed window. A fixed lookback strands everything older than it the moment analysis stops reading `sessions/`: the raw objects are still there, but nothing would ever compact them, so they disappear from every report. `oldestRawDay()` finds that floor in one delimited LIST, and the rollup listing starts from it — so the work is bounded by retention rather than by total history. Three failure modes the tests pin down, because each one is silent: - A **failed `get`** is transient, so the hour throws and writes nothing. A rollup is built once and trusted forever, so a short read would quietly become the permanent record. - A **corrupt record** loses only itself. This Worker wrote that content with `JSON.stringify`; it will never become valid, so blocking on it strands the hour instead of the record. - An **empty hour** writes an `empty` marker. Without one the hour stays "missing" and is re-listed on every run forever. `test-rollup.mjs` asserts the Worker's dedup picks exactly the same rows as the analysis-side `QUALIFY`. If those two ever disagree the reports go quietly wrong rather than loudly broken, which is why that check exists. Its stub paginates at 3 keys so the list cursor loop — load-bearing at the real ~4,000 objects/hour — runs in every case. ## 2. Per-strategy savings (`compression.by_strategy`) `compression.transforms` counts *invocations*, which cannot distinguish a compressor that saved 60% from one that ran constantly and saved nothing. The fleet's top transform by count contributes an unknown share of `tokens.saved`. It is worse than that in practice. Transform labels are slugged with `split(":", 1)[0]`, so every `router:<strategy>:<detail>` label collapses into a single `router` bucket. On 2026-08-08 that bucket held **19.1 M of the day's transform counts, across 8,528 of 9,616 sessions** — the compressors that do most of the work are indistinguishable from each other, by name as well as by yield: | transform | n | sessions | |---|---|---| | `router` | 19,108,118 | 8,528 | | `anthropic` | 688,124 | 4,734 | | `output_shaper` | 548,017 | 1,120 | No question about which strategy is earning its keep can be answered from that, which is what this field is for. The measurement already existed. `PrometheusMetrics.record_compression` is the configured `CompressionObserver` and already accumulates `tokens_saved_by_strategy` on the hot path — the numbers just never left the process. This forwards from that one chokepoint rather than adding a second observer and a second measurement pass. The paths that have **no** observer configured (MCP server, LangGraph, Strands hooks, the transform pipeline) get `BeaconCompressionObserver` passed directly. Compression runs on the executor thread *before* that request's outcome reaches `record()`, so events are **staged** into module state and drained by the next outcome. Staging is what makes two things true at once: - The first turn of a session still reports its numbers — otherwise every session's opening turn, and any session short enough to be one turn, would report nothing. - A compression event **never opens a session**. An abandoned request would otherwise emit a phantom `turns=0` row with all-zero tokens, inflating fleet session and install counts. Staging takes a dedicated mutex the request path never touches, so the fan-out stays off the aggregator's lock and `record_compression` keeps its "synchronous + lock-free" contract. ```json "by_strategy": [ {"strategy": "code_aware", "n": 1, "tokens_in": 800, "tokens_out": 800}, {"strategy": "smart_crusher", "n": 2, "tokens_in": 1500, "tokens_out": 700} ] ``` A **list of records, sorted by strategy** — not an object keyed by strategy. Keyed shapes change type as keys accumulate: DuckDB infers a STRUCT under ~24 keys and a MAP over it, so the analysis query breaks on the day the fleet picks up a 25th strategy. Sorted so heartbeats are byte-comparable. **These do not sum to `tokens.saved`,** and the field comment says so: strategies compose (the router routes, a strategy runs inside it) so the same text is measured more than once. A row means "of what this strategy was handed, it removed this much" — a per-strategy yield, not a share of the total. A strategy that saved nothing still appears; dropping it would make every strategy look effective. ## 3. `headroom.stack` `resource_attributes()` was called with no arguments at its one call site, so `headroom.stack` was absent from **all 24,040 sessions** in the corpus while `detect_stack` sat unused — dead code on both ends of a wire nobody connected. The fleet was unsegmentable by agent, which is the question the corpus is asked most often. Environment detection alone is not enough. It answers `wrap_claude` only under `headroom wrap`; every install that points an agent at a persistent proxy — the common deployment — reports `proxy`, which segments nothing. The per-request `X-Headroom-Stack` slugs are the only signal that names the harness there, so `record_stack()` stages them the same way and feeds `detect_stack`'s `by_stack` branch: ``` 9x wrap_claude, 1x wrap_cursor -> wrap_claude (dominant harness wins) 5x wrap_claude, 5x wrap_cursor -> mixed no per-request signal -> proxy (environment fallback) junk slug -> dropped before staging ``` ## Privacy The strategy string is slugged through the same `_safe_slug` as skip reasons and capped at `MAX_STRATEGIES`, because the observer protocol takes a free string and an extension could otherwise invent keys per request. Stack slugs are normalized and capped the same way. **Deliberately not collected:** the tool names in `smart_crush:<count>:<names>`. Those are user-defined MCP identifiers and can name internal tooling (`acme_deploy_prod`). They stay stripped by the existing `split(":", 1)[0]`, and this PR does not widen it. No new key was needed in `worker.js` — `by_strategy` nests under the already-allowlisted `compression`, and `headroom.stack` was already in `ALLOWED_RESOURCE`. Nothing here deletes or rewrites existing data: the Worker only ever writes, `sessions/` is never pruned by it, and readers merge old and new shapes with `union_by_name`, so pre-change heartbeats keep reading with the new fields null. ## Verification - `python -m headroom.telemetry.session` — self-check covers staging, the no-phantom-session case, drain exhaustiveness, a 0%-yield strategy staying visible, the cardinality cap, slug safety, and dominant/mixed/junk stack resolution - `node test-rollup.mjs /tmp/hr` — 3,938 real corpus objects → 1,061 sessions in 1 object, plus the pagination, partial-read, corrupt-record, empty-hour and `oldestRawDay` cases - 51 passing in `test_compression_observability`, `test_prometheus_obs_counters`, `test_telemetry_context`, `test_compression_strategy_outcomes` - Consumer side exists and is checked: `beacon.sh by_strategy` in headroom-beacon-stats reads the field end to end (sessions, installs, invocations, tokens in/out, yield %), verified against a synthetic parquet for the cases that matter — aggregation across installs, a 0%-yield strategy staying visible, pre-field sessions dropping out rather than erroring. It is guarded on the column, so it prints an instruction instead of a binder error until a release carrying this PR reaches the fleet. - Deployed and running against the live corpus on the `5 * * * *` trigger 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:42:12 -07:00
/** Hourly cron. Builds every complete hour back to the oldest raw data. */
async scheduled(event, env) {
// Backfill reaches all the way to the oldest surviving raw day, NOT a fixed
// window. A fixed window silently strands everything older than it the
// moment analysis stopped reading sessions/ — the raw objects are still
// there, but nothing would ever compact them, so they vanish from every
// report. Bounding by real data instead means the floor rises only when a
// lifecycle rule actually expires the raw objects.
const oldest = await oldestRawDay(env);
if (!oldest) return;
const floorMs = Date.parse(`${oldest}T00:00:00Z`);
if (Number.isNaN(floorMs)) return;
// Only list from the floor forward. Rollups older than the oldest raw day
// can never be rebuilt, so enumerating them answers nothing — this is what
// keeps the listing bounded by retention rather than by total history.
const done = new Set();
let cursor;
do {
const page = await env.CORPUS.list({
prefix: 'rollup/',
startAfter: `rollup/dt=${oldest}`,
cursor,
});
for (const o of page.objects) {
// Tolerates both `<part>/data.ndjson` and the `<part>/empty` marker.
const rel = o.key.slice('rollup/'.length);
const cut = rel.lastIndexOf('/');
if (cut > 0) done.add(rel.slice(0, cut));
}
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);
// Newest first, so a backlog drains from the present backwards and the
// freshest hour is never the one starved by the budget. Starts one hour
// back: the current hour is still being written to.
let budget = READ_BUDGET;
for (let t = event.scheduledTime - 3600_000; t >= floorMs && budget > 0; t -= 3600_000) {
const part = partition(new Date(t));
if (done.has(part)) continue;
// Shared with rollupHour so a throw still reports what it spent.
const spend = { read: 0 };
try {
await rollupHour(env, part, spend);
} catch (err) {
// Newest-first means an hour that always throws — one grown past the
// subrequest ceiling, say — would otherwise block every older hour
// behind it forever. Skip it and keep draining; it has no marker, so
// the next run retries it.
console.error(`rollup ${part} failed after ${spend.read} objects: ${err}`);
}
budget -= spend.read;
}
},
fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('beacon: POST OTLP logs to /v1/logs', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/v1/logs') {
return new Response('not found', { status: 404 });
}
const raw = await request.arrayBuffer();
if (raw.byteLength > MAX_BODY_BYTES) {
return new Response('payload too large', { status: 413 });
}
let records;
try {
records = extract(JSON.parse(new TextDecoder().decode(raw)));
} catch {
// Malformed input is not worth a retry storm from clients.
return new Response('bad request', { status: 400 });
}
if (records.length === 0) return new Response(null, { status: 204 });
// Hive-style partitioning so DuckDB can prune by date without a catalog.
feat(beacon): hourly R2 compaction, per-strategy savings, and a stack that reports (#2853) Three beacon changes bundled because they are one story: the corpus got too slow to query, and then too coarse to answer the question it was collected for. ## 1. Hourly compaction (`deploy/beacon`) The beacon writes one ~1 KB object per heartbeat — **64,987 on 2026-08-06** and climbing. A full analysis `pull` was ~100k HTTPS round trips for 95 MB: minutes of pure per-object latency. Listing the bucket alone took 88 seconds. Moving the query server-side does not help — R2 SQL reads only Iceberg tables, and a pile of tiny files is the pathological case for every query engine. Compaction is the fix, and it is Iceberg's own answer to the same problem. An hourly cron collapses each **complete** hour of `sessions/` into one `rollup/dt=…/hh=…/data.ndjson`, keeping the highest-`seq` heartbeat per `(install, session)`. | measured on `dt=2026-08-06/hh=14` | before | after | |---|---|---| | objects | 3,938 | **1** | | rows | 3,938 | **1,061** | | analysis `pull` | minutes | **seconds** | Hourly rather than daily because every R2 binding call is a subrequest: a day is ~65k, an hour is ~4k. Newest-first, so a backlog drains from the present backwards and live data never starves behind it; a failing hour is logged and skipped rather than blocking every older hour behind it. **Raw objects are never deleted**, so any rollup is rebuildable by deleting it. Backfill runs to the **oldest surviving raw day**, not a fixed window. A fixed lookback strands everything older than it the moment analysis stops reading `sessions/`: the raw objects are still there, but nothing would ever compact them, so they disappear from every report. `oldestRawDay()` finds that floor in one delimited LIST, and the rollup listing starts from it — so the work is bounded by retention rather than by total history. Three failure modes the tests pin down, because each one is silent: - A **failed `get`** is transient, so the hour throws and writes nothing. A rollup is built once and trusted forever, so a short read would quietly become the permanent record. - A **corrupt record** loses only itself. This Worker wrote that content with `JSON.stringify`; it will never become valid, so blocking on it strands the hour instead of the record. - An **empty hour** writes an `empty` marker. Without one the hour stays "missing" and is re-listed on every run forever. `test-rollup.mjs` asserts the Worker's dedup picks exactly the same rows as the analysis-side `QUALIFY`. If those two ever disagree the reports go quietly wrong rather than loudly broken, which is why that check exists. Its stub paginates at 3 keys so the list cursor loop — load-bearing at the real ~4,000 objects/hour — runs in every case. ## 2. Per-strategy savings (`compression.by_strategy`) `compression.transforms` counts *invocations*, which cannot distinguish a compressor that saved 60% from one that ran constantly and saved nothing. The fleet's top transform by count contributes an unknown share of `tokens.saved`. It is worse than that in practice. Transform labels are slugged with `split(":", 1)[0]`, so every `router:<strategy>:<detail>` label collapses into a single `router` bucket. On 2026-08-08 that bucket held **19.1 M of the day's transform counts, across 8,528 of 9,616 sessions** — the compressors that do most of the work are indistinguishable from each other, by name as well as by yield: | transform | n | sessions | |---|---|---| | `router` | 19,108,118 | 8,528 | | `anthropic` | 688,124 | 4,734 | | `output_shaper` | 548,017 | 1,120 | No question about which strategy is earning its keep can be answered from that, which is what this field is for. The measurement already existed. `PrometheusMetrics.record_compression` is the configured `CompressionObserver` and already accumulates `tokens_saved_by_strategy` on the hot path — the numbers just never left the process. This forwards from that one chokepoint rather than adding a second observer and a second measurement pass. The paths that have **no** observer configured (MCP server, LangGraph, Strands hooks, the transform pipeline) get `BeaconCompressionObserver` passed directly. Compression runs on the executor thread *before* that request's outcome reaches `record()`, so events are **staged** into module state and drained by the next outcome. Staging is what makes two things true at once: - The first turn of a session still reports its numbers — otherwise every session's opening turn, and any session short enough to be one turn, would report nothing. - A compression event **never opens a session**. An abandoned request would otherwise emit a phantom `turns=0` row with all-zero tokens, inflating fleet session and install counts. Staging takes a dedicated mutex the request path never touches, so the fan-out stays off the aggregator's lock and `record_compression` keeps its "synchronous + lock-free" contract. ```json "by_strategy": [ {"strategy": "code_aware", "n": 1, "tokens_in": 800, "tokens_out": 800}, {"strategy": "smart_crusher", "n": 2, "tokens_in": 1500, "tokens_out": 700} ] ``` A **list of records, sorted by strategy** — not an object keyed by strategy. Keyed shapes change type as keys accumulate: DuckDB infers a STRUCT under ~24 keys and a MAP over it, so the analysis query breaks on the day the fleet picks up a 25th strategy. Sorted so heartbeats are byte-comparable. **These do not sum to `tokens.saved`,** and the field comment says so: strategies compose (the router routes, a strategy runs inside it) so the same text is measured more than once. A row means "of what this strategy was handed, it removed this much" — a per-strategy yield, not a share of the total. A strategy that saved nothing still appears; dropping it would make every strategy look effective. ## 3. `headroom.stack` `resource_attributes()` was called with no arguments at its one call site, so `headroom.stack` was absent from **all 24,040 sessions** in the corpus while `detect_stack` sat unused — dead code on both ends of a wire nobody connected. The fleet was unsegmentable by agent, which is the question the corpus is asked most often. Environment detection alone is not enough. It answers `wrap_claude` only under `headroom wrap`; every install that points an agent at a persistent proxy — the common deployment — reports `proxy`, which segments nothing. The per-request `X-Headroom-Stack` slugs are the only signal that names the harness there, so `record_stack()` stages them the same way and feeds `detect_stack`'s `by_stack` branch: ``` 9x wrap_claude, 1x wrap_cursor -> wrap_claude (dominant harness wins) 5x wrap_claude, 5x wrap_cursor -> mixed no per-request signal -> proxy (environment fallback) junk slug -> dropped before staging ``` ## Privacy The strategy string is slugged through the same `_safe_slug` as skip reasons and capped at `MAX_STRATEGIES`, because the observer protocol takes a free string and an extension could otherwise invent keys per request. Stack slugs are normalized and capped the same way. **Deliberately not collected:** the tool names in `smart_crush:<count>:<names>`. Those are user-defined MCP identifiers and can name internal tooling (`acme_deploy_prod`). They stay stripped by the existing `split(":", 1)[0]`, and this PR does not widen it. No new key was needed in `worker.js` — `by_strategy` nests under the already-allowlisted `compression`, and `headroom.stack` was already in `ALLOWED_RESOURCE`. Nothing here deletes or rewrites existing data: the Worker only ever writes, `sessions/` is never pruned by it, and readers merge old and new shapes with `union_by_name`, so pre-change heartbeats keep reading with the new fields null. ## Verification - `python -m headroom.telemetry.session` — self-check covers staging, the no-phantom-session case, drain exhaustiveness, a 0%-yield strategy staying visible, the cardinality cap, slug safety, and dominant/mixed/junk stack resolution - `node test-rollup.mjs /tmp/hr` — 3,938 real corpus objects → 1,061 sessions in 1 object, plus the pagination, partial-read, corrupt-record, empty-hour and `oldestRawDay` cases - 51 passing in `test_compression_observability`, `test_prometheus_obs_counters`, `test_telemetry_context`, `test_compression_strategy_outcomes` - Consumer side exists and is checked: `beacon.sh by_strategy` in headroom-beacon-stats reads the field end to end (sessions, installs, invocations, tokens in/out, yield %), verified against a synthetic parquet for the cases that matter — aggregation across installs, a 0%-yield strategy staying visible, pre-field sessions dropping out rather than erroring. It is guarded on the column, so it prints an instruction instead of a binder error until a release carrying this PR reaches the fleet. - Deployed and running against the live corpus on the `5 * * * *` trigger 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:42:12 -07:00
// Shares partition() with the rollup: the cron lists `sessions/<part>/`, so
// two independent spellings of this scheme would mean the writer and the
// compactor could drift apart and silently match zero objects.
// ponytail: one object per request. Compacted hourly into rollup/ by
// scheduled() above — analysis reads that, never this.
const key = `sessions/${partition(new Date())}/${crypto.randomUUID()}.json`;
fix(telemetry): anonymous compression stats — no prompts, no data (#2728) ## In one line Headroom starts reporting **how well compression is working** — counters and percentages only. **No prompts. No code. No file paths. Nothing about what you're building.** ## Why Right now nobody knows whether compression actually helps real users. You can see your own numbers in `/stats`, but that's it — there's no way to tell whether a given workload compresses well, or why it sometimes doesn't. This closes that loop so we can make compression better for everyone. ## Exactly what gets sent One message per session, and every 5 minutes while you're active: ```json { "session": { "id": "random", "turns": 47, "duration_s": 4210, "seq": 3 }, "tokens": { "original": 890000, "attempted": 410000, "saved": 320000, "tool_saved": 48000, "cache_read": 210000 }, "rates": { "saved_pct": 35.96, "eligible_pct": 46.07, "yield_pct": 78.05, "cache_read_pct": 23.60, "overhead_pct": 1.96 }, "compression": { "transforms": {"crush": 47}, "passthrough_turns": 0 }, "skips": {}, "sources": { "proxy": 47 }, "providers": ["anthropic"], "models": ["claude-sonnet-4-5-20250929"], "failures": 2 } ``` Plus a random install ID, the Headroom version, and OS/architecture (`darwin`, `arm64`). That's the whole thing. A full example lives at `deploy/beacon/sample-event.json`. ## What is never sent - Your prompts or the model's responses - Your code - File paths, project names, repo names - Tool names or MCP server names - Hostname, username, or IP address - Custom or fine-tuned model names (an id like `ft:gpt-4o:acme-corp:…` contains a company name, so only models in a public registry are reported) **This is structural, not a pinky-swear.** Every value in the payload is a number, a fixed word, or a random ID — there is no free-text field anywhere for content to hide in. The receiver (`deploy/beacon/worker.js`, in this repo so you can read it) drops anything not on an explicit allowlist before storing. ## Turning it off Any one of these: ```bash HEADROOM_BEACON=off # or DO_NOT_TRACK=1 # or # offline mode ``` It's on by default, and Headroom says so at startup: ``` Telemetry: anonymous compression stats — never prompts, code, or file paths. Helps us improve compression | Off: HEADROOM_BEACON=off ``` `HEADROOM_TELEMETRY` is a **separate** switch that still only affects local stats. If you had turned that on, this change does not start uploading anything — you answered a different question, and upgrading should not change the answer. ## Why the percentages, not just "tokens saved" "We saved 36%" hides the interesting part. In the example above only **46% of tokens were eligible** for compression at all — the rest is frozen cache prefix and system prompts we deliberately do not touch. Of what we *could* touch, we removed **78%**. Those are two separate problems. Raising eligibility is proxy work; raising yield is compressor work. A single number cannot tell us which to fix. ## Coverage `emit_request_outcome` is a single chokepoint — `handler.metrics.record_request` is called from exactly one place, inside the funnel — so all 30 `RequestOutcome` construction sites are covered: Anthropic, OpenAI, Gemini, Bedrock, batch, streaming, and the long-lived Codex Responses-WS path. The `headroom_compress` MCP path bypassed that funnel and is now wired in separately. It has a different shape (no provider, no upstream latency, and everything handed to the tool is eligible by construction), so `sources` counts turns by origin — MCP turns always read `eligible_pct: 100` and must not drag the proxy's real eligibility ceiling upward. **Subagents.** All subagent traffic through the proxy merges into one session, which is correct for savings and retention but means `turns` conflates fan-out with depth. Fan-out is still derivable — `compression.latency_ms_total / session.duration_s` gives the concurrency ratio (~1x serial, ~4x for four parallel agents), so no extra field is needed. Verified no lost updates under 6-way concurrency (1,200 turns). **Known gap:** `--workers N` gives each process its own aggregator, so one user session becomes up to N. Token totals and fleet rates stay correct; session counts inflate. This matches the existing documented limitation that TOIN state, CostTracker, and the prefix tracker are all per-process. ## Notes for reviewers - **Cumulative snapshots, not deltas.** Every report restates running totals under one session ID, so the highest `seq` per `(install, session)` is the complete session. Dedupe is a window function, and a lost report costs nothing. - **Never breaks the proxy.** Every path swallows its own exceptions; uploads go out on a daemon thread so nothing blocks the request loop. - **Explicit User-Agent is load-bearing.** urllib's default is blocked by Cloudflare (error 1010). Combined with fire-and-forget error handling, that would have failed every upload while looking perfectly healthy. - **The exit flush was broken and is fixed.** `atexit` handed the POST to a daemon thread, and daemon threads are killed before they finish during interpreter shutdown — so nothing was sent. That silently dropped *every session shorter than the 5-minute heartbeat*, plus all short-lived subagent MCP processes. The exit path now posts synchronously with a 2s timeout. - Receiver and query tooling are in `deploy/beacon/`. ## Testing - `python -m headroom.telemetry.session` self-check: dedupe, cumulative totals, dropped-report recovery, payload contains no model id or prompt-derived string, allowlist coverage - 175 telemetry/outcome tests pass; 6 new ones cover the opt-out notice - Verified end to end against a live deployment: client → receiver → storage → query ## Still to do before release The default endpoint currently points at a temporary `workers.dev` URL. It needs to move to a Headroom-owned hostname before this ships in a tagged release — noted inline at `DEFAULT_ENDPOINT`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:43:25 -07:00
const ndjson = records.map((r) => JSON.stringify(r)).join('\n');
// Respond immediately; durability work continues after the response.
// The client is fire-and-forget and ignores the status anyway — making it
// wait on R2 would only add latency to someone else's coding session.
ctx.waitUntil(
env.CORPUS.put(key, ndjson, {
httpMetadata: { contentType: 'application/x-ndjson' },
})
);
// Optional second lane: forward verbatim OTLP to a metrics backend for
// dashboards. Configured by secret, so it can be added or swapped with a
// `wrangler secret put` and no code change.
if (env.METRICS_OTLP_URL) {
ctx.waitUntil(
fetch(env.METRICS_OTLP_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: env.METRICS_OTLP_AUTH || '',
},
body: JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: records.map((r) => ({ body: { stringValue: JSON.stringify(r) } })) }] }] }),
}).catch(() => {})
);
}
return new Response(null, { status: 204 });
},
};