smoke-ingest.sh 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. #!/usr/bin/env bash
  2. # End-to-end check of the ingest contract against a real `wrangler dev` + local D1.
  3. #
  4. # Boots the worker, POSTs a spread of good and bad batches, then shuts the worker
  5. # down and inspects the rows that actually landed. Every request uses a fresh
  6. # machine_id, so the script is re-runnable against a dirty local database and never
  7. # trips the per-machine rate limit.
  8. #
  9. # npm run db:migrate:local # once
  10. # npm run smoke # or: INGEST_PORT=8791 ./scripts/smoke-ingest.sh
  11. set -euo pipefail
  12. cd "$(dirname "$0")/.."
  13. PORT="${INGEST_PORT:-8787}"
  14. BASE="http://127.0.0.1:$PORT"
  15. DB=codegraph-telemetry
  16. pass=0; fail=0
  17. ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
  18. bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
  19. is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
  20. uuid() { node -e 'console.log(crypto.randomUUID())'; }
  21. # HTTP status of a POST /v1/events with the given body.
  22. post() { curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
  23. -H 'content-type: application/json' --data-binary "$1"; }
  24. # First column of the first row of a query against the LOCAL D1 state.
  25. q() {
  26. npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
  27. node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
  28. const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
  29. console.log(r===undefined?"":String(Object.values(r)[0]));})'
  30. }
  31. # ---------------------------------------------------------------------------
  32. # Boot
  33. # ---------------------------------------------------------------------------
  34. echo "booting wrangler dev on :$PORT"
  35. npx wrangler dev --port "$PORT" >/tmp/cg-smoke-ingest.log 2>&1 &
  36. DEV_PID=$!
  37. cleanup() { kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true; }
  38. trap cleanup EXIT
  39. for _ in $(seq 1 60); do
  40. curl -sf -o /dev/null "$BASE/" && break
  41. kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
  42. sleep 1
  43. done
  44. curl -sf -o /dev/null "$BASE/" || { echo "worker never came up:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
  45. # ---------------------------------------------------------------------------
  46. # Request contract
  47. # ---------------------------------------------------------------------------
  48. echo
  49. echo "request contract"
  50. INFO=$(curl -s "$BASE/")
  51. case "$INFO" in *"codegraph anonymous-telemetry ingest"*) ok "GET / serves the info text";;
  52. *) bad "GET / serves the info text" "info text" "$INFO";; esac
  53. case "$INFO" in *"never forwarded to any third-party analytics"*) ok "info text states the storage guarantee";;
  54. *) bad "info text states the storage guarantee" "the no-third-party sentence" "missing";; esac
  55. # The guarantee above holds only while the worker makes no outbound request at all,
  56. # so the only `fetch(` anywhere in the source may be the handler's own declaration.
  57. is "worker source makes no outbound fetch" 0 \
  58. "$(grep -E 'fetch\(' src/*.ts | grep -vc 'async fetch(request' || true)"
  59. is "unknown path → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/nope")"
  60. is "GET /v1/events → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/events")"
  61. is "non-JSON body → 400" 400 "$(post 'not json')"
  62. is "JSON array body → 400" 400 "$(post '[]')"
  63. is "missing machine_id → 400" 400 "$(post '{"events":[]}')"
  64. is "malformed machine_id → 400" 400 "$(post '{"machine_id":"nope","events":[]}')"
  65. is "chunked (no length) → 411" 411 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
  66. -H 'content-type: application/json' -H 'transfer-encoding: chunked' --data-binary '{"machine_id":"x"}')"
  67. BIG=$(node -e 'process.stdout.write(JSON.stringify({machine_id:"00000000-0000-4000-8000-000000000000",pad:"x".repeat(70000),events:[]}))')
  68. is "oversized body → 413" 413 "$(post "$BIG")"
  69. # ---------------------------------------------------------------------------
  70. # Accepted batches
  71. # ---------------------------------------------------------------------------
  72. echo
  73. echo "ingest"
  74. M_OK=$(uuid); M_DROP=$(uuid); M_CI=$(uuid); M_BACK=$(uuid)
  75. TODAY=$(date -u +%F)
  76. # Three valid events + one unknown event + unknown/malformed props that must be stripped.
  77. is "valid batch → 204" 204 "$(post "$(node -e '
  78. const [m] = process.argv.slice(1);
  79. process.stdout.write(JSON.stringify({
  80. machine_id: m, codegraph_version: "1.5.0", os: "darwin", arch: "arm64",
  81. node_major: 22, ci: false, schema_version: 1, secret_field: "must not be stored",
  82. events: [
  83. { event: "install", ts: "2026-07-27T10:00:00Z",
  84. props: { scope: "local", kind: "fresh", targets: ["claude", "cursor"], nope: "strip me" } },
  85. { event: "index", ts: "2026-07-27T10:01:00Z",
  86. props: { languages: ["typescript"], file_count_bucket: "100-1k",
  87. duration_bucket: "bogus-bucket", repo_path: "/Users/someone/secret" } },
  88. { event: "usage_rollup",
  89. props: { kind: "mcp_tool", name: "codegraph_explore", count: 12, client_name: "Claude Code" } },
  90. { event: "not_an_event", props: { count: 1 } },
  91. ],
  92. }));' "$M_OK")")"
  93. # Nothing survives the allowlist: unknown event + usage_rollup missing required props.
  94. is "all-dropped batch → 204" 204 "$(post "$(node -e '
  95. const [m] = process.argv.slice(1);
  96. process.stdout.write(JSON.stringify({ machine_id: m, os: "linux", events: [
  97. { event: "made_up" },
  98. { event: "usage_rollup", props: { kind: "mcp_tool" } },
  99. { event: "install", props: { scope: "local" } },
  100. ]}));' "$M_DROP")")"
  101. # NOTE: build every body into a variable first. Escaped quotes nested inside
  102. # "$(post "…\"…\"…")" break out of the quoting context and get brace-expanded.
  103. index_batch() { # <machine_id> [ci] [ts]
  104. node -e 'const [m, ci, ts] = process.argv.slice(1);
  105. const e = { event: "index", props: {} };
  106. if (ts) e.ts = ts;
  107. const b = { machine_id: m, os: "linux", events: [e] };
  108. if (ci) b.ci = ci === "true";
  109. process.stdout.write(JSON.stringify(b));' "$@"
  110. }
  111. # ci = true, then a non-CI batch for the same machine/day: prod must flip 0 → 1.
  112. CI_ON=$(index_batch "$M_CI" true); CI_OFF=$(index_batch "$M_CI" false)
  113. is "ci batch → 204" 204 "$(post "$CI_ON")"
  114. is "same machine, non-ci → 204" 204 "$(post "$CI_OFF")"
  115. # A late offline buffer arriving second must move first_day EARLIER, never later.
  116. RECENT=$(index_batch "$M_BACK" "" 2026-07-27T09:00:00Z)
  117. BACKDATED=$(index_batch "$M_BACK" "" 2026-07-20T09:00:00Z)
  118. is "recent batch → 204" 204 "$(post "$RECENT")"
  119. is "backdated batch → 204" 204 "$(post "$BACKDATED")"
  120. sleep 2 # let the ctx.waitUntil writes drain
  121. cleanup; trap - EXIT
  122. sleep 1 # and let miniflare release the local sqlite file
  123. # ---------------------------------------------------------------------------
  124. # What actually got stored
  125. # ---------------------------------------------------------------------------
  126. echo
  127. echo "stored rows"
  128. is "3 of 4 events stored (unknown dropped)" 3 "$(q "select count(*) from events where machine_id='$M_OK'")"
  129. is "all-dropped batch stored nothing" 0 "$(q "select count(*) from events where machine_id='$M_DROP'")"
  130. is "…and no machine_days row for it" 0 "$(q "select count(*) from machine_days where machine_id='$M_DROP'")"
  131. is "envelope columns land in their own columns" "darwin|arm64|22|0|1.5.0" \
  132. "$(q "select os||'|'||arch||'|'||node_major||'|'||ci||'|'||codegraph_version from events where machine_id='$M_OK' limit 1")"
  133. is "day derived from the client ts" "2026-07-27" \
  134. "$(q "select day from events where machine_id='$M_OK' and event='install'")"
  135. is "day falls back to received_at when ts is absent" "$TODAY" \
  136. "$(q "select day from events where machine_id='$M_OK' and event='usage_rollup'")"
  137. is "ts is NULL when the client sent none" 1 \
  138. "$(q "select ts is null from events where machine_id='$M_OK' and event='usage_rollup'")"
  139. is "allowlisted props stored" "local|fresh|2" \
  140. "$(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'")"
  141. is "unknown prop stripped" 0 \
  142. "$(q "select count(*) from events where machine_id='$M_OK' and props like '%strip me%'")"
  143. is "malformed enum prop stripped" 0 \
  144. "$(q "select count(*) from events where machine_id='$M_OK' and props like '%bogus-bucket%'")"
  145. is "path-shaped prop stripped" 0 \
  146. "$(q "select count(*) from events where machine_id='$M_OK' and props like '%/Users/%'")"
  147. is "unknown envelope field stored nowhere" 0 \
  148. "$(q "select count(*) from events where props like '%must not be stored%'")"
  149. # The valid batch mixes ts-dated events (2026-07-27) with an undated rollup (today),
  150. # so it legitimately spans two days and must produce a machine_days row for each.
  151. is "machine_days: one row per distinct day in the batch" 2 \
  152. "$(q "select count(*) from machine_days where machine_id='$M_OK'")"
  153. is "machine_days: non-ci machine is production" 1 \
  154. "$(q "select min(prod) from machine_days where machine_id='$M_OK'")"
  155. is "machine_days: a later non-ci batch flips the day to production" 1 \
  156. "$(q "select prod from machine_days where machine_id='$M_CI'")"
  157. is "machine_days: each backdated batch gets its own day" "2026-07-20,2026-07-27" \
  158. "$(q "select group_concat(day) from (select day from machine_days where machine_id='$M_BACK' order by day)")"
  159. is "machine_first_seen recorded" "2026-07-27" "$(q "select first_day from machine_first_seen where machine_id='$M_OK'")"
  160. is "machine_first_seen only moves earlier" "2026-07-20" \
  161. "$(q "select first_day from machine_first_seen where machine_id='$M_BACK'")"
  162. echo
  163. echo "$pass passed, $fail failed"
  164. [ "$fail" -eq 0 ]