Просмотр исходного кода

feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10)

Adds a scheduled() handler to the ingest worker that recomputes
daily_event_counts / daily_dim_counts / daily_machines for the just-completed
UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw
events past the retention window. Rollup writes are idempotent upserts, so a
re-run never double-counts. Also adds an ADMIN_TOKEN-guarded
POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog
forwarding path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
71a402fe6a

+ 5 - 4
telemetry-worker/.dev.vars.example

@@ -1,4 +1,5 @@
-# Copy to .dev.vars for local development (`npm run dev`) and so that
-# `wrangler types` includes POSTHOG_KEY in the generated Env.
-# The real key lives only in the deployed secret (`wrangler secret put POSTHOG_KEY`).
-POSTHOG_KEY="phc_dev_placeholder"
+# Copy to .dev.vars for local development (`npm run dev`) if you want to exercise
+# POST /admin/rollup — without it that route 404s, which is also how a deploy that
+# never ran `wrangler secret put ADMIN_TOKEN` behaves.
+# The real token lives only in the deployed secret; nothing here is ever committed.
+ADMIN_TOKEN="dev_admin_token_placeholder"

+ 2 - 1
telemetry-worker/.gitignore

@@ -1,5 +1,6 @@
 node_modules/
 .wrangler/
+# local secrets (ADMIN_TOKEN) — see .dev.vars.example
 .dev.vars
-# generated by `wrangler types` (npm run types) — includes .dev.vars keys
+# generated by `wrangler types` (npm run types)
 worker-configuration.d.ts

+ 80 - 18
telemetry-worker/README.md

@@ -7,9 +7,12 @@ field, and everything that is never collected) is in
 [`docs/design/telemetry.md`](../docs/design/telemetry.md).
 
 What it does, in one breath: validates incoming batches against a strict allowlist (unknown
-events dropped, unknown properties stripped), never reads or forwards the client IP,
-rate-limits per machine ID, and forwards to PostHog off the response path. It ships nowhere
-with the npm package — the engine's `files` allowlist excludes it.
+events dropped, unknown properties stripped), never reads or stores the client IP,
+rate-limits per machine ID, and writes the survivors to our own D1 database off the response
+path. A nightly cron rolls each finished day up into anonymous daily counts and deletes the
+raw rows behind it. It makes no outbound requests — nothing is forwarded to a third-party
+analytics vendor. It ships nowhere with the npm package — the engine's `files` allowlist
+excludes it.
 
 ## Endpoint contract
 
@@ -19,11 +22,20 @@ with the npm package — the engine's `files` allowlist excludes it.
   for malformed/oversized/rate-limited requests. Clients treat every response as final —
   no retries.
 - `GET /` — plain-text pointer to the docs and the off-switches.
+- `POST /admin/rollup` — manual rollup trigger, see below. `404` unless `ADMIN_TOKEN` is set.
 
 ## Storage (Cloudflare D1)
 
 Telemetry is stored in the `codegraph-telemetry` D1 database on the same account, bound as
-`env.DB`. The complete schema is [`migrations/0001_init.sql`](migrations/0001_init.sql) —
+`env.DB` — this database is the only place accepted events go. Each request's surviving
+events are written in a single `batch()` (one implicit transaction) under `ctx.waitUntil`,
+so the write is off the response path. It is deliberately **fail-silent**: a D1 error is
+logged to Workers Logs (counts only, never the payload) and the client still gets its `204`,
+because clients never retry — losing a datapoint beats losing availability. Alongside the
+raw rows, the worker upserts `machine_days` and `machine_first_seen`; when a batch is emptied
+by the allowlist, nothing at all is written, so those tables only ever describe stored events.
+
+The complete schema is [`migrations/0001_init.sql`](migrations/0001_init.sql) —
 checked in for the same reason this worker's source is public: it is the entire list of what
 gets kept, with a comment on every column and on which dashboard chart each rollup table
 serves. Shape: raw sanitized `events`, `daily_*` rollups recomputed nightly, and
@@ -42,12 +54,49 @@ new numbered file (`npx wrangler d1 migrations create codegraph-telemetry <name>
 edit to a migration that has been applied.
 
 Volume, at ~97k accepted POSTs/day: ≈30M D1 row writes/month against the 50M included on
-Workers Paid. D1 bills a row write per index touched on top of the table row, which is why
-`events` carries only two indexes. Storage is the tighter constraint — raw events grow
-≈74 MB/day, so a 90-day retention window lands at ≈6.7 GB against D1's 10 GB per-database
-cap, while 180 days would exceed it. Rollups are tiny and kept forever, so shortening the raw
-window costs drill-back, never a chart. Full arithmetic and the levers are in the migration's
-footer comment.
+Workers Paid, plus roughly as much again once the purge reaches steady state — a delete bills
+like an insert, and at steady state every row written is eventually deleted, so budget ≈48M.
+D1 bills a row write per index touched on top of the table row, which is why `events` carries
+only two indexes; dropping `events_machine_day` is the first lever if that gets tight. Storage
+is the other constraint, and it is what sets the window: raw events grow ≈74 MB/day, so 90 days
+lands at ≈6.7 GB against D1's 10 GB per-database cap, while 180 days would exceed it. Full
+arithmetic and the remaining levers are in the migration's footer comment.
+
+## Rollups & retention (nightly cron)
+
+`src/rollup.ts` runs on a Cron Trigger at **00:30 UTC** and does two things.
+
+**Rolls up** the day that just ended into `daily_machines`, `daily_event_counts` and
+`daily_dim_counts`, then re-runs the two days before it — offline clients ship completed-day
+rollups late, so a day keeps growing after it ends. The aggregation is one
+`INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension, so it happens inside D1 and
+no event row crosses the wire. Every write overwrites the recomputed value rather than adding
+to it: **re-running a day is a no-op, never a double count.** Two things the SQL is careful
+about — a `usage_rollup` row is a counter the client pre-aggregated, so its `count` prop is
+summed rather than the rows counted; and `index.languages` / `install.targets` are unnested
+with `json_each`, one row per element. Adding a breakdown is a line in `ROLLUP_STATEMENTS`,
+never a migration — that is what the generic `(dim, value)` shape buys.
+
+**Purges** raw `events` older than `RETENTION_DAYS` (90, a var in `wrangler.jsonc`) in bounded
+`DELETE` batches, and logs one line of counts. `machine_days` and `machine_first_seen` are
+never purged — retention cohorts need the full history and they are two orders of magnitude
+smaller. Rollups are kept forever, so shortening the window costs ad-hoc drill-back, never a
+chart.
+
+Backfill or repair without a redeploy, guarded by the `ADMIN_TOKEN` secret:
+
+```bash
+curl -X POST -H "x-admin-token: $ADMIN_TOKEN" \
+  'https://telemetry.getcodegraph.com/admin/rollup?day=2026-07-27'          # one day
+curl -X POST -H "x-admin-token: $ADMIN_TOKEN" \
+  'https://telemetry.getcodegraph.com/admin/rollup?day=2026-07-27&days=14'  # the 14 days ending there
+```
+
+`&reset=1` drops the day's rollup rows before recomputing, for when the dimension list itself
+changed and a value that no longer exists would otherwise linger. It is ignored past the
+retention window, where it would delete rows and then find no events to rebuild them from —
+the response says which days it refused. Keep manual ranges to a few days at production volume;
+each day is a full scan of that day's events, and the request has a wall-clock budget.
 
 ## Deploy
 
@@ -57,21 +106,26 @@ domain route auto-provisions DNS + cert), wrangler ≥ 4.36 (the `ratelimits` bi
 ```bash
 cd telemetry-worker
 npm install
-npx wrangler login                      # once
-npx wrangler secret put POSTHOG_KEY     # the phc_… project write key — never committed
-npm run db:migrate                      # bring the D1 schema up to date first
+npx wrangler login     # once
+npm run db:migrate     # bring the D1 schema up to date FIRST — the worker writes on deploy
 npm run deploy
+npx wrangler secret put ADMIN_TOKEN   # optional, see below
 ```
 
-The PostHog project itself must have **"Discard client IP data"** enabled — defense in
-depth on top of this worker never forwarding IPs (`$geoip_disable` is also set per event).
+The worker holds no API keys — it talks to nothing but its own bound D1 database. The one
+secret is `ADMIN_TOKEN`, which enables `POST /admin/rollup`; leave it unset and that route
+does not exist. Generate one with `openssl rand -hex 32`, and note that rotating it takes
+effect on the next request.
 
 ## Local dev & checks
 
 ```bash
-cp .dev.vars.example .dev.vars   # placeholder key; also feeds `wrangler types`
-npm run check                    # wrangler types + tsc --noEmit + deploy --dry-run
-npm run dev                      # http://localhost:8787
+npm run check                # wrangler types + tsc --noEmit + deploy --dry-run
+npm run db:migrate:local     # once, so `wrangler dev` has tables to write to
+npm run dev                  # http://localhost:8787 (local D1 in .wrangler/)
+npm run smoke                # end-to-end: boots `wrangler dev`, POSTs, asserts stored rows
+npm run smoke:rollup         # end-to-end: seeds synthetic days, rolls them up, purges,
+                             # asserts every number against hand-computed values
 
 curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
   "machine_id": "00000000-0000-4000-8000-000000000000",
@@ -81,8 +135,16 @@ curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
                "props": { "kind": "mcp_tool", "name": "codegraph_explore",
                           "count": 12, "error_count": 0, "client_name": "Claude Code" } }]
 }'
+
+npx wrangler d1 execute codegraph-telemetry --local \
+  --command "select day, event, machine_id, props from events order by id desc limit 5"
 ```
 
+To drive the cron body by hand, run `wrangler dev --test-scheduled` and hit
+`localhost:8787/__scheduled?cron=30+0+*+*+*`. For `POST /admin/rollup` locally, copy
+`.dev.vars.example` to `.dev.vars` — without an `ADMIN_TOKEN` the route 404s, exactly as a
+deploy that never set the secret does.
+
 ## Changing the schema
 
 The allowlist in `src/index.ts` mirrors `docs/design/telemetry.md` (and the user-facing

+ 2 - 0
telemetry-worker/package.json

@@ -7,6 +7,8 @@
     "deploy": "wrangler deploy",
     "types": "wrangler types",
     "check": "wrangler types && tsc --noEmit && wrangler deploy --dry-run",
+    "smoke": "./scripts/smoke-ingest.sh",
+    "smoke:rollup": "./scripts/smoke-rollup.sh",
     "db:migrate:local": "wrangler d1 migrations apply codegraph-telemetry --local",
     "db:migrate": "wrangler d1 migrations apply codegraph-telemetry --remote",
     "db:migrations": "wrangler d1 migrations list codegraph-telemetry --remote",

+ 187 - 0
telemetry-worker/scripts/smoke-ingest.sh

@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+# End-to-end check of the ingest contract against a real `wrangler dev` + local D1.
+#
+# Boots the worker, POSTs a spread of good and bad batches, then shuts the worker
+# down and inspects the rows that actually landed. Every request uses a fresh
+# machine_id, so the script is re-runnable against a dirty local database and never
+# trips the per-machine rate limit.
+#
+#   npm run db:migrate:local   # once
+#   npm run smoke              # or: INGEST_PORT=8791 ./scripts/smoke-ingest.sh
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+PORT="${INGEST_PORT:-8787}"
+BASE="http://127.0.0.1:$PORT"
+DB=codegraph-telemetry
+
+pass=0; fail=0
+ok()   { pass=$((pass + 1)); printf '  ok   %s\n' "$1"; }
+bad()  { fail=$((fail + 1)); printf '  FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
+is()   { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
+
+uuid() { node -e 'console.log(crypto.randomUUID())'; }
+
+# HTTP status of a POST /v1/events with the given body.
+post() { curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
+           -H 'content-type: application/json' --data-binary "$1"; }
+
+# First column of the first row of a query against the LOCAL D1 state.
+q() {
+  npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
+    node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
+      const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
+      console.log(r===undefined?"":String(Object.values(r)[0]));})'
+}
+
+# ---------------------------------------------------------------------------
+# Boot
+# ---------------------------------------------------------------------------
+echo "booting wrangler dev on :$PORT"
+npx wrangler dev --port "$PORT" >/tmp/cg-smoke-ingest.log 2>&1 &
+DEV_PID=$!
+cleanup() { kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true; }
+trap cleanup EXIT
+
+for _ in $(seq 1 60); do
+  curl -sf -o /dev/null "$BASE/" && break
+  kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
+  sleep 1
+done
+curl -sf -o /dev/null "$BASE/" || { echo "worker never came up:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
+
+# ---------------------------------------------------------------------------
+# Request contract
+# ---------------------------------------------------------------------------
+echo
+echo "request contract"
+
+INFO=$(curl -s "$BASE/")
+case "$INFO" in *"codegraph anonymous-telemetry ingest"*) ok "GET / serves the info text";;
+  *) bad "GET / serves the info text" "info text" "$INFO";; esac
+case "$INFO" in *"never forwarded to any third-party analytics"*) ok "info text states the storage guarantee";;
+  *) bad "info text states the storage guarantee" "the no-third-party sentence" "missing";; esac
+# The guarantee above holds only while the worker makes no outbound request at all,
+# so the only `fetch(` anywhere in the source may be the handler's own declaration.
+is "worker source makes no outbound fetch" 0 \
+   "$(grep -E 'fetch\(' src/*.ts | grep -vc 'async fetch(request' || true)"
+
+is "unknown path → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/nope")"
+is "GET /v1/events → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/events")"
+is "non-JSON body → 400" 400 "$(post 'not json')"
+is "JSON array body → 400" 400 "$(post '[]')"
+is "missing machine_id → 400" 400 "$(post '{"events":[]}')"
+is "malformed machine_id → 400" 400 "$(post '{"machine_id":"nope","events":[]}')"
+is "chunked (no length) → 411" 411 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
+     -H 'content-type: application/json' -H 'transfer-encoding: chunked' --data-binary '{"machine_id":"x"}')"
+BIG=$(node -e 'process.stdout.write(JSON.stringify({machine_id:"00000000-0000-4000-8000-000000000000",pad:"x".repeat(70000),events:[]}))')
+is "oversized body → 413" 413 "$(post "$BIG")"
+
+# ---------------------------------------------------------------------------
+# Accepted batches
+# ---------------------------------------------------------------------------
+echo
+echo "ingest"
+
+M_OK=$(uuid); M_DROP=$(uuid); M_CI=$(uuid); M_BACK=$(uuid)
+TODAY=$(date -u +%F)
+
+# Three valid events + one unknown event + unknown/malformed props that must be stripped.
+is "valid batch → 204" 204 "$(post "$(node -e '
+const [m] = process.argv.slice(1);
+process.stdout.write(JSON.stringify({
+  machine_id: m, codegraph_version: "1.5.0", os: "darwin", arch: "arm64",
+  node_major: 22, ci: false, schema_version: 1, secret_field: "must not be stored",
+  events: [
+    { event: "install", ts: "2026-07-27T10:00:00Z",
+      props: { scope: "local", kind: "fresh", targets: ["claude", "cursor"], nope: "strip me" } },
+    { event: "index", ts: "2026-07-27T10:01:00Z",
+      props: { languages: ["typescript"], file_count_bucket: "100-1k",
+               duration_bucket: "bogus-bucket", repo_path: "/Users/someone/secret" } },
+    { event: "usage_rollup",
+      props: { kind: "mcp_tool", name: "codegraph_explore", count: 12, client_name: "Claude Code" } },
+    { event: "not_an_event", props: { count: 1 } },
+  ],
+}));' "$M_OK")")"
+
+# Nothing survives the allowlist: unknown event + usage_rollup missing required props.
+is "all-dropped batch → 204" 204 "$(post "$(node -e '
+const [m] = process.argv.slice(1);
+process.stdout.write(JSON.stringify({ machine_id: m, os: "linux", events: [
+  { event: "made_up" },
+  { event: "usage_rollup", props: { kind: "mcp_tool" } },
+  { event: "install", props: { scope: "local" } },
+]}));' "$M_DROP")")"
+
+# NOTE: build every body into a variable first. Escaped quotes nested inside
+# "$(post "…\"…\"…")" break out of the quoting context and get brace-expanded.
+index_batch() {  # <machine_id> [ci] [ts]
+  node -e 'const [m, ci, ts] = process.argv.slice(1);
+    const e = { event: "index", props: {} };
+    if (ts) e.ts = ts;
+    const b = { machine_id: m, os: "linux", events: [e] };
+    if (ci) b.ci = ci === "true";
+    process.stdout.write(JSON.stringify(b));' "$@"
+}
+
+# ci = true, then a non-CI batch for the same machine/day: prod must flip 0 → 1.
+CI_ON=$(index_batch "$M_CI" true); CI_OFF=$(index_batch "$M_CI" false)
+is "ci batch → 204" 204 "$(post "$CI_ON")"
+is "same machine, non-ci → 204" 204 "$(post "$CI_OFF")"
+
+# A late offline buffer arriving second must move first_day EARLIER, never later.
+RECENT=$(index_batch "$M_BACK" "" 2026-07-27T09:00:00Z)
+BACKDATED=$(index_batch "$M_BACK" "" 2026-07-20T09:00:00Z)
+is "recent batch → 204" 204 "$(post "$RECENT")"
+is "backdated batch → 204" 204 "$(post "$BACKDATED")"
+
+sleep 2          # let the ctx.waitUntil writes drain
+cleanup; trap - EXIT
+sleep 1          # and let miniflare release the local sqlite file
+
+# ---------------------------------------------------------------------------
+# What actually got stored
+# ---------------------------------------------------------------------------
+echo
+echo "stored rows"
+
+is "3 of 4 events stored (unknown dropped)" 3 "$(q "select count(*) from events where machine_id='$M_OK'")"
+is "all-dropped batch stored nothing" 0 "$(q "select count(*) from events where machine_id='$M_DROP'")"
+is "…and no machine_days row for it" 0 "$(q "select count(*) from machine_days where machine_id='$M_DROP'")"
+is "envelope columns land in their own columns" "darwin|arm64|22|0|1.5.0" \
+   "$(q "select os||'|'||arch||'|'||node_major||'|'||ci||'|'||codegraph_version from events where machine_id='$M_OK' limit 1")"
+is "day derived from the client ts" "2026-07-27" \
+   "$(q "select day from events where machine_id='$M_OK' and event='install'")"
+is "day falls back to received_at when ts is absent" "$TODAY" \
+   "$(q "select day from events where machine_id='$M_OK' and event='usage_rollup'")"
+is "ts is NULL when the client sent none" 1 \
+   "$(q "select ts is null from events where machine_id='$M_OK' and event='usage_rollup'")"
+is "allowlisted props stored" "local|fresh|2" \
+   "$(q "select json_extract(props,'\$.scope')||'|'||json_extract(props,'\$.kind')||'|'||json_array_length(props,'\$.targets') from events where machine_id='$M_OK' and event='install'")"
+is "unknown prop stripped" 0 \
+   "$(q "select count(*) from events where machine_id='$M_OK' and props like '%strip me%'")"
+is "malformed enum prop stripped" 0 \
+   "$(q "select count(*) from events where machine_id='$M_OK' and props like '%bogus-bucket%'")"
+is "path-shaped prop stripped" 0 \
+   "$(q "select count(*) from events where machine_id='$M_OK' and props like '%/Users/%'")"
+is "unknown envelope field stored nowhere" 0 \
+   "$(q "select count(*) from events where props like '%must not be stored%'")"
+
+# The valid batch mixes ts-dated events (2026-07-27) with an undated rollup (today),
+# so it legitimately spans two days and must produce a machine_days row for each.
+is "machine_days: one row per distinct day in the batch" 2 \
+   "$(q "select count(*) from machine_days where machine_id='$M_OK'")"
+is "machine_days: non-ci machine is production" 1 \
+   "$(q "select min(prod) from machine_days where machine_id='$M_OK'")"
+is "machine_days: a later non-ci batch flips the day to production" 1 \
+   "$(q "select prod from machine_days where machine_id='$M_CI'")"
+is "machine_days: each backdated batch gets its own day" "2026-07-20,2026-07-27" \
+   "$(q "select group_concat(day) from (select day from machine_days where machine_id='$M_BACK' order by day)")"
+
+is "machine_first_seen recorded" "2026-07-27" "$(q "select first_day from machine_first_seen where machine_id='$M_OK'")"
+is "machine_first_seen only moves earlier" "2026-07-20" \
+   "$(q "select first_day from machine_first_seen where machine_id='$M_BACK'")"
+
+echo
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]

+ 276 - 0
telemetry-worker/scripts/smoke-rollup.sh

@@ -0,0 +1,276 @@
+#!/usr/bin/env bash
+# End-to-end check of the nightly rollup + retention purge against a real
+# `wrangler dev` and the local D1 state.
+#
+# Seeds three synthetic days of events straight into local D1 (the ingest path clamps
+# client timestamps to the last 30 days, so backdating far enough to exercise the purge
+# has to bypass it), drives the rollup through the admin endpoint and the cron handler,
+# then inspects what actually landed against hand-computed numbers.
+#
+# What it pins:
+#   * rollup numbers match the events they came from, including the two that are easy
+#     to get wrong — usage_rollup SUMs its `count` prop, and array props unnest
+#   * running a day twice changes nothing (idempotent upserts, no double counting)
+#   * ?reset=1 drops stale rollup rows on a live day and REFUSES to blank a day whose
+#     raw events are already purged
+#   * the purge deletes only rows past the window, and leaves machine_days /
+#     machine_first_seen alone
+#   * /admin/rollup does not exist without ADMIN_TOKEN, and rejects a wrong one
+#
+# Re-runnable: it wipes its own synthetic days first, and they are chosen to sit
+# outside the cron's 3-day lookback so the nightly run never rewrites them.
+#
+#   npm run smoke:rollup        # or: ROLLUP_PORT=8792 ./scripts/smoke-rollup.sh
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+PORT="${ROLLUP_PORT:-8788}"
+BASE="http://127.0.0.1:$PORT"
+DB=codegraph-telemetry
+TOKEN=smoke-admin-token
+SEED_SQL=/tmp/cg-smoke-rollup-seed.sql
+LOG=/tmp/cg-smoke-rollup.log
+
+pass=0; fail=0
+ok()   { pass=$((pass + 1)); printf '  ok   %s\n' "$1"; }
+bad()  { fail=$((fail + 1)); printf '  FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
+is()   { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
+
+day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; }
+
+# Synthetic days. MAIN/RESET sit inside the 90-day retention window but outside the
+# cron's 3-day lookback; OLD sits past the window so the purge takes it.
+DAY_MAIN=$(day_ago 40)
+DAY_RESET=$(day_ago 41)
+DAY_OLD=$(day_ago 200)
+CUTOFF=$(day_ago 90)
+
+# First column of the first row of a query against the LOCAL D1 state.
+q() {
+  npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
+    node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
+      const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
+      console.log(r===undefined?"":String(Object.values(r)[0]));})'
+}
+
+# A daily_dim_counts cell as "count/machines" — "" when the row does not exist.
+dim() { q "select count||'/'||machines from daily_dim_counts
+             where day='$1' and event='$2' and dim='$3' and value='$4'"; }
+
+# POST /admin/rollup, printing the HTTP status.
+roll() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?$1"; }
+
+boot() {  # extra wrangler dev args
+  npx wrangler dev --port "$PORT" "$@" >"$LOG" 2>&1 &
+  DEV_PID=$!
+  trap 'kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true' EXIT
+  local up=
+  for _ in $(seq 1 60); do
+    curl -sf -o /dev/null "$BASE/" && { up=1; break; }
+    kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat "$LOG"; exit 1; }
+    sleep 1
+  done
+  [ -n "$up" ] || { echo "worker never came up:"; cat "$LOG"; exit 1; }
+  # If wrangler could not bind the port, something else answers every probe and the
+  # whole run silently grades a different server. Check who picked up.
+  case "$(curl -s "$BASE/")" in
+    *'codegraph anonymous-telemetry ingest'*) : ;;
+    *) echo "port $PORT is serving something else — set ROLLUP_PORT to a free one"; exit 1 ;;
+  esac
+}
+shutdown() {
+  kill "$DEV_PID" 2>/dev/null || true
+  wait "$DEV_PID" 2>/dev/null || true
+  trap - EXIT
+  sleep 1   # let miniflare release the local sqlite file
+}
+
+# ---------------------------------------------------------------------------
+# Seed
+# ---------------------------------------------------------------------------
+echo "applying migrations to local D1"
+npx wrangler d1 migrations apply "$DB" --local >/dev/null 2>&1
+
+echo "seeding $DAY_MAIN / $DAY_RESET / $DAY_OLD"
+node -e '
+const [main, reset, old, seedFile] = process.argv.slice(1);
+const sq = (v) => `'"'"'${String(v).replace(/'"'"'/g, "'"'"''"'"'")}'"'"'`;
+const M = ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222",
+           "33333333-3333-4333-8333-333333333333", "44444444-4444-4444-8444-444444444444",
+           "99999999-9999-4999-8999-999999999999"];
+const out = [];
+
+// Re-runnable: every table this script touches, scoped to its own synthetic days.
+for (const t of ["events", "daily_event_counts", "daily_dim_counts", "daily_machines", "machine_days"]) {
+  out.push(`DELETE FROM ${t} WHERE day IN (${[main, reset, old].map(sq).join(", ")});`);
+}
+out.push(`DELETE FROM machine_first_seen WHERE machine_id IN (${M.map(sq).join(", ")});`);
+
+// day, machine, event, os, arch, version, node_major, ci, props
+const rows = [
+  [main, M[0], "install",      "darwin", "arm64", "1.5.0", 22, 0, {targets:["claude","cursor"], scope:"local", kind:"fresh"}],
+  [main, M[0], "index",        "darwin", "arm64", "1.5.0", 22, 0, {languages:["typescript","go"], file_count_bucket:"100-1k", duration_bucket:"10-60s"}],
+  [main, M[0], "usage_rollup", "darwin", "arm64", "1.5.0", 22, 0, {kind:"mcp_tool", name:"codegraph_explore", count:10, error_count:2, client_name:"Claude Code"}],
+  [main, M[1], "index",        "darwin", "x64",   "1.5.0", 20, 0, {languages:["typescript"], file_count_bucket:"1k-10k", duration_bucket:"10-60s"}],
+  [main, M[1], "usage_rollup", "darwin", "x64",   "1.5.0", 20, 0, {kind:"mcp_tool", name:"codegraph_explore", count:5, error_count:0, client_name:"Cursor"}],
+  [main, M[2], "install",      "linux",  "x64",   "1.4.1", 22, 1, {targets:["claude"], scope:"global", kind:"upgrade"}],
+  [main, M[2], "uninstall",    "linux",  "x64",   "1.4.1", 22, 1, {targets:["claude"]}],
+  [reset, M[3], "index",       "darwin", "arm64", "1.5.0", 22, 0, {languages:["python"], file_count_bucket:"<100", duration_bucket:"<10s"}],
+  [old,  M[4], "install",      "linux",  "x64",   "1.0.0", 20, 0, {targets:["codex"], scope:"local", kind:"fresh"}],
+  [old,  M[4], "index",        "linux",  "x64",   "1.0.0", 20, 0, {languages:["rust"], file_count_bucket:"<100", duration_bucket:"<10s"}],
+];
+for (const [day, m, event, os, arch, version, node, ci, props] of rows) {
+  out.push(`INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
+    VALUES (${sq(day + "T12:00:00.000Z")}, ${sq(day + "T12:00:00.000Z")}, ${sq(day)}, ${sq(event)}, ${sq(m)},
+            ${sq(version)}, ${sq(os)}, ${sq(arch)}, ${node}, ${ci}, 1, ${sq(JSON.stringify(props))});`);
+}
+
+// What the ingest path would have written alongside those events.
+for (const [m, day, prod] of [[M[0], main, 1], [M[1], main, 1], [M[2], main, 0], [M[3], reset, 1], [M[4], old, 1]]) {
+  out.push(`INSERT INTO machine_days (machine_id, day, prod) VALUES (${sq(m)}, ${sq(day)}, ${prod});`);
+  out.push(`INSERT INTO machine_first_seen (machine_id, first_day) VALUES (${sq(m)}, ${sq(day)})
+              ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day);`);
+}
+
+// A rollup row from a dimension that no longer exists — only ?reset=1 should clear it.
+out.push(`INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+            VALUES (${sq(reset)}, ${sq("index")}, ${sq("obsolete_dim")}, ${sq("stale")}, 99, 99);`);
+
+require("fs").writeFileSync(seedFile, out.join("\n"));
+' "$DAY_MAIN" "$DAY_RESET" "$DAY_OLD" "$SEED_SQL"
+npx wrangler d1 execute "$DB" --local --file "$SEED_SQL" >/dev/null
+
+# ---------------------------------------------------------------------------
+# The admin route does not exist without a token
+# ---------------------------------------------------------------------------
+echo
+echo "admin route, no ADMIN_TOKEN configured"
+boot
+is "POST /admin/rollup → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
+is "…even with a token header" 404 \
+   "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
+shutdown
+
+# ---------------------------------------------------------------------------
+# Drive the rollup
+# ---------------------------------------------------------------------------
+echo
+echo "admin route, ADMIN_TOKEN configured"
+boot --test-scheduled --var "ADMIN_TOKEN:$TOKEN"
+
+is "no token → 401" 401 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
+is "wrong token → 401" 401 \
+   "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'x-admin-token: nope' "$BASE/admin/rollup")"
+is "GET → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
+is "impossible day → 400" 400 "$(roll 'day=2026-02-31')"
+is "malformed day → 400" 400 "$(roll 'day=yesterday')"
+is "days out of range → 400" 400 "$(roll "day=$DAY_MAIN&days=99")"
+
+echo
+echo "rollup"
+is "rollup $DAY_MAIN → 200" 200 "$(roll "day=$DAY_MAIN")"
+is "rollup $DAY_MAIN again → 200" 200 "$(roll "day=$DAY_MAIN")"
+is "rollup $DAY_OLD, whose events are still there → 200" 200 "$(roll "day=$DAY_OLD")"
+is "rollup $DAY_RESET with reset → 200" 200 "$(roll "day=$DAY_RESET&reset=1")"
+
+# The cron body: rolls up the last three days and purges everything past the window.
+is "cron trigger → 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/__scheduled?cron=30+0+*+*+*")"
+sleep 2
+
+# Rolling a purged day with reset=1 must NOT blank the rollups it already has: past
+# the window the reset is ignored, so the delete-then-rebuild can't find zero events.
+is "rollup $DAY_OLD after the purge, with reset → 200" 200 "$(roll "day=$DAY_OLD&reset=1")"
+is "…and reports the reset it refused to run" "[\"$DAY_OLD\"]" \
+   "$(curl -s -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?day=$DAY_OLD&reset=1" |
+      node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s).reset_ignored)))')"
+
+shutdown
+
+# ---------------------------------------------------------------------------
+# What actually landed — every number below is hand-computed from the seed above
+# ---------------------------------------------------------------------------
+echo
+echo "daily_machines"
+is "3 machines, 2 of them production (one is ci)" "3/2" \
+   "$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_MAIN'")"
+
+echo
+echo "daily_event_counts"
+is "install: 2 events from 2 machines" "2/2" \
+   "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='install'")"
+is "index: 2 events from 2 machines" "2/2" \
+   "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
+is "uninstall: 1 event from 1 machine" "1/1" \
+   "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='uninstall'")"
+# The one that is easy to get wrong: 2 rows carrying count 10 and 5 is 15 tool calls.
+is "usage_rollup: SUMs the count prop (10+5), not the rows" "15/2" \
+   "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
+is "one row per event type" 4 "$(q "select count(*) from daily_event_counts where day='$DAY_MAIN'")"
+
+echo
+echo "daily_dim_counts"
+is "os / index / darwin" "2/2" "$(dim "$DAY_MAIN" index os darwin)"
+is "os / usage_rollup / darwin sums counts" "15/2" "$(dim "$DAY_MAIN" usage_rollup os darwin)"
+is "arch / uninstall / x64" "1/1" "$(dim "$DAY_MAIN" uninstall arch x64)"
+is "codegraph_version / index / 1.5.0" "2/2" "$(dim "$DAY_MAIN" index codegraph_version 1.5.0)"
+is "node_major / install / 22 (stored as text)" "2/2" "$(dim "$DAY_MAIN" install node_major 22)"
+is "file_count_bucket / index / 100-1k" "1/1" "$(dim "$DAY_MAIN" index file_count_bucket 100-1k)"
+is "duration_bucket / index / 10-60s" "2/2" "$(dim "$DAY_MAIN" index duration_bucket 10-60s)"
+is "scope / install / global" "1/1" "$(dim "$DAY_MAIN" install scope global)"
+is "kind / install / fresh" "1/1" "$(dim "$DAY_MAIN" install kind fresh)"
+is "kind / usage_rollup / mcp_tool (same dim, other event)" "15/2" "$(dim "$DAY_MAIN" usage_rollup kind mcp_tool)"
+is "name / usage_rollup / codegraph_explore" "15/2" "$(dim "$DAY_MAIN" usage_rollup name codegraph_explore)"
+is "client_name / usage_rollup / Claude Code" "10/1" "$(dim "$DAY_MAIN" usage_rollup client_name 'Claude Code')"
+# languages and targets are JSON arrays: one row per element, counted once per event.
+is "language / index / typescript (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" index language typescript)"
+is "language / index / go (unnested, 1 event)" "1/1" "$(dim "$DAY_MAIN" index language go)"
+is "target / install / claude (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" install target claude)"
+is "target / install / cursor" "1/1" "$(dim "$DAY_MAIN" install target cursor)"
+is "target / uninstall / claude" "1/1" "$(dim "$DAY_MAIN" uninstall target claude)"
+# Only groups with at least one error are stored, so machines = machines that saw one.
+is "name_error / usage_rollup / codegraph_explore" "2/1" "$(dim "$DAY_MAIN" usage_rollup name_error codegraph_explore)"
+is "no dimension row for a machine with no errors" "" "$(dim "$DAY_MAIN" usage_rollup name_error nothing)"
+is "40 dimension rows in total (no strays, no doubles)" 40 \
+   "$(q "select count(*) from daily_dim_counts where day='$DAY_MAIN'")"
+
+# Independent of the hand-computed numbers: recompute two of them straight off `events`.
+echo
+echo "cross-check against the raw events"
+is "machines matches count(distinct machine_id)" \
+   "$(q "select count(distinct machine_id) from events where day='$DAY_MAIN' and event='index'")" \
+   "$(q "select machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
+is "usage count matches sum(props.count)" \
+   "$(q "select sum(json_extract(props,'\$.count')) from events where day='$DAY_MAIN' and event='usage_rollup'")" \
+   "$(q "select count from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
+
+echo
+echo "reset"
+is "?reset=1 drops a rollup row whose dimension no longer exists" 0 \
+   "$(q "select count(*) from daily_dim_counts where day='$DAY_RESET' and dim='obsolete_dim'")"
+is "…and recomputes the day correctly" "1/1" "$(dim "$DAY_RESET" index language python)"
+is "…leaving exactly the 7 dimensions that day has" 7 \
+   "$(q "select count(*) from daily_dim_counts where day='$DAY_RESET'")"
+
+echo
+echo "retention purge"
+is "raw events past the window are gone" 0 "$(q "select count(*) from events where day='$DAY_OLD'")"
+is "nothing older than the cutoff survives" 0 "$(q "select count(*) from events where day<'$CUTOFF'")"
+is "events inside the window are untouched" 7 "$(q "select count(*) from events where day='$DAY_MAIN'")"
+is "machine_days is NOT purged (retention cohorts need it)" 1 \
+   "$(q "select count(*) from machine_days where day='$DAY_OLD'")"
+is "machine_first_seen is NOT purged" "$DAY_OLD" \
+   "$(q "select first_day from machine_first_seen where machine_id='99999999-9999-4999-8999-999999999999'")"
+
+echo
+echo "rollups outlive the events they came from"
+is "daily_event_counts survives the purge" "1/1" \
+   "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_OLD' and event='index'")"
+is "daily_dim_counts survives the purge" "1/1" "$(dim "$DAY_OLD" index language rust)"
+is "…all 14 rows of it, even after a reset run over the purged day" 14 \
+   "$(q "select count(*) from daily_dim_counts where day='$DAY_OLD'")"
+is "daily_machines is still rebuilt for a purged day (machine_days survives)" "1/1" \
+   "$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_OLD'")"
+
+echo
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]

+ 10 - 0
telemetry-worker/src/env.d.ts

@@ -0,0 +1,10 @@
+/**
+ * Secrets are set with `wrangler secret put`, so they are deliberately absent from
+ * wrangler.jsonc (this repo is public) and `wrangler types` cannot see them. Declared
+ * here by interface merging so the worker type-checks with or without a local
+ * `.dev.vars`. Anything listed here may be missing at runtime — check before use.
+ */
+interface Env {
+  /** Shared secret for `POST /admin/rollup`. Unset ⇒ the route does not exist (404). */
+  ADMIN_TOKEN: string;
+}

+ 137 - 36
telemetry-worker/src/index.ts

@@ -3,15 +3,22 @@
  *
  * This file is public on purpose: it is the exact code that receives codegraph's
  * anonymous usage telemetry, so anyone can audit what is (and is not) stored.
- * The schema contract lives in docs/design/telemetry.md.
+ * The schema contract lives in docs/design/telemetry.md; the storage schema — the
+ * complete list of what is kept — is migrations/0001_init.sql.
  *
  * Guarantees enforced here:
  * - strict allowlist: unknown events are dropped, unknown properties are stripped
- * - the client IP is never read, logged, or forwarded
+ * - the client IP is never read, logged, or stored
+ * - accepted events land in our own Cloudflare D1 database and are never forwarded
+ *   to a third-party analytics vendor — this worker makes no outbound requests
  * - per-machine rate limiting, bounded body/batch sizes
- * - forwarding happens off the response path (ctx.waitUntil); bodies are never logged
+ * - the write happens off the response path (ctx.waitUntil); bodies are never logged
+ * - raw events expire: a nightly cron rolls each day up into anonymous daily counts
+ *   and then deletes the rows behind it (rollup.ts)
  */
 
+import { handleAdminRollup, retentionDays, runNightly } from './rollup';
+
 const MAX_BODY_BYTES = 64 * 1024;
 const MAX_EVENTS_PER_BATCH = 100;
 const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -20,13 +27,25 @@ const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
 // Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
 const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
 
-const INFO_TEXT = `codegraph anonymous-telemetry ingest.
+const infoText = (keepDays: number): string => `codegraph anonymous-telemetry ingest.
 
 What gets collected (and what never does) is documented field-by-field:
 https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
 This endpoint's full source:
 https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
 
+Guarantees: no code, file paths, repo/file/symbol names, or query strings are ever
+sent; the client IP is never read or stored; the machine ID is a random UUID the
+client mints locally and can delete at any time. Accepted events are stored in our
+own database on Cloudflare (D1) and are never forwarded to any third-party analytics
+vendor. The stored schema is the complete list of what is kept:
+https://github.com/colbymchenry/codegraph/blob/main/telemetry-worker/migrations/0001_init.sql
+
+Individual events are deleted after ${keepDays} days. What outlives them: anonymous
+daily totals (counts per day of things like operating system, version and language),
+and which days each machine ID was active, so returning-user numbers survive. No event
+details, and still nothing that identifies a person or a codebase.
+
 Disable any time: codegraph telemetry off  |  CODEGRAPH_TELEMETRY=0  |  DO_NOT_TRACK=1
 `;
 
@@ -117,11 +136,17 @@ const ENVELOPE_PROPS: Record<string, Sanitize> = {
   schema_version: nonNegInt(99),
 };
 
-interface PostHogEvent {
+/**
+ * One sanitized event, ready to become one `events` row. The envelope is NOT
+ * folded in here: it is identical for every event in a batch and lands in its own
+ * columns, so it is carried alongside (`common`) and bound at write time.
+ */
+interface StoredEvent {
   event: string;
-  distinct_id: string;
-  timestamp?: string;
-  properties: JsonObject;
+  /** Clamped ISO 8601 UTC; absent when the client sent none or sent nonsense. */
+  ts?: string;
+  /** Event-specific props only — stored as the `props` JSON column. */
+  props: JsonObject;
 }
 
 function clampTimestamp(v: unknown): string | undefined {
@@ -134,7 +159,7 @@ function clampTimestamp(v: unknown): string | undefined {
   return new Date(t).toISOString();
 }
 
-function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): PostHogEvent | null {
+function sanitizeEvent(raw: unknown): StoredEvent | null {
   if (typeof raw !== 'object' || raw === null) return null;
   const e = raw as JsonObject;
   if (typeof e.event !== 'string') return null;
@@ -151,36 +176,94 @@ function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): Pos
     if (!(req in props)) return null;
   }
 
-  const out: PostHogEvent = {
-    event: e.event,
-    distinct_id: machineId,
-    properties: {
-      ...props,
-      ...common,
-      // Anonymous events: no person profiles, no geo enrichment.
-      $process_person_profile: false,
-      $geoip_disable: true,
-      $lib: 'codegraph-telemetry-worker',
-    },
-  };
+  const out: StoredEvent = { event: e.event, props };
   const ts = clampTimestamp(e.ts);
-  if (ts !== undefined) out.timestamp = ts;
+  if (ts !== undefined) out.ts = ts;
   return out;
 }
 
-async function forwardToPostHog(env: Env, batch: PostHogEvent[]): Promise<void> {
+/**
+ * Re-narrow a sanitized envelope value for binding. The ENVELOPE_PROPS sanitizers
+ * already guarantee these types; these just turn "absent" into a NULL bind.
+ */
+const asText = (v: unknown): string | null => (typeof v === 'string' ? v : null);
+const asInt = (v: unknown): number | null => (typeof v === 'number' ? v : null);
+const asFlag = (v: unknown): number | null => (typeof v === 'boolean' ? (v ? 1 : 0) : null);
+
+const INSERT_EVENT = `INSERT INTO events (
+  received_at, ts, day, event, machine_id,
+  codegraph_version, os, arch, node_major, ci, schema_version, props
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
+
+// prod = 0 only if EVERY event this machine sent that day carried ci = 1, so a later
+// non-CI batch flips the day to production and never back (max, not overwrite).
+const UPSERT_MACHINE_DAY = `INSERT INTO machine_days (machine_id, day, prod) VALUES (?, ?, ?)
+  ON CONFLICT (machine_id, day) DO UPDATE SET prod = max(machine_days.prod, excluded.prod)`;
+
+// A late-arriving offline buffer can move a machine's first day earlier, never later.
+const UPSERT_FIRST_SEEN = `INSERT INTO machine_first_seen (machine_id, first_day) VALUES (?, ?)
+  ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day)`;
+
+/**
+ * Persist a sanitized batch: one `events` row per event, plus the machine×day and
+ * first-seen bookkeeping the dashboard's retention/activation panels need. One D1
+ * `batch()` = one implicit transaction = one round trip.
+ *
+ * Fail-silent by design: the client treats every response as final and never retries,
+ * so a failed write loses a datapoint rather than costing availability. The error is
+ * logged (Workers Logs) with counts only — never the payload.
+ */
+async function writeToD1(
+  env: Env,
+  machineId: string,
+  common: JsonObject,
+  batch: StoredEvent[],
+): Promise<void> {
   try {
-    const res = await fetch(`${env.POSTHOG_HOST}/batch/`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json' },
-      body: JSON.stringify({ api_key: env.POSTHOG_KEY, batch }),
-      signal: AbortSignal.timeout(5000),
-    });
-    if (!res.ok) {
-      console.error(JSON.stringify({ msg: 'posthog forward failed', status: res.status, events: batch.length }));
+    const receivedAt = new Date().toISOString();
+    const insertEvent = env.DB.prepare(INSERT_EVENT);
+    const stmts: D1PreparedStatement[] = [];
+    // Envelope columns are identical for every row in the batch.
+    const envelopeCols = [
+      asText(common.codegraph_version),
+      asText(common.os),
+      asText(common.arch),
+      asInt(common.node_major),
+      asFlag(common.ci),
+      asInt(common.schema_version),
+    ] as const;
+    // A batch can span days (offline buffers hold completed-day rollups), so
+    // machine_days gets one row per distinct day rather than one per batch.
+    const days = new Set<string>();
+
+    for (const e of batch) {
+      const day = (e.ts ?? receivedAt).slice(0, 10);
+      days.add(day);
+      stmts.push(
+        insertEvent.bind(
+          receivedAt,
+          e.ts ?? null,
+          day,
+          e.event,
+          machineId,
+          ...envelopeCols,
+          JSON.stringify(e.props),
+        ),
+      );
     }
+
+    const prod = common.ci === true ? 0 : 1;
+    const upsertDay = env.DB.prepare(UPSERT_MACHINE_DAY);
+    for (const day of days) stmts.push(upsertDay.bind(machineId, day, prod));
+
+    const firstDay = [...days].sort()[0];
+    if (firstDay !== undefined) {
+      stmts.push(env.DB.prepare(UPSERT_FIRST_SEEN).bind(machineId, firstDay));
+    }
+
+    await env.DB.batch(stmts);
   } catch (err) {
-    console.error(JSON.stringify({ msg: 'posthog forward error', err: String(err), events: batch.length }));
+    console.error(JSON.stringify({ msg: 'd1 write failed', err: String(err), events: batch.length }));
   }
 }
 
@@ -190,7 +273,13 @@ export default {
       const url = new URL(request.url);
 
       if (request.method === 'GET' && url.pathname === '/') {
-        return new Response(INFO_TEXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
+        return new Response(infoText(retentionDays(env)), {
+          headers: { 'content-type': 'text/plain; charset=utf-8' },
+        });
+      }
+      // Backfill/repair for the nightly rollup. 404s unless ADMIN_TOKEN is configured.
+      if (url.pathname === '/admin/rollup') {
+        return await handleAdminRollup(request, env, url);
       }
       if (url.pathname !== '/v1/events') {
         return new Response('not found\n', { status: 404 });
@@ -240,14 +329,16 @@ export default {
       }
 
       const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
-      const batch: PostHogEvent[] = [];
+      const batch: StoredEvent[] = [];
       for (const raw of rawEvents) {
-        const sanitized = sanitizeEvent(raw, machineId, common);
+        const sanitized = sanitizeEvent(raw);
         if (sanitized) batch.push(sanitized);
       }
 
+      // Nothing survived the allowlist ⇒ nothing is written at all, not even the
+      // machine×day bookkeeping: those tables must only ever describe stored events.
       if (batch.length > 0) {
-        ctx.waitUntil(forwardToPostHog(env, batch));
+        ctx.waitUntil(writeToD1(env, machineId, common, batch));
       }
       // Accepted (including "everything was dropped by the allowlist") — the
       // client treats every response as final and never retries.
@@ -257,4 +348,14 @@ export default {
       return new Response('internal error\n', { status: 500 });
     }
   },
+
+  /**
+   * Nightly (00:30 UTC, see wrangler.jsonc): roll the completed day up into the
+   * daily_* tables and purge raw events past the retention window. Awaited rather
+   * than backgrounded so a failure marks the cron run failed — everything it does is
+   * an idempotent upsert or a bounded delete, so the retry is safe.
+   */
+  async scheduled(event, env): Promise<void> {
+    await runNightly(env, event.scheduledTime);
+  },
 } satisfies ExportedHandler<Env>;

+ 397 - 0
telemetry-worker/src/rollup.ts

@@ -0,0 +1,397 @@
+/**
+ * codegraph telemetry — nightly rollup + raw-event retention purge.
+ *
+ * Public for the same reason the ingest path is: this is every read and every write
+ * we make over the stored events, including the one that deletes them.
+ *
+ * Two jobs, both driven by the cron trigger in wrangler.jsonc (00:30 UTC daily):
+ *
+ * 1. ROLL UP the just-completed UTC day into `daily_machines`, `daily_event_counts`
+ *    and `daily_dim_counts` — plus the two days before it, because clients buffer
+ *    offline and ship completed-day rollups late, so a day keeps growing after it
+ *    ends. Every write is an upsert that OVERWRITES the recomputed value rather than
+ *    adding to it, so re-running a day is a no-op and never double-counts.
+ *
+ * 2. PURGE raw `events` past the retention window, in bounded batches. Rollups are
+ *    kept forever, so only ad-hoc drill-down has a horizon; `machine_days` and
+ *    `machine_first_seen` are never purged, because retention cohorts need the full
+ *    history and they are two orders of magnitude smaller than the raw rows.
+ *
+ * `POST /admin/rollup` re-runs a day (or a short range) on demand for backfill and
+ * repair, guarded by the ADMIN_TOKEN secret. Like everything else here it makes no
+ * outbound requests — the only thing this worker talks to is its own D1 database.
+ */
+
+/** Raw-event retention when RETENTION_DAYS is unset or nonsense. Storage-bound — see README. */
+export const DEFAULT_RETENTION_DAYS = 90;
+/** The just-completed day, plus the two before it (late offline buffers). */
+export const ROLLUP_LOOKBACK_DAYS = 3;
+/** Widest range one manual /admin/rollup call will attempt. */
+export const MAX_MANUAL_DAYS = 31;
+
+/** Rows per purge DELETE — bounded so one statement stays well inside D1's limits. */
+const PURGE_BATCH_ROWS = 5_000;
+/** Ceiling on one night's deletions (≈1.5 days of ingest at current volume). */
+const PURGE_MAX_BATCHES = 60;
+
+const DAY_MS = 86_400_000;
+
+/** UTC YYYY-MM-DD — the key every event, rollup and chart is bucketed on. */
+export function utcDay(atMs: number): string {
+  return new Date(atMs).toISOString().slice(0, 10);
+}
+
+/** Rejects both the wrong shape and impossible dates (`2026-02-31` round-trips as `2026-03-03`). */
+export function isValidDay(day: string): boolean {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
+  const t = Date.parse(`${day}T00:00:00Z`);
+  return Number.isFinite(t) && utcDay(t) === day;
+}
+
+/** Configured retention, clamped to something sane; falls back to the default. */
+export function retentionDays(env: Env): number {
+  const raw = Number(env.RETENTION_DAYS);
+  return Number.isInteger(raw) && raw >= 1 && raw <= 3650 ? raw : DEFAULT_RETENTION_DAYS;
+}
+
+/** Oldest day kept: everything strictly before this is purged. */
+export function retentionCutoff(atMs: number, keepDays: number): string {
+  return utcDay(atMs - keepDays * DAY_MS);
+}
+
+// ---------------------------------------------------------------------------
+// The rollup statements
+// ---------------------------------------------------------------------------
+// One `INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension: the whole
+// aggregation happens inside D1, so a day rolls up in one round trip and no event
+// row ever crosses the wire. Each takes exactly one bound parameter — the day.
+//
+// Adding a breakdown is a line in ROLLUP_STATEMENTS, never a migration — that is
+// what the generic (dim, value) shape of daily_dim_counts buys.
+
+/**
+ * A group's event volume. For install/index/uninstall one row is one event, but a
+ * usage_rollup row is a counter the client pre-aggregated (one per machine × day ×
+ * tool), so its `count` prop is what has to be summed — counting rows there would
+ * silently report "machines that used the tool" and undercount by an order of magnitude.
+ */
+const COUNT = `CASE WHEN e.event = 'usage_rollup'
+           THEN sum(coalesce(json_extract(e.props, '$.count'), 0))
+           ELSE count(*) END`;
+
+const DIM_CONFLICT = `ON CONFLICT (day, event, dim, value) DO UPDATE
+     SET count = excluded.count, machines = excluded.machines`;
+
+const prop = (name: string): string => `json_extract(e.props, '$.${name}')`;
+const quoted = (values: readonly string[]): string => values.map((v) => `'${v}'`).join(', ');
+const onlyEvents = (...events: readonly string[]): string => ` AND e.event IN (${quoted(events)})`;
+
+/** One dimension whose value is a scalar column or a scalar prop. */
+function dimStatement(dim: string, value: string, where = ''): string {
+  return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+   SELECT e.day, e.event, '${dim}', CAST(${value} AS TEXT), ${COUNT}, count(DISTINCT e.machine_id)
+     FROM events e
+    WHERE e.day = ? AND ${value} IS NOT NULL AND ${value} <> ''${where}
+    GROUP BY e.day, e.event, ${value}
+   ${DIM_CONFLICT}`;
+}
+
+/**
+ * One dimension unnested from a JSON array prop — one row per element, so an index
+ * of a TypeScript+Go repo counts once under each language. `json_each` over a path
+ * the props do not have yields no rows, which is exactly the wanted behaviour for
+ * events that omit the array.
+ */
+function arrayDimStatement(dim: string, path: string, events: readonly string[]): string {
+  return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+   SELECT e.day, e.event, '${dim}', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
+     FROM events e, json_each(e.props, '${path}') j
+    WHERE e.day = ? AND e.event IN (${quoted(events)}) AND j.value <> ''
+    GROUP BY e.day, e.event, j.value
+   ${DIM_CONFLICT}`;
+}
+
+/**
+ * Rebuilt from `machine_days`, not from `events`: that table is never purged, so this
+ * number stays right for days whose raw rows are long gone. `prod` is already the
+ * per-machine-day maximum the ingest path maintains (0 only if every event that
+ * machine sent that day carried ci = 1).
+ *
+ * So this is also the one rollup that can still be rebuilt for a day whose raw events
+ * are long gone.
+ */
+const DAILY_MACHINES = `INSERT INTO daily_machines (day, machines, prod_machines)
+   SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days WHERE day = ? GROUP BY day
+   ON CONFLICT (day) DO UPDATE
+     SET machines = excluded.machines, prod_machines = excluded.prod_machines`;
+
+const ROLLUP_STATEMENTS: readonly string[] = [
+  DAILY_MACHINES,
+
+  `INSERT INTO daily_event_counts (day, event, count, machines)
+   SELECT e.day, e.event, ${COUNT}, count(DISTINCT e.machine_id)
+     FROM events e
+    WHERE e.day = ?
+    GROUP BY e.day, e.event
+   ON CONFLICT (day, event) DO UPDATE
+     SET count = excluded.count, machines = excluded.machines`,
+
+  // Envelope dimensions — every event type carries them.
+  dimStatement('os', 'e.os'),
+  dimStatement('arch', 'e.arch'),
+  dimStatement('codegraph_version', 'e.codegraph_version'),
+  dimStatement('node_major', 'e.node_major'),
+
+  // Event-specific scalar props.
+  dimStatement('file_count_bucket', prop('file_count_bucket'), onlyEvents('index')),
+  dimStatement('duration_bucket', prop('duration_bucket'), onlyEvents('index')),
+  dimStatement('scope', prop('scope'), onlyEvents('install')),
+  // `kind` is fresh/upgrade/reinstall on install and mcp_tool/cli_command on
+  // usage_rollup; `event` is part of the primary key, so both live here without colliding.
+  dimStatement('kind', prop('kind'), onlyEvents('install', 'usage_rollup')),
+  dimStatement('name', prop('name'), onlyEvents('usage_rollup')),
+  dimStatement('client_name', prop('client_name'), onlyEvents('usage_rollup')),
+
+  // Array props.
+  arrayDimStatement('language', '$.languages', ['index']),
+  arrayDimStatement('target', '$.targets', ['install', 'uninstall']),
+
+  // Errors per tool/command. Not in the migration's documented dim list because dims
+  // are a cron concern rather than a schema one, but rolled up because it is the one
+  // usage number that is gone for good after the purge. Only groups with at least one
+  // error are stored, so `count` is errors and `machines` is the machines that saw one
+  // — NOT the machines that ran the tool (that is the `name` dim).
+  `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+   SELECT e.day, e.event, 'name_error', CAST(${prop('name')} AS TEXT),
+          sum(${prop('error_count')}), count(DISTINCT e.machine_id)
+     FROM events e
+    WHERE e.day = ? AND e.event = 'usage_rollup'
+      AND ${prop('name')} IS NOT NULL AND coalesce(${prop('error_count')}, 0) > 0
+    GROUP BY e.day, e.event, ${prop('name')}
+   ${DIM_CONFLICT}`,
+];
+
+/** Rollup tables derived from raw `events` — the ones `reset` wipes before recomputing. */
+const EVENT_DERIVED_TABLES = ['daily_event_counts', 'daily_dim_counts'] as const;
+
+// ---------------------------------------------------------------------------
+// Running it
+// ---------------------------------------------------------------------------
+
+export interface DayResult {
+  day: string;
+  /** Rollup rows written for the day. */
+  rows: number;
+  /** Day is past the retention window — a `reset` on it is ignored (see below). */
+  pastRetention: boolean;
+}
+
+/**
+ * Recompute every rollup for one UTC day. One D1 `batch()` = one implicit
+ * transaction, so a day is either fully recomputed or not touched at all.
+ *
+ * Plain (upsert-only) runs are safe on any day: a day whose raw events are already
+ * purged selects nothing, so nothing is written and the rollups it earned while the
+ * events were still around survive untouched. That is what keeps rollups permanent.
+ *
+ * `reset` drops the day's event-derived rollup rows first instead of upserting over
+ * them — repair for when the dimension list itself changes and a value that no longer
+ * exists would otherwise linger. It is IGNORED past the retention window, where it
+ * would delete rows and then find no events to rebuild them from: silently blanking a
+ * real day is the one irreversible thing this file could do.
+ */
+export async function rollupDay(
+  env: Env,
+  day: string,
+  opts: { cutoff: string; reset?: boolean },
+): Promise<DayResult> {
+  const pastRetention = day < opts.cutoff;
+  const statements: D1PreparedStatement[] = [];
+
+  if (opts.reset && !pastRetention) {
+    for (const table of EVENT_DERIVED_TABLES) {
+      statements.push(env.DB.prepare(`DELETE FROM ${table} WHERE day = ?`).bind(day));
+    }
+  }
+  for (const sql of ROLLUP_STATEMENTS) {
+    statements.push(env.DB.prepare(sql).bind(day));
+  }
+
+  const results = await env.DB.batch(statements);
+  const rows = results.reduce((total, r) => total + (r.meta?.changes ?? 0), 0);
+  return { day, rows, pastRetention };
+}
+
+export interface PurgeResult {
+  /** Everything strictly before this day was deleted. */
+  cutoff: string;
+  deleted: number;
+  batches: number;
+  /** Hit the per-run batch ceiling — more rows are still due, next run takes them. */
+  capped: boolean;
+}
+
+/**
+ * Delete raw events older than the window, oldest first, in bounded batches.
+ * `id` is a rowid alias and the purge only ever removes the oldest rows, so the
+ * keyset subquery stays a cheap index range scan on (day, event).
+ */
+export async function purgeOldEvents(env: Env, cutoff: string): Promise<PurgeResult> {
+  const del = env.DB.prepare(
+    `DELETE FROM events WHERE id IN (SELECT id FROM events WHERE day < ? LIMIT ${PURGE_BATCH_ROWS})`,
+  );
+  let deleted = 0;
+  for (let batch = 1; batch <= PURGE_MAX_BATCHES; batch++) {
+    const { meta } = await del.bind(cutoff).run();
+    const removed = meta?.changes ?? 0;
+    deleted += removed;
+    if (removed < PURGE_BATCH_ROWS) return { cutoff, deleted, batches: batch, capped: false };
+  }
+  return { cutoff, deleted, batches: PURGE_MAX_BATCHES, capped: true };
+}
+
+/**
+ * The cron body: roll up the completed day and the two before it, then purge.
+ *
+ * Logs one line of counts — never a day's contents, never a machine id. Throws if
+ * anything failed so the invocation is marked failed (and retried) rather than
+ * quietly skipping a day; every write here is idempotent, so a retry is safe.
+ */
+export async function runNightly(env: Env, atMs: number): Promise<void> {
+  const started = Date.now();
+  const keepDays = retentionDays(env);
+  const cutoff = retentionCutoff(atMs, keepDays);
+
+  const rolled: string[] = [];
+  const failed: string[] = [];
+  let rows = 0;
+  for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) {
+    const day = utcDay(atMs - back * DAY_MS);
+    try {
+      rows += (await rollupDay(env, day, { cutoff })).rows;
+      rolled.push(day);
+    } catch (err) {
+      failed.push(day);
+      console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) }));
+    }
+  }
+
+  let purge: PurgeResult | null = null;
+  try {
+    purge = await purgeOldEvents(env, cutoff);
+  } catch (err) {
+    console.error(JSON.stringify({ msg: 'purge failed', cutoff, err: String(err) }));
+  }
+
+  console.log(
+    JSON.stringify({
+      msg: 'nightly rollup',
+      days: rolled,
+      rows,
+      failed: failed.length,
+      retention_days: keepDays,
+      purged_before: cutoff,
+      purged: purge?.deleted ?? null,
+      purge_batches: purge?.batches ?? null,
+      purge_capped: purge?.capped ?? null,
+      ms: Date.now() - started,
+    }),
+  );
+
+  if (failed.length > 0 || purge === null) {
+    throw new Error(`nightly rollup incomplete: ${failed.length} day(s) failed, purge ${purge ? 'ok' : 'failed'}`);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// POST /admin/rollup — manual backfill / repair
+// ---------------------------------------------------------------------------
+
+const json = (body: unknown, status = 200): Response =>
+  new Response(JSON.stringify(body), {
+    status,
+    headers: { 'content-type': 'application/json; charset=utf-8' },
+  });
+
+/** Constant-time over digests, so neither the length nor a prefix of the token leaks. */
+async function tokenMatches(provided: string, expected: string): Promise<boolean> {
+  const encoder = new TextEncoder();
+  const [a, b] = await Promise.all([
+    crypto.subtle.digest('SHA-256', encoder.encode(provided)),
+    crypto.subtle.digest('SHA-256', encoder.encode(expected)),
+  ]);
+  return crypto.subtle.timingSafeEqual(a, b);
+}
+
+/**
+ * `POST /admin/rollup?day=YYYY-MM-DD[&days=N][&reset=1]`, header `x-admin-token`.
+ *
+ * Re-runs the rollup for `day` (default: yesterday), or for the `N` days ending on it.
+ * Exists so a backfill or a repair never needs a redeploy. It only ever recomputes
+ * aggregates from stored rows — there is no path here that deletes raw events; the
+ * purge runs on the cron and nowhere else.
+ */
+export async function handleAdminRollup(request: Request, env: Env, url: URL): Promise<Response> {
+  // No secret configured ⇒ no admin surface at all, and nothing that hints there is one.
+  const expected = env.ADMIN_TOKEN;
+  if (typeof expected !== 'string' || expected.length === 0) {
+    return new Response('not found\n', { status: 404 });
+  }
+  if (request.method !== 'POST') {
+    return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
+  }
+
+  if (!(await tokenMatches(request.headers.get('x-admin-token') ?? '', expected))) {
+    // Cap how fast the token can be guessed at. Only failures spend the budget, so a
+    // chunked backfill loop is never throttled. Best-effort and fails open like the
+    // ingest limiter — the token itself is the guard, this only slows a guesser down.
+    try {
+      const { success } = await env.ADMIN_RATE_LIMITER.limit({ key: 'admin' });
+      if (!success) return new Response('rate limited\n', { status: 429 });
+    } catch (err) {
+      console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
+    }
+    return new Response('unauthorized\n', { status: 401 });
+  }
+
+  const now = Date.now();
+  const day = url.searchParams.get('day') ?? utcDay(now - DAY_MS);
+  if (!isValidDay(day)) return json({ error: 'day must be YYYY-MM-DD' }, 400);
+
+  const requested = url.searchParams.get('days');
+  const span = requested === null ? 1 : Number(requested);
+  if (!Number.isInteger(span) || span < 1 || span > MAX_MANUAL_DAYS) {
+    return json({ error: `days must be an integer between 1 and ${MAX_MANUAL_DAYS}` }, 400);
+  }
+
+  const reset = url.searchParams.get('reset') === '1';
+  const cutoff = retentionCutoff(now, retentionDays(env));
+  const endMs = Date.parse(`${day}T00:00:00Z`);
+
+  const days: DayResult[] = [];
+  try {
+    for (let back = span - 1; back >= 0; back--) {
+      days.push(await rollupDay(env, utcDay(endMs - back * DAY_MS), { cutoff, reset }));
+    }
+  } catch (err) {
+    console.error(JSON.stringify({ msg: 'manual rollup failed', through: day, err: String(err) }));
+    return json({ error: 'rollup failed', through: day, completed: days }, 500);
+  }
+
+  const rows = days.reduce((total, d) => total + d.rows, 0);
+  // A day past the window kept its rollups but ignored the reset — say so rather than
+  // reporting a repair that did not happen.
+  const resetIgnored = reset ? days.filter((d) => d.pastRetention).map((d) => d.day) : [];
+  console.log(
+    JSON.stringify({
+      msg: 'manual rollup',
+      through: day,
+      days: span,
+      reset,
+      reset_ignored: resetIgnored.length,
+      rows,
+      ms: Date.now() - now,
+    }),
+  );
+  return json({ ok: true, through: day, retention_cutoff: cutoff, rows, reset_ignored: resetIgnored, days });
+}

+ 22 - 3
telemetry-worker/wrangler.jsonc

@@ -1,5 +1,7 @@
 // codegraph telemetry ingest — see README.md and docs/design/telemetry.md.
-// Secrets are NOT configured here: POSTHOG_KEY is set via `wrangler secret put POSTHOG_KEY`.
+// Accepted events go straight into the bound D1 database and the worker makes no
+// outbound requests. The only secret is ADMIN_TOKEN, which guards the manual rollup
+// trigger (`wrangler secret put ADMIN_TOKEN`); leave it unset and that route 404s.
 {
   "$schema": "node_modules/wrangler/config-schema.json",
   "name": "codegraph-telemetry",
@@ -15,8 +17,18 @@
 
   "observability": { "enabled": true, "head_sampling_rate": 1 },
 
-  // Non-secret config. Swap host here if the backend ever moves (EU, self-hosted…).
-  "vars": { "POSTHOG_HOST": "https://us.i.posthog.com" },
+  // Nightly rollup + retention purge (src/rollup.ts). 00:30 UTC — half an hour after
+  // the day it rolls up closed, so straggling writes for it have landed. It also
+  // re-runs the two days before that, because offline clients ship completed-day
+  // rollups late; the writes are idempotent upserts, so re-running is free.
+  "triggers": { "crons": ["30 0 * * *"] },
+
+  // How many days of RAW events are kept. Rollups are kept forever, so shortening
+  // this costs ad-hoc drill-back, never a chart. 90 is a storage limit, not a policy
+  // one: raw events grow ≈74 MB/day, so 90 days ≈ 6.7 GB against D1's 10 GB
+  // per-database cap — the arithmetic is in migrations/0001_init.sql's footer.
+  // Measure real row size after cutover before widening it.
+  "vars": { "RETENTION_DAYS": 90 },
 
   // Telemetry storage. Schema + the chart each table serves: migrations/0001_init.sql.
   // Apply with `npm run db:migrate:local` (local state) / `npm run db:migrate` (remote).
@@ -37,6 +49,13 @@
       "name": "MACHINE_RATE_LIMITER",
       "namespace_id": "1001",
       "simple": { "limit": 6, "period": 60 }
+    },
+    // POST /admin/rollup. The ADMIN_TOKEN secret is the real guard; this only caps
+    // how fast it can be guessed at, with enough room for a chunked backfill loop.
+    {
+      "name": "ADMIN_RATE_LIMITER",
+      "namespace_id": "1002",
+      "simple": { "limit": 10, "period": 60 }
     }
   ]
 }