#!/usr/bin/env bash # The cutover gate (CG-14): drives the WHOLE chain the way production will run it — # a client POSTs a batch, the ingest worker writes D1, the nightly rollup aggregates, # and the dashboard reads the numbers back out. # # Every other suite tests one link. smoke-ingest.sh stops at the `events` table, # smoke-rollup.sh hand-checks the rollup SQL, and smoke-api.sh reads a fixture that # was written by hand rather than by the cron. That leaves exactly the seam this # cutover turns on unverified: the dimension names the rollup WRITES versus the ones # the dashboard READS. Those two lists live in different workers on different # branches, and a mismatch is silent — no error, no failed request, just a panel that # renders zero forever. Catching that after cutover means a day of lost telemetry; # catching it here costs a minute. # # Both workers declare the same D1 `database_id`, so pointing them at one # `--persist-to` directory gives them literally the same local SQLite file. The state # is a fresh mktemp each run, so every expected number below is exact rather than a # lower bound. # # npm run smoke:cutover # # Expected numbers are derived from THE_BATCH below and nothing else; see the table # in that comment block. set -uo pipefail cd "$(dirname "$0")/.." WORKER_DIR="$PWD" DASH_DIR="$(cd .. && pwd)/telemetry-dashboard" [ -d "$DASH_DIR" ] || { echo "cannot find telemetry-dashboard/ next to telemetry-worker/"; exit 1; } INGEST_PORT="${CUTOVER_INGEST_PORT:-8795}" DASH_PORT="${CUTOVER_DASH_PORT:-8796}" INGEST="http://127.0.0.1:$INGEST_PORT" DASH="http://127.0.0.1:$DASH_PORT" # Test-only credentials. The point is to exercise the wiring, not to keep a secret. ADMIN_TOKEN=cutover-admin-token DASH_PASSWORD=cutover-dashboard-password SESSION_SECRET=cutover-session-secret STATE="$(mktemp -d -t cg-cutover-state)" JAR="$(mktemp -t cg-cutover-jar)" ILOG=/tmp/cg-cutover-ingest.log DLOG=/tmp/cg-cutover-dash.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"; } DEV_PID="" stop_dev() { [ -n "$DEV_PID" ] || return 0 kill "$DEV_PID" 2>/dev/null wait "$DEV_PID" 2>/dev/null DEV_PID="" } cleanup() { stop_dev; rm -rf "$STATE" "$JAR"; } trap cleanup EXIT # Boot a worker in on against the SHARED state, wait for . boot() { # boot [extra wrangler args...] local dir="$1" port="$2" ready="$3" log="$4"; shift 4 ( cd "$dir" && exec npx wrangler dev --port "$port" --ip 127.0.0.1 \ --persist-to "$STATE" "$@" ) >"$log" 2>&1 & DEV_PID=$! for _ in $(seq 1 90); do curl -sf -o /dev/null "$ready" && return 0 kill -0 "$DEV_PID" 2>/dev/null || break sleep 1 done echo "worker in $dir never came up on :$port — log follows"; cat "$log"; exit 1 } # Resolve a dotted path through a JSON document. Numeric segments index arrays. jget() { node -e ' let v = JSON.parse(process.argv[1]); for (const k of process.argv[2].split(".")) v = v?.[k]; console.log(v === undefined ? "" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)); ' "$1" "$2" } day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; } # Inside the ingest clamp window (30 days) and outside the cron's 3-day lookback. DAY="$(day_ago 5)" RANGE="from=$DAY&to=$DAY" # --------------------------------------------------------------------------- # THE_BATCH — three machines, one day. Everything asserted below follows from here. # # machine os arch node version ci events # m1 darwin arm64 22 1.5.0 false install(local/fresh, [claude,cursor]) # index([typescript,python], 100-1k, 10-60s) # usage_rollup(codegraph_explore x12, Claude Code) # m2 linux x64 20 1.5.0 false install(global/upgrade, [codex]) # index([typescript], 1k-10k, 1-5m) # usage_rollup(codegraph_explore x8, Codex CLI) # m3 linux arm64 22 1.4.1 TRUE index([go], <100, <10s) # uninstall([claude]) # # The three deliberate traps: # * m3 is ci=true, so it counts as active but NOT as a production user. # * tool_calls must SUM the `count` prop (12 + 8 = 20), not count the 2 rows. # * m3's uninstall carries targets=[claude], so a `target` breakdown that forgets # to scope by event would report claude twice. # --------------------------------------------------------------------------- M1=11111111-1111-4111-8111-111111111111 M2=22222222-2222-4222-8222-222222222222 M3=33333333-3333-4333-8333-333333333333 post_batch() { # post_batch curl -s -o /dev/null -w '%{http_code}' -X POST "$INGEST/v1/events" \ -H 'content-type: application/json' --data-binary "$1" } batch() { # batch node -e ' const [m, os, arch, node_major, v, ci, events, day] = process.argv.slice(1); process.stdout.write(JSON.stringify({ machine_id: m, codegraph_version: v, os, arch, node_major: Number(node_major), ci: ci === "true", schema_version: 1, events: JSON.parse(events).map((e) => ({ ...e, ts: `${day}T12:00:00Z` })), })); ' "$@" "$DAY" } # --------------------------------------------------------------------------- echo "cutover chain: client → ingest worker → D1 → rollup → dashboard" echo echo "migrating the shared local D1 state" ( cd "$WORKER_DIR" && npx wrangler d1 migrations apply codegraph-telemetry \ --local --persist-to "$STATE" ) >/tmp/cg-cutover-migrate.log 2>&1 || { echo "migration failed:"; cat /tmp/cg-cutover-migrate.log; exit 1; } echo "booting the ingest worker on :$INGEST_PORT" boot "$WORKER_DIR" "$INGEST_PORT" "$INGEST/" "$ILOG" --var "ADMIN_TOKEN:$ADMIN_TOKEN" echo echo "ingest accepts the batch" is "m1 batch → 204" 204 "$(post_batch "$(batch "$M1" darwin arm64 22 1.5.0 false '[ {"event":"install","props":{"scope":"local","kind":"fresh","targets":["claude","cursor"]}}, {"event":"index","props":{"languages":["typescript","python"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}}, {"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Claude Code"}} ]')")" is "m2 batch → 204" 204 "$(post_batch "$(batch "$M2" linux x64 20 1.5.0 false '[ {"event":"install","props":{"scope":"global","kind":"upgrade","targets":["codex"]}}, {"event":"index","props":{"languages":["typescript"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}}, {"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":8,"client_name":"Codex CLI"}} ]')")" is "m3 (ci) batch → 204" 204 "$(post_batch "$(batch "$M3" linux arm64 22 1.4.1 true '[ {"event":"index","props":{"languages":["go"],"file_count_bucket":"<100","duration_bucket":"<10s"}}, {"event":"uninstall","props":{"targets":["claude"]}} ]')")" sleep 2 # let the ctx.waitUntil writes drain before rolling up echo echo "the nightly rollup aggregates the day" ROLL=$(curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" "$INGEST/admin/rollup?day=$DAY") is "POST /admin/rollup → ok" true "$(jget "$ROLL" ok)" is "rollup wrote rows" true "$(node -e 'process.stdout.write(String((JSON.parse(process.argv[1]).rows ?? 0) > 0))' "$ROLL")" stop_dev # free the D1 lock before the dashboard opens the same file echo echo "booting the dashboard on :$DASH_PORT against the same D1" ( cd "$DASH_DIR" && npm run --silent vendor ) >/dev/null 2>&1 boot "$DASH_DIR" "$DASH_PORT" "$DASH/robots.txt" "$DLOG" \ --var "ADMIN_PASSWORD:$DASH_PASSWORD" --var "SESSION_SECRET:$SESSION_SECRET" curl -s -o /dev/null -c "$JAR" -X POST "$DASH/login" --data-urlencode "password=$DASH_PASSWORD" api() { curl -s -b "$JAR" "$DASH/api/$1"; } is "dashboard session established" 200 "$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/health")" # --- the big numbers ------------------------------------------------------- echo echo "summary panel reads back what was ingested" S=$(api "summary?$RANGE") is "production users (ci machine excluded)" 2 "$(jget "$S" production_users)" is "active machines" 3 "$(jget "$S" active_machines)" is "new machines" 3 "$(jget "$S" new_machines)" is "installs" 2 "$(jget "$S" installs)" is "uninstalls" 1 "$(jget "$S" uninstalls)" is "indexing runs" 3 "$(jget "$S" index_runs)" is "tool calls SUM the count prop (12+8)" 20 "$(jget "$S" tool_calls)" # --- every dimension the dashboard offers ---------------------------------- # This is the actual point of the suite: each of these is a distinct string that # must match between rollup.ts and api.ts's DIMS registry. An empty `labels` means # the dashboard is asking for a dimension the cron never writes. echo echo "every breakdown dimension resolves against the cron's output" bd() { # bd local body; body=$(api "breakdown?$RANGE&$2") is "$1 — labels" "$3" "$(jget "$body" labels)" is "$1 — data" "$4" "$(jget "$body" datasets.0.data)" } bd "os" "dim=os" '["linux","darwin"]' '[2,1]' bd "arch" "dim=arch" '["arm64","x64"]' '[2,1]' bd "version" "dim=codegraph_version" '["1.5.0","1.4.1"]' '[2,1]' bd "node major" "dim=node_major" '["22","20"]' '[2,1]' bd "language" "dim=language" '["typescript","go","python"]' '[2,1,1]' bd "files in project" "dim=file_count_bucket" '["<100","100-1k","1k-10k","10k+"]' '[1,1,1,0]' bd "run length" "dim=duration_bucket" '["<10s","10-60s","1-5m","5m+"]' '[1,1,1,0]' bd "install scope" "dim=scope" '["global","local"]' '[1,1]' bd "install kind" "dim=kind" '["fresh","upgrade"]' '[1,1]' bd "tool name" "dim=name" '["codegraph_explore"]' '[20]' bd "agent" "dim=client_name" '["Claude Code","Codex CLI"]' '[12,8]' # The trap: `target` defaults to event=install, so the uninstall's own claude target # must NOT be folded in — and must still be reachable by asking for it explicitly. bd "agent target (install-scoped)" "dim=target" '["claude","codex","cursor"]' '[1,1,1]' bd "agent target (uninstall)" "dim=target&event=uninstall" '["claude"]' '[1]' # --- the remaining panels -------------------------------------------------- echo echo "the timeseries and funnel panels see the day" # Every entry in api.ts's SERIES registry — each one reads a different rollup table, # so this is the second half of the write-vs-read seam the breakdowns cover above. ts() { # ts [series-1] local body; body=$(api "timeseries?$RANGE&metric=$2") is "$1 — day" "[\"$DAY\"]" "$(jget "$body" labels)" is "$1 — series" "$3" "$(jget "$body" datasets.0.data)" [ $# -ge 4 ] && is "$1 — second series" "$4" "$(jget "$body" datasets.1.data)" } ts "installs and uninstalls" installs_uninstalls '[2]' '[1]' ts "new installs" new_installs '[3]' ts "production users" production_users '[2]' ts "indexing activity" indexing_activity '[3]' '[3]' ts "tool calls (sums the prop)" tool_calls '[20]' '[2]' MET=$(api "meta") is "meta anchors on the rolled-up day" "$DAY" "$(jget "$MET" latest_day)" is "meta reports the rollup ran" "$DAY" "$(jget "$MET" latest_rollup_day)" # The funnel is the one panel that reads RAW events rather than a rollup, so it is # also the one the retention purge can blind — worth pinning that it works today. # # Its denominator is FIRST-SEEN MACHINES, not `install` events (api.ts: "a machine # that reinstalls does not re-enter the funnel"). m3 is the discriminator: it never # sent an install event, but it is new and it indexed, so it belongs in both legs. # Reading 2 here would mean the funnel had quietly become an install-event ratio. ACT=$(api "activation?$RANGE&window=1") is "funnel counts new machines, not install events" 3 "$(jget "$ACT" installs)" is "all three indexed within the window" 3 "$(jget "$ACT" activated)" is "nobody dropped out" 0 "$(jget "$ACT" dropped)" is "raw-event floor is reported to the caller" "$DAY" "$(jget "$ACT" raw_events_from)" is "retention endpoint answers" 200 \ "$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/retention?$RANGE")" # --- the guarantee the cutover is selling ---------------------------------- echo echo "the no-third-party guarantee still holds" is "ingest worker makes no outbound fetch" 0 \ "$(grep -E 'fetch\(' "$WORKER_DIR"/src/*.ts | grep -vc 'async fetch(request' || true)" is "ingest worker names no third-party analytics endpoint" 0 \ "$(grep -rEil 'https?://[a-z0-9.-]+/(batch|capture|collect|track|ingest)' \ "$WORKER_DIR"/src "$WORKER_DIR"/wrangler.jsonc 2>/dev/null | wc -l | tr -d ' ')" echo if [ "$fail" -eq 0 ]; then echo "$pass passed, 0 failed — the chain is whole; safe to cut over" else echo "$pass passed, $fail failed" fi [ "$fail" -eq 0 ]