smoke-rollup.sh 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. #!/usr/bin/env bash
  2. # End-to-end check of the nightly rollup + retention purge against a real
  3. # `wrangler dev` and the local D1 state.
  4. #
  5. # Seeds three synthetic days of events straight into local D1 (the ingest path clamps
  6. # client timestamps to the last 30 days, so backdating far enough to exercise the purge
  7. # has to bypass it), drives the rollup through the admin endpoint and the cron handler,
  8. # then inspects what actually landed against hand-computed numbers.
  9. #
  10. # What it pins:
  11. # * rollup numbers match the events they came from, including the two that are easy
  12. # to get wrong — usage_rollup SUMs its `count` prop, and array props unnest
  13. # * running a day twice changes nothing (idempotent upserts, no double counting)
  14. # * ?reset=1 drops stale rollup rows on a live day and REFUSES to blank a day whose
  15. # raw events are already purged
  16. # * the purge deletes only rows past the window, and leaves machine_days /
  17. # machine_first_seen alone
  18. # * /admin/rollup does not exist without ADMIN_TOKEN, and rejects a wrong one
  19. #
  20. # Re-runnable: it wipes its own synthetic days first, and they are chosen to sit
  21. # outside the cron's 3-day lookback so the nightly run never rewrites them.
  22. #
  23. # npm run smoke:rollup # or: ROLLUP_PORT=8792 ./scripts/smoke-rollup.sh
  24. set -euo pipefail
  25. cd "$(dirname "$0")/.."
  26. PORT="${ROLLUP_PORT:-8788}"
  27. BASE="http://127.0.0.1:$PORT"
  28. DB=codegraph-telemetry
  29. TOKEN=smoke-admin-token
  30. SEED_SQL=/tmp/cg-smoke-rollup-seed.sql
  31. LOG=/tmp/cg-smoke-rollup.log
  32. pass=0; fail=0
  33. ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
  34. bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
  35. is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
  36. day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; }
  37. # Synthetic days. MAIN/RESET sit inside the 90-day retention window but outside the
  38. # cron's 3-day lookback; OLD sits past the window so the purge takes it.
  39. DAY_MAIN=$(day_ago 40)
  40. DAY_RESET=$(day_ago 41)
  41. DAY_OLD=$(day_ago 200)
  42. CUTOFF=$(day_ago 90)
  43. # First column of the first row of a query against the LOCAL D1 state.
  44. q() {
  45. npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
  46. node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
  47. const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
  48. console.log(r===undefined?"":String(Object.values(r)[0]));})'
  49. }
  50. # A daily_dim_counts cell as "count/machines" — "" when the row does not exist.
  51. dim() { q "select count||'/'||machines from daily_dim_counts
  52. where day='$1' and event='$2' and dim='$3' and value='$4'"; }
  53. # POST /admin/rollup, printing the HTTP status.
  54. roll() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?$1"; }
  55. boot() { # extra wrangler dev args
  56. npx wrangler dev --port "$PORT" "$@" >"$LOG" 2>&1 &
  57. DEV_PID=$!
  58. trap 'kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true' EXIT
  59. local up=
  60. for _ in $(seq 1 60); do
  61. curl -sf -o /dev/null "$BASE/" && { up=1; break; }
  62. kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat "$LOG"; exit 1; }
  63. sleep 1
  64. done
  65. [ -n "$up" ] || { echo "worker never came up:"; cat "$LOG"; exit 1; }
  66. # If wrangler could not bind the port, something else answers every probe and the
  67. # whole run silently grades a different server. Check who picked up.
  68. case "$(curl -s "$BASE/")" in
  69. *'codegraph anonymous-telemetry ingest'*) : ;;
  70. *) echo "port $PORT is serving something else — set ROLLUP_PORT to a free one"; exit 1 ;;
  71. esac
  72. }
  73. shutdown() {
  74. kill "$DEV_PID" 2>/dev/null || true
  75. wait "$DEV_PID" 2>/dev/null || true
  76. trap - EXIT
  77. sleep 1 # let miniflare release the local sqlite file
  78. }
  79. # ---------------------------------------------------------------------------
  80. # Seed
  81. # ---------------------------------------------------------------------------
  82. echo "applying migrations to local D1"
  83. npx wrangler d1 migrations apply "$DB" --local >/dev/null 2>&1
  84. echo "seeding $DAY_MAIN / $DAY_RESET / $DAY_OLD"
  85. node -e '
  86. const [main, reset, old, seedFile] = process.argv.slice(1);
  87. const sq = (v) => `'"'"'${String(v).replace(/'"'"'/g, "'"'"''"'"'")}'"'"'`;
  88. const M = ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222",
  89. "33333333-3333-4333-8333-333333333333", "44444444-4444-4444-8444-444444444444",
  90. "99999999-9999-4999-8999-999999999999"];
  91. const out = [];
  92. // Re-runnable: every table this script touches, scoped to its own synthetic days.
  93. for (const t of ["events", "daily_event_counts", "daily_dim_counts", "daily_machines", "machine_days"]) {
  94. out.push(`DELETE FROM ${t} WHERE day IN (${[main, reset, old].map(sq).join(", ")});`);
  95. }
  96. out.push(`DELETE FROM machine_first_seen WHERE machine_id IN (${M.map(sq).join(", ")});`);
  97. // day, machine, event, os, arch, version, node_major, ci, props
  98. const rows = [
  99. [main, M[0], "install", "darwin", "arm64", "1.5.0", 22, 0, {targets:["claude","cursor"], scope:"local", kind:"fresh"}],
  100. [main, M[0], "index", "darwin", "arm64", "1.5.0", 22, 0, {languages:["typescript","go"], file_count_bucket:"100-1k", duration_bucket:"10-60s"}],
  101. [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"}],
  102. [main, M[1], "index", "darwin", "x64", "1.5.0", 20, 0, {languages:["typescript"], file_count_bucket:"1k-10k", duration_bucket:"10-60s"}],
  103. [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"}],
  104. [main, M[2], "install", "linux", "x64", "1.4.1", 22, 1, {targets:["claude"], scope:"global", kind:"upgrade"}],
  105. [main, M[2], "uninstall", "linux", "x64", "1.4.1", 22, 1, {targets:["claude"]}],
  106. [reset, M[3], "index", "darwin", "arm64", "1.5.0", 22, 0, {languages:["python"], file_count_bucket:"<100", duration_bucket:"<10s"}],
  107. [old, M[4], "install", "linux", "x64", "1.0.0", 20, 0, {targets:["codex"], scope:"local", kind:"fresh"}],
  108. [old, M[4], "index", "linux", "x64", "1.0.0", 20, 0, {languages:["rust"], file_count_bucket:"<100", duration_bucket:"<10s"}],
  109. ];
  110. for (const [day, m, event, os, arch, version, node, ci, props] of rows) {
  111. out.push(`INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
  112. VALUES (${sq(day + "T12:00:00.000Z")}, ${sq(day + "T12:00:00.000Z")}, ${sq(day)}, ${sq(event)}, ${sq(m)},
  113. ${sq(version)}, ${sq(os)}, ${sq(arch)}, ${node}, ${ci}, 1, ${sq(JSON.stringify(props))});`);
  114. }
  115. // What the ingest path would have written alongside those events.
  116. 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]]) {
  117. out.push(`INSERT INTO machine_days (machine_id, day, prod) VALUES (${sq(m)}, ${sq(day)}, ${prod});`);
  118. out.push(`INSERT INTO machine_first_seen (machine_id, first_day) VALUES (${sq(m)}, ${sq(day)})
  119. ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day);`);
  120. }
  121. // A rollup row from a dimension that no longer exists — only ?reset=1 should clear it.
  122. out.push(`INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
  123. VALUES (${sq(reset)}, ${sq("index")}, ${sq("obsolete_dim")}, ${sq("stale")}, 99, 99);`);
  124. require("fs").writeFileSync(seedFile, out.join("\n"));
  125. ' "$DAY_MAIN" "$DAY_RESET" "$DAY_OLD" "$SEED_SQL"
  126. npx wrangler d1 execute "$DB" --local --file "$SEED_SQL" >/dev/null
  127. # ---------------------------------------------------------------------------
  128. # The admin route does not exist without a token
  129. # ---------------------------------------------------------------------------
  130. echo
  131. echo "admin route, no ADMIN_TOKEN configured"
  132. boot
  133. is "POST /admin/rollup → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
  134. is "…even with a token header" 404 \
  135. "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
  136. shutdown
  137. # ---------------------------------------------------------------------------
  138. # Drive the rollup
  139. # ---------------------------------------------------------------------------
  140. echo
  141. echo "admin route, ADMIN_TOKEN configured"
  142. boot --test-scheduled --var "ADMIN_TOKEN:$TOKEN"
  143. is "no token → 401" 401 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
  144. is "wrong token → 401" 401 \
  145. "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'x-admin-token: nope' "$BASE/admin/rollup")"
  146. is "GET → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
  147. is "impossible day → 400" 400 "$(roll 'day=2026-02-31')"
  148. is "malformed day → 400" 400 "$(roll 'day=yesterday')"
  149. is "days out of range → 400" 400 "$(roll "day=$DAY_MAIN&days=99")"
  150. echo
  151. echo "rollup"
  152. is "rollup $DAY_MAIN → 200" 200 "$(roll "day=$DAY_MAIN")"
  153. is "rollup $DAY_MAIN again → 200" 200 "$(roll "day=$DAY_MAIN")"
  154. is "rollup $DAY_OLD, whose events are still there → 200" 200 "$(roll "day=$DAY_OLD")"
  155. is "rollup $DAY_RESET with reset → 200" 200 "$(roll "day=$DAY_RESET&reset=1")"
  156. # The cron body: rolls up the last three days and purges everything past the window.
  157. is "cron trigger → 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/__scheduled?cron=30+0+*+*+*")"
  158. sleep 2
  159. # Rolling a purged day with reset=1 must NOT blank the rollups it already has: past
  160. # the window the reset is ignored, so the delete-then-rebuild can't find zero events.
  161. is "rollup $DAY_OLD after the purge, with reset → 200" 200 "$(roll "day=$DAY_OLD&reset=1")"
  162. is "…and reports the reset it refused to run" "[\"$DAY_OLD\"]" \
  163. "$(curl -s -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?day=$DAY_OLD&reset=1" |
  164. node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s).reset_ignored)))')"
  165. shutdown
  166. # ---------------------------------------------------------------------------
  167. # What actually landed — every number below is hand-computed from the seed above
  168. # ---------------------------------------------------------------------------
  169. echo
  170. echo "daily_machines"
  171. is "3 machines, 2 of them production (one is ci)" "3/2" \
  172. "$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_MAIN'")"
  173. echo
  174. echo "daily_event_counts"
  175. is "install: 2 events from 2 machines" "2/2" \
  176. "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='install'")"
  177. is "index: 2 events from 2 machines" "2/2" \
  178. "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
  179. is "uninstall: 1 event from 1 machine" "1/1" \
  180. "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='uninstall'")"
  181. # The one that is easy to get wrong: 2 rows carrying count 10 and 5 is 15 tool calls.
  182. is "usage_rollup: SUMs the count prop (10+5), not the rows" "15/2" \
  183. "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
  184. is "one row per event type" 4 "$(q "select count(*) from daily_event_counts where day='$DAY_MAIN'")"
  185. echo
  186. echo "daily_dim_counts"
  187. is "os / index / darwin" "2/2" "$(dim "$DAY_MAIN" index os darwin)"
  188. is "os / usage_rollup / darwin sums counts" "15/2" "$(dim "$DAY_MAIN" usage_rollup os darwin)"
  189. is "arch / uninstall / x64" "1/1" "$(dim "$DAY_MAIN" uninstall arch x64)"
  190. is "codegraph_version / index / 1.5.0" "2/2" "$(dim "$DAY_MAIN" index codegraph_version 1.5.0)"
  191. is "node_major / install / 22 (stored as text)" "2/2" "$(dim "$DAY_MAIN" install node_major 22)"
  192. is "file_count_bucket / index / 100-1k" "1/1" "$(dim "$DAY_MAIN" index file_count_bucket 100-1k)"
  193. is "duration_bucket / index / 10-60s" "2/2" "$(dim "$DAY_MAIN" index duration_bucket 10-60s)"
  194. is "scope / install / global" "1/1" "$(dim "$DAY_MAIN" install scope global)"
  195. is "kind / install / fresh" "1/1" "$(dim "$DAY_MAIN" install kind fresh)"
  196. is "kind / usage_rollup / mcp_tool (same dim, other event)" "15/2" "$(dim "$DAY_MAIN" usage_rollup kind mcp_tool)"
  197. is "name / usage_rollup / codegraph_explore" "15/2" "$(dim "$DAY_MAIN" usage_rollup name codegraph_explore)"
  198. is "client_name / usage_rollup / Claude Code" "10/1" "$(dim "$DAY_MAIN" usage_rollup client_name 'Claude Code')"
  199. # languages and targets are JSON arrays: one row per element, counted once per event.
  200. is "language / index / typescript (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" index language typescript)"
  201. is "language / index / go (unnested, 1 event)" "1/1" "$(dim "$DAY_MAIN" index language go)"
  202. is "target / install / claude (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" install target claude)"
  203. is "target / install / cursor" "1/1" "$(dim "$DAY_MAIN" install target cursor)"
  204. is "target / uninstall / claude" "1/1" "$(dim "$DAY_MAIN" uninstall target claude)"
  205. # Only groups with at least one error are stored, so machines = machines that saw one.
  206. is "name_error / usage_rollup / codegraph_explore" "2/1" "$(dim "$DAY_MAIN" usage_rollup name_error codegraph_explore)"
  207. is "no dimension row for a machine with no errors" "" "$(dim "$DAY_MAIN" usage_rollup name_error nothing)"
  208. is "40 dimension rows in total (no strays, no doubles)" 40 \
  209. "$(q "select count(*) from daily_dim_counts where day='$DAY_MAIN'")"
  210. # Independent of the hand-computed numbers: recompute two of them straight off `events`.
  211. echo
  212. echo "cross-check against the raw events"
  213. is "machines matches count(distinct machine_id)" \
  214. "$(q "select count(distinct machine_id) from events where day='$DAY_MAIN' and event='index'")" \
  215. "$(q "select machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
  216. is "usage count matches sum(props.count)" \
  217. "$(q "select sum(json_extract(props,'\$.count')) from events where day='$DAY_MAIN' and event='usage_rollup'")" \
  218. "$(q "select count from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
  219. echo
  220. echo "reset"
  221. is "?reset=1 drops a rollup row whose dimension no longer exists" 0 \
  222. "$(q "select count(*) from daily_dim_counts where day='$DAY_RESET' and dim='obsolete_dim'")"
  223. is "…and recomputes the day correctly" "1/1" "$(dim "$DAY_RESET" index language python)"
  224. is "…leaving exactly the 7 dimensions that day has" 7 \
  225. "$(q "select count(*) from daily_dim_counts where day='$DAY_RESET'")"
  226. echo
  227. echo "retention purge"
  228. is "raw events past the window are gone" 0 "$(q "select count(*) from events where day='$DAY_OLD'")"
  229. is "nothing older than the cutoff survives" 0 "$(q "select count(*) from events where day<'$CUTOFF'")"
  230. is "events inside the window are untouched" 7 "$(q "select count(*) from events where day='$DAY_MAIN'")"
  231. is "machine_days is NOT purged (retention cohorts need it)" 1 \
  232. "$(q "select count(*) from machine_days where day='$DAY_OLD'")"
  233. is "machine_first_seen is NOT purged" "$DAY_OLD" \
  234. "$(q "select first_day from machine_first_seen where machine_id='99999999-9999-4999-8999-999999999999'")"
  235. echo
  236. echo "rollups outlive the events they came from"
  237. is "daily_event_counts survives the purge" "1/1" \
  238. "$(q "select count||'/'||machines from daily_event_counts where day='$DAY_OLD' and event='index'")"
  239. is "daily_dim_counts survives the purge" "1/1" "$(dim "$DAY_OLD" index language rust)"
  240. is "…all 14 rows of it, even after a reset run over the purged day" 14 \
  241. "$(q "select count(*) from daily_dim_counts where day='$DAY_OLD'")"
  242. is "daily_machines is still rebuilt for a purged day (machine_days survives)" "1/1" \
  243. "$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_OLD'")"
  244. echo
  245. echo "$pass passed, $fail failed"
  246. [ "$fail" -eq 0 ]