smoke-cutover.sh 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. #!/usr/bin/env bash
  2. # The cutover gate (CG-14): drives the WHOLE chain the way production will run it —
  3. # a client POSTs a batch, the ingest worker writes D1, the nightly rollup aggregates,
  4. # and the dashboard reads the numbers back out.
  5. #
  6. # Every other suite tests one link. smoke-ingest.sh stops at the `events` table,
  7. # smoke-rollup.sh hand-checks the rollup SQL, and smoke-api.sh reads a fixture that
  8. # was written by hand rather than by the cron. That leaves exactly the seam this
  9. # cutover turns on unverified: the dimension names the rollup WRITES versus the ones
  10. # the dashboard READS. Those two lists live in different workers on different
  11. # branches, and a mismatch is silent — no error, no failed request, just a panel that
  12. # renders zero forever. Catching that after cutover means a day of lost telemetry;
  13. # catching it here costs a minute.
  14. #
  15. # Both workers declare the same D1 `database_id`, so pointing them at one
  16. # `--persist-to` directory gives them literally the same local SQLite file. The state
  17. # is a fresh mktemp each run, so every expected number below is exact rather than a
  18. # lower bound.
  19. #
  20. # npm run smoke:cutover
  21. #
  22. # Expected numbers are derived from THE_BATCH below and nothing else; see the table
  23. # in that comment block.
  24. set -uo pipefail
  25. cd "$(dirname "$0")/.."
  26. WORKER_DIR="$PWD"
  27. DASH_DIR="$(cd .. && pwd)/telemetry-dashboard"
  28. [ -d "$DASH_DIR" ] || { echo "cannot find telemetry-dashboard/ next to telemetry-worker/"; exit 1; }
  29. INGEST_PORT="${CUTOVER_INGEST_PORT:-8795}"
  30. DASH_PORT="${CUTOVER_DASH_PORT:-8796}"
  31. INGEST="http://127.0.0.1:$INGEST_PORT"
  32. DASH="http://127.0.0.1:$DASH_PORT"
  33. # Test-only credentials. The point is to exercise the wiring, not to keep a secret.
  34. ADMIN_TOKEN=cutover-admin-token
  35. DASH_PASSWORD=cutover-dashboard-password
  36. SESSION_SECRET=cutover-session-secret
  37. STATE="$(mktemp -d -t cg-cutover-state)"
  38. JAR="$(mktemp -t cg-cutover-jar)"
  39. ILOG=/tmp/cg-cutover-ingest.log
  40. DLOG=/tmp/cg-cutover-dash.log
  41. pass=0; fail=0
  42. ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
  43. bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
  44. is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
  45. DEV_PID=""
  46. stop_dev() {
  47. [ -n "$DEV_PID" ] || return 0
  48. kill "$DEV_PID" 2>/dev/null
  49. wait "$DEV_PID" 2>/dev/null
  50. DEV_PID=""
  51. }
  52. cleanup() { stop_dev; rm -rf "$STATE" "$JAR"; }
  53. trap cleanup EXIT
  54. # Boot a worker in <dir> on <port> against the SHARED state, wait for <readyurl>.
  55. boot() { # boot <dir> <port> <readyurl> <log> [extra wrangler args...]
  56. local dir="$1" port="$2" ready="$3" log="$4"; shift 4
  57. ( cd "$dir" && exec npx wrangler dev --port "$port" --ip 127.0.0.1 \
  58. --persist-to "$STATE" "$@" ) >"$log" 2>&1 &
  59. DEV_PID=$!
  60. for _ in $(seq 1 90); do
  61. curl -sf -o /dev/null "$ready" && return 0
  62. kill -0 "$DEV_PID" 2>/dev/null || break
  63. sleep 1
  64. done
  65. echo "worker in $dir never came up on :$port — log follows"; cat "$log"; exit 1
  66. }
  67. # Resolve a dotted path through a JSON document. Numeric segments index arrays.
  68. jget() {
  69. node -e '
  70. let v = JSON.parse(process.argv[1]);
  71. for (const k of process.argv[2].split(".")) v = v?.[k];
  72. console.log(v === undefined ? "<missing>" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
  73. ' "$1" "$2"
  74. }
  75. day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; }
  76. # Inside the ingest clamp window (30 days) and outside the cron's 3-day lookback.
  77. DAY="$(day_ago 5)"
  78. RANGE="from=$DAY&to=$DAY"
  79. # ---------------------------------------------------------------------------
  80. # THE_BATCH — three machines, one day. Everything asserted below follows from here.
  81. #
  82. # machine os arch node version ci events
  83. # m1 darwin arm64 22 1.5.0 false install(local/fresh, [claude,cursor])
  84. # index([typescript,python], 100-1k, 10-60s)
  85. # usage_rollup(codegraph_explore x12, Claude Code)
  86. # m2 linux x64 20 1.5.0 false install(global/upgrade, [codex])
  87. # index([typescript], 1k-10k, 1-5m)
  88. # usage_rollup(codegraph_explore x8, Codex CLI)
  89. # m3 linux arm64 22 1.4.1 TRUE index([go], <100, <10s)
  90. # uninstall([claude])
  91. #
  92. # The three deliberate traps:
  93. # * m3 is ci=true, so it counts as active but NOT as a production user.
  94. # * tool_calls must SUM the `count` prop (12 + 8 = 20), not count the 2 rows.
  95. # * m3's uninstall carries targets=[claude], so a `target` breakdown that forgets
  96. # to scope by event would report claude twice.
  97. # ---------------------------------------------------------------------------
  98. M1=11111111-1111-4111-8111-111111111111
  99. M2=22222222-2222-4222-8222-222222222222
  100. M3=33333333-3333-4333-8333-333333333333
  101. post_batch() { # post_batch <json>
  102. curl -s -o /dev/null -w '%{http_code}' -X POST "$INGEST/v1/events" \
  103. -H 'content-type: application/json' --data-binary "$1"
  104. }
  105. batch() { # batch <machine> <os> <arch> <node> <version> <ci> <events-json>
  106. node -e '
  107. const [m, os, arch, node_major, v, ci, events, day] = process.argv.slice(1);
  108. process.stdout.write(JSON.stringify({
  109. machine_id: m, codegraph_version: v, os, arch,
  110. node_major: Number(node_major), ci: ci === "true", schema_version: 1,
  111. events: JSON.parse(events).map((e) => ({ ...e, ts: `${day}T12:00:00Z` })),
  112. }));
  113. ' "$@" "$DAY"
  114. }
  115. # ---------------------------------------------------------------------------
  116. echo "cutover chain: client → ingest worker → D1 → rollup → dashboard"
  117. echo
  118. echo "migrating the shared local D1 state"
  119. ( cd "$WORKER_DIR" && npx wrangler d1 migrations apply codegraph-telemetry \
  120. --local --persist-to "$STATE" ) >/tmp/cg-cutover-migrate.log 2>&1 ||
  121. { echo "migration failed:"; cat /tmp/cg-cutover-migrate.log; exit 1; }
  122. echo "booting the ingest worker on :$INGEST_PORT"
  123. boot "$WORKER_DIR" "$INGEST_PORT" "$INGEST/" "$ILOG" --var "ADMIN_TOKEN:$ADMIN_TOKEN"
  124. echo
  125. echo "ingest accepts the batch"
  126. is "m1 batch → 204" 204 "$(post_batch "$(batch "$M1" darwin arm64 22 1.5.0 false '[
  127. {"event":"install","props":{"scope":"local","kind":"fresh","targets":["claude","cursor"]}},
  128. {"event":"index","props":{"languages":["typescript","python"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}},
  129. {"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Claude Code"}}
  130. ]')")"
  131. is "m2 batch → 204" 204 "$(post_batch "$(batch "$M2" linux x64 20 1.5.0 false '[
  132. {"event":"install","props":{"scope":"global","kind":"upgrade","targets":["codex"]}},
  133. {"event":"index","props":{"languages":["typescript"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}},
  134. {"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":8,"client_name":"Codex CLI"}}
  135. ]')")"
  136. is "m3 (ci) batch → 204" 204 "$(post_batch "$(batch "$M3" linux arm64 22 1.4.1 true '[
  137. {"event":"index","props":{"languages":["go"],"file_count_bucket":"<100","duration_bucket":"<10s"}},
  138. {"event":"uninstall","props":{"targets":["claude"]}}
  139. ]')")"
  140. sleep 2 # let the ctx.waitUntil writes drain before rolling up
  141. echo
  142. echo "the nightly rollup aggregates the day"
  143. ROLL=$(curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" "$INGEST/admin/rollup?day=$DAY")
  144. is "POST /admin/rollup → ok" true "$(jget "$ROLL" ok)"
  145. is "rollup wrote rows" true "$(node -e 'process.stdout.write(String((JSON.parse(process.argv[1]).rows ?? 0) > 0))' "$ROLL")"
  146. stop_dev # free the D1 lock before the dashboard opens the same file
  147. echo
  148. echo "booting the dashboard on :$DASH_PORT against the same D1"
  149. ( cd "$DASH_DIR" && npm run --silent vendor ) >/dev/null 2>&1
  150. boot "$DASH_DIR" "$DASH_PORT" "$DASH/robots.txt" "$DLOG" \
  151. --var "ADMIN_PASSWORD:$DASH_PASSWORD" --var "SESSION_SECRET:$SESSION_SECRET"
  152. curl -s -o /dev/null -c "$JAR" -X POST "$DASH/login" --data-urlencode "password=$DASH_PASSWORD"
  153. api() { curl -s -b "$JAR" "$DASH/api/$1"; }
  154. is "dashboard session established" 200 "$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/health")"
  155. # --- the big numbers -------------------------------------------------------
  156. echo
  157. echo "summary panel reads back what was ingested"
  158. S=$(api "summary?$RANGE")
  159. is "production users (ci machine excluded)" 2 "$(jget "$S" production_users)"
  160. is "active machines" 3 "$(jget "$S" active_machines)"
  161. is "new machines" 3 "$(jget "$S" new_machines)"
  162. is "installs" 2 "$(jget "$S" installs)"
  163. is "uninstalls" 1 "$(jget "$S" uninstalls)"
  164. is "indexing runs" 3 "$(jget "$S" index_runs)"
  165. is "tool calls SUM the count prop (12+8)" 20 "$(jget "$S" tool_calls)"
  166. # --- every dimension the dashboard offers ----------------------------------
  167. # This is the actual point of the suite: each of these is a distinct string that
  168. # must match between rollup.ts and api.ts's DIMS registry. An empty `labels` means
  169. # the dashboard is asking for a dimension the cron never writes.
  170. echo
  171. echo "every breakdown dimension resolves against the cron's output"
  172. bd() { # bd <desc> <query> <expected-labels-json> <expected-data-json>
  173. local body; body=$(api "breakdown?$RANGE&$2")
  174. is "$1 — labels" "$3" "$(jget "$body" labels)"
  175. is "$1 — data" "$4" "$(jget "$body" datasets.0.data)"
  176. }
  177. bd "os" "dim=os" '["linux","darwin"]' '[2,1]'
  178. bd "arch" "dim=arch" '["arm64","x64"]' '[2,1]'
  179. bd "version" "dim=codegraph_version" '["1.5.0","1.4.1"]' '[2,1]'
  180. bd "node major" "dim=node_major" '["22","20"]' '[2,1]'
  181. bd "language" "dim=language" '["typescript","go","python"]' '[2,1,1]'
  182. bd "files in project" "dim=file_count_bucket" '["<100","100-1k","1k-10k","10k+"]' '[1,1,1,0]'
  183. bd "run length" "dim=duration_bucket" '["<10s","10-60s","1-5m","5m+"]' '[1,1,1,0]'
  184. bd "install scope" "dim=scope" '["global","local"]' '[1,1]'
  185. bd "install kind" "dim=kind" '["fresh","upgrade"]' '[1,1]'
  186. bd "tool name" "dim=name" '["codegraph_explore"]' '[20]'
  187. bd "agent" "dim=client_name" '["Claude Code","Codex CLI"]' '[12,8]'
  188. # The trap: `target` defaults to event=install, so the uninstall's own claude target
  189. # must NOT be folded in — and must still be reachable by asking for it explicitly.
  190. bd "agent target (install-scoped)" "dim=target" '["claude","codex","cursor"]' '[1,1,1]'
  191. bd "agent target (uninstall)" "dim=target&event=uninstall" '["claude"]' '[1]'
  192. # --- the remaining panels --------------------------------------------------
  193. echo
  194. echo "the timeseries and funnel panels see the day"
  195. # Every entry in api.ts's SERIES registry — each one reads a different rollup table,
  196. # so this is the second half of the write-vs-read seam the breakdowns cover above.
  197. ts() { # ts <desc> <metric> <series-0> [series-1]
  198. local body; body=$(api "timeseries?$RANGE&metric=$2")
  199. is "$1 — day" "[\"$DAY\"]" "$(jget "$body" labels)"
  200. is "$1 — series" "$3" "$(jget "$body" datasets.0.data)"
  201. [ $# -ge 4 ] && is "$1 — second series" "$4" "$(jget "$body" datasets.1.data)"
  202. }
  203. ts "installs and uninstalls" installs_uninstalls '[2]' '[1]'
  204. ts "new installs" new_installs '[3]'
  205. ts "production users" production_users '[2]'
  206. ts "indexing activity" indexing_activity '[3]' '[3]'
  207. ts "tool calls (sums the prop)" tool_calls '[20]' '[2]'
  208. MET=$(api "meta")
  209. is "meta anchors on the rolled-up day" "$DAY" "$(jget "$MET" latest_day)"
  210. is "meta reports the rollup ran" "$DAY" "$(jget "$MET" latest_rollup_day)"
  211. # The funnel is the one panel that reads RAW events rather than a rollup, so it is
  212. # also the one the retention purge can blind — worth pinning that it works today.
  213. #
  214. # Its denominator is FIRST-SEEN MACHINES, not `install` events (api.ts: "a machine
  215. # that reinstalls does not re-enter the funnel"). m3 is the discriminator: it never
  216. # sent an install event, but it is new and it indexed, so it belongs in both legs.
  217. # Reading 2 here would mean the funnel had quietly become an install-event ratio.
  218. ACT=$(api "activation?$RANGE&window=1")
  219. is "funnel counts new machines, not install events" 3 "$(jget "$ACT" installs)"
  220. is "all three indexed within the window" 3 "$(jget "$ACT" activated)"
  221. is "nobody dropped out" 0 "$(jget "$ACT" dropped)"
  222. is "raw-event floor is reported to the caller" "$DAY" "$(jget "$ACT" raw_events_from)"
  223. is "retention endpoint answers" 200 \
  224. "$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/retention?$RANGE")"
  225. # --- the guarantee the cutover is selling ----------------------------------
  226. echo
  227. echo "the no-third-party guarantee still holds"
  228. is "ingest worker makes no outbound fetch" 0 \
  229. "$(grep -E 'fetch\(' "$WORKER_DIR"/src/*.ts | grep -vc 'async fetch(request' || true)"
  230. is "ingest worker names no third-party analytics endpoint" 0 \
  231. "$(grep -rEil 'https?://[a-z0-9.-]+/(batch|capture|collect|track|ingest)' \
  232. "$WORKER_DIR"/src "$WORKER_DIR"/wrangler.jsonc 2>/dev/null | wc -l | tr -d ' ')"
  233. echo
  234. if [ "$fail" -eq 0 ]; then
  235. echo "$pass passed, 0 failed — the chain is whole; safe to cut over"
  236. else
  237. echo "$pass passed, $fail failed"
  238. fi
  239. [ "$fail" -eq 0 ]