Files
49c11fc2e0 Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497)
* feat(telemetry): D1 schema + migrations for raw events and daily rollups

First step of replacing PostHog with self-hosted telemetry on Cloudflare D1.
Creates the codegraph-telemetry database binding and the initial migration; no
worker code paths change yet (the ingest write path and the nightly rollup cron
land next).

Schema is raw events plus daily rollups: `events` holds one row per sanitized
event with the envelope broken out into columns and event-specific props as
JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the
nightly rollups the dashboard reads; `machine_first_seen` and `machine_days`
carry the retention cohorts and are never purged. One generic dimension table
covers every bar and pie, so a new breakdown is a cron change rather than a
migration.

The migration is commented as an audit surface, like the rest of this worker —
every column, and which dashboard chart each rollup table serves.

Three judgment calls worth flagging, all documented in the file:

- `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)`
  indexes. D1 bills a row write per index touched, so a third index on the hot
  table costs ~97k writes/day, and `(day, event)` is a covering index for plain
  day-range scans anyway (verified with EXPLAIN QUERY PLAN).
- `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and
  `machine_days` a `prod` flag. The "users by ..." panels and the production-user
  count are distinct-machine numbers, not event counts, and they are
  unrecoverable once raw events are purged.
- No CHECK constraint on `event`: the worker's allowlist is the source of truth
  and the write path is fail-silent, so a rejected INSERT would lose data
  quietly instead of erroring loudly.

Volume note in the migration footer: ~30M row writes/month against the 50M
included on Workers Paid. Storage is the tighter constraint — raw events grow
~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180,
which would exceed D1's 10 GB per-database cap.

* feat(telemetry): admin dashboard worker — scaffold + shared-password auth

New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and
bound read-only to the same D1 database. Serves a static frontend plus a JSON
API behind a shared password, on stats.getcodegraph.com.

Auth is the simplest thing that is actually safe for exactly two users: one
password in a secret, compared in constant time over SHA-256 digests, and an
HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year
expiry so you sign in once per browser. The cookie is a signed assertion, not a
lookup key — no session store. Its payload carries a fingerprint of the password
it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login
attempts are capped at 5/min per IP via a ratelimit binding.

Everything is deny-by-default: assets.run_worker_first routes every request
through the worker before the static-asset server sees it, so the dashboard
HTML, its JS, its CSS and the chart library are all behind the session check.
The login page is rendered inline by the worker rather than served from public/,
which leaves no "is this file public?" judgement calls in the asset directory.
Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing
secret fails closed rather than opening the dashboard.

scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway
`wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/
truncated cookies, open-redirect refusal, brute-force capping, and password
rotation invalidating live sessions.

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(telemetry-dashboard): simplify the chart-library probe in the shell

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 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>

* feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13)

Replaces the scaffold page with the dashboard proper: 19 panels covering every
view of the PostHog dashboard this retires, driven by one filter row.

src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries,
breakdown,activation,retention}, all range-scoped, all parameterized against a
closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend
does no arithmetic. Rollups answer everything except the activation funnel,
which needs raw events and says where they start.

The frontend splits into a DOM-free panel registry (public/panels.js) and the
page that mounts it (public/app.js), so the render check can drive the same
registry the browser rendered from. Panels fail alone, refetch dims rather than
flashing, and every chart carries a table twin.

Two numbers are labelled rather than rounded off: range-wide "users" per
dimension is machine-days (the rollups cannot give distinct machines, and
per-day counts are taken as the largest single-event count so one machine's
install + index + usage is not counted three times), and recent activation and
retention cohorts are marked as still-converting instead of drawn as a cliff.

Both colour scales were run through the data-viz validator against the panel
surface, not picked by eye; the results are recorded in public/theme.js.

Verification, all against the committed fixture (12 machines over 10 days, every
expected number worked out by hand from the events, not recorded from a run):
  scripts/smoke-api.sh      98 assertions
  scripts/render-check.mjs  79 assertions — real Chromium over CDP, no new deps
  scripts/smoke-auth.sh     54 assertions (unchanged, still green)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14)

The account-level steps of the PostHog cutover are the maintainer's to run, so
this lands the runbook they follow and the check that has to pass first.

The runbook (telemetry-worker/README.md) walks the six steps in the order that
keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the
first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the
subscription. Step 3 records the outgoing version id because `wrangler rollback`
is the escape hatch for the whole verification window, and that window is
precisely why the PostHog key is deleted last rather than first.

The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one
seam nothing else did. Both workers declare the same D1 database_id, so pointing
them at a single --persist-to directory runs the real chain: a client batch →
the ingest worker → D1 → the nightly rollup → the dashboard API reading the
numbers back. Every other suite stops at one link — smoke-ingest at the events
table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture
that the cron never touched. That left the dimension names the rollup WRITES
versus the ones the dashboard READS agreeing by convention across two branches,
where a mismatch is silent: no error, no failed request, just a panel reading
zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a
ci machine that is active but not a production user, usage_rollup counts that
must be summed rather than tallied, and an uninstall's `targets` that must not
leak into the install-scoped breakdown.

Writing it caught that the activation funnel's denominator is first-seen
machines, not install events (deliberate — a reinstall must not re-enter the
funnel), so the suite now pins that distinction rather than assuming it.

Also rewords the last PostHog reference in dashboard code: a comment justifying
the 14-day retention curve by pointing at a dashboard step 6 deletes. The
reasoning now stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(telemetry): tell the truth about where events are stored (CG-15)

The telemetry docs are a privacy contract, and they still described a
managed analytics store that no longer receives anything. Replace that
with what actually happens now — events land in our own D1 database on
Cloudflare, the endpoint makes no outbound requests, raw events are
purged after 90 days and only anonymous daily rollups outlive them.
This strengthens the guarantee rather than restating it: there is no
second party to share with.

- TELEMETRY.md: new "Where it is stored" section; the never-collected
  IP bullet no longer leans on a vendor-side setting to hold.
- docs/design/telemetry.md: ingest section rewritten around D1 + the
  nightly rollup/retention cron; volume math redone on Workers Paid and
  the D1 quota (storage, not writes, is what sets the 90-day window);
  new section documenting the dashboard worker and cross-linking it.
- Fixed three drifts from the worker allowlist the sweep surfaced:
  schema_version was still 1, client_name/client_version was still
  marked "plumbing to add" though session.ts passes it today, and the
  legacy sqlite_backend field the worker still accepts was undocumented.
- telemetry-worker/README.md: step 6 claimed a repo-wide grep came back
  clean, which this runbook itself falsifies. Added step 7 — deleting
  the runbook is what makes that grep true, and is the completion check.
- smoke-cutover.sh: the vendor guarantee is now asserted by class
  (no analytics-ingest endpoint referenced) rather than by one vendor's
  name, so it keeps working once the name is gone. Verified it still
  catches a planted forwarding URL. 61/61 pass.

Retention is documented as 90 days, not the 180 in the task notes: 180
days of raw events exceeds D1's 10 GB per-database cap, and the code
purges at 90.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: untrack local Kommandr issue DB and ignore its sqlite artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:17:10 -05:00

277 lines
16 KiB
Bash
Executable File

#!/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 ]