index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. /**
  2. * codegraph telemetry ingest — telemetry.getcodegraph.com
  3. *
  4. * This file is public on purpose: it is the exact code that receives codegraph's
  5. * anonymous usage telemetry, so anyone can audit what is (and is not) stored.
  6. * The schema contract lives in docs/design/telemetry.md; the storage schema — the
  7. * complete list of what is kept — is migrations/0001_init.sql.
  8. *
  9. * Guarantees enforced here:
  10. * - strict allowlist: unknown events are dropped, unknown properties are stripped
  11. * - the client IP is never read, logged, or stored
  12. * - accepted events land in our own Cloudflare D1 database and are never forwarded
  13. * to a third-party analytics vendor — this worker makes no outbound requests
  14. * - per-machine rate limiting, bounded body/batch sizes
  15. * - the write happens off the response path (ctx.waitUntil); bodies are never logged
  16. * - raw events expire: a nightly cron rolls each day up into anonymous daily counts
  17. * and then deletes the rows behind it (rollup.ts)
  18. */
  19. import { handleAdminRollup, retentionDays, runNightly } from './rollup';
  20. const MAX_BODY_BYTES = 64 * 1024;
  21. const MAX_EVENTS_PER_BATCH = 100;
  22. const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  23. // Bare identifiers: tool/command/target/language names, versions.
  24. const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
  25. // Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
  26. const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
  27. const infoText = (keepDays: number): string => `codegraph anonymous-telemetry ingest.
  28. What gets collected (and what never does) is documented field-by-field:
  29. https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
  30. This endpoint's full source:
  31. https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
  32. Guarantees: no code, file paths, repo/file/symbol names, or query strings are ever
  33. sent; the client IP is never read or stored; the machine ID is a random UUID the
  34. client mints locally and can delete at any time. Accepted events are stored in our
  35. own database on Cloudflare (D1) and are never forwarded to any third-party analytics
  36. vendor. The stored schema is the complete list of what is kept:
  37. https://github.com/colbymchenry/codegraph/blob/main/telemetry-worker/migrations/0001_init.sql
  38. Individual events are deleted after ${keepDays} days. What outlives them: anonymous
  39. daily totals (counts per day of things like operating system, version and language),
  40. and which days each machine ID was active, so returning-user numbers survive. No event
  41. details, and still nothing that identifies a person or a codebase.
  42. Disable any time: codegraph telemetry off | CODEGRAPH_TELEMETRY=0 | DO_NOT_TRACK=1
  43. `;
  44. type JsonObject = Record<string, unknown>;
  45. /** Returns the sanitized value, or undefined to strip the property. */
  46. type Sanitize = (v: unknown) => unknown;
  47. const oneOf =
  48. (allowed: readonly string[]): Sanitize =>
  49. (v) =>
  50. typeof v === 'string' && allowed.includes(v) ? v : undefined;
  51. const matching =
  52. (re: RegExp, maxLen: number): Sanitize =>
  53. (v) =>
  54. typeof v === 'string' && v.length > 0 && v.length <= maxLen && re.test(v) ? v : undefined;
  55. const token = (maxLen: number): Sanitize => matching(TOKEN_RE, maxLen);
  56. const label = (maxLen: number): Sanitize => matching(LABEL_RE, maxLen);
  57. const tokenArray =
  58. (maxItems: number, maxLen: number): Sanitize =>
  59. (v) =>
  60. Array.isArray(v) &&
  61. v.length <= maxItems &&
  62. v.every((s) => typeof s === 'string' && s.length > 0 && s.length <= maxLen && TOKEN_RE.test(s))
  63. ? v
  64. : undefined;
  65. const nonNegInt =
  66. (max: number): Sanitize =>
  67. (v) =>
  68. typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= max ? v : undefined;
  69. /**
  70. * THE allowlist. This mirrors docs/design/telemetry.md exactly — changing one
  71. * without the other is a bug. Anything not listed here does not exist as far
  72. * as this endpoint is concerned.
  73. */
  74. // `sqlite_backend` (`native`/`wasm`) below is a LEGACY field: pre-schema-v2 clients
  75. // (≤ June 2026) sent it, but node:sqlite is now the only backend so current clients
  76. // omit it. Kept here so old clients' events still validate; safe to drop once their
  77. // share is negligible. Never `required`.
  78. const EVENTS: Record<string, { required: readonly string[]; props: Record<string, Sanitize> }> = {
  79. install: {
  80. required: ['scope', 'kind'],
  81. props: {
  82. targets: tokenArray(12, 24),
  83. scope: oneOf(['local', 'global']),
  84. kind: oneOf(['fresh', 'upgrade', 'reinstall']),
  85. sqlite_backend: oneOf(['native', 'wasm']),
  86. },
  87. },
  88. index: {
  89. required: [],
  90. props: {
  91. languages: tokenArray(32, 24),
  92. file_count_bucket: oneOf(['<100', '100-1k', '1k-10k', '10k+']),
  93. duration_bucket: oneOf(['<10s', '10-60s', '1-5m', '5m+']),
  94. sqlite_backend: oneOf(['native', 'wasm']),
  95. },
  96. },
  97. usage_rollup: {
  98. required: ['kind', 'name', 'count'],
  99. props: {
  100. kind: oneOf(['mcp_tool', 'cli_command']),
  101. name: token(64),
  102. count: nonNegInt(1_000_000),
  103. error_count: nonNegInt(1_000_000),
  104. client_name: label(64),
  105. client_version: label(32),
  106. },
  107. },
  108. uninstall: {
  109. required: [],
  110. props: { targets: tokenArray(12, 24) },
  111. },
  112. };
  113. /** Envelope fields shared by every event in a batch (sanitized, all optional). */
  114. const ENVELOPE_PROPS: Record<string, Sanitize> = {
  115. codegraph_version: token(32),
  116. os: token(16),
  117. arch: token(16),
  118. node_major: nonNegInt(99),
  119. ci: (v) => (typeof v === 'boolean' ? v : undefined),
  120. schema_version: nonNegInt(99),
  121. };
  122. /**
  123. * One sanitized event, ready to become one `events` row. The envelope is NOT
  124. * folded in here: it is identical for every event in a batch and lands in its own
  125. * columns, so it is carried alongside (`common`) and bound at write time.
  126. */
  127. interface StoredEvent {
  128. event: string;
  129. /** Clamped ISO 8601 UTC; absent when the client sent none or sent nonsense. */
  130. ts?: string;
  131. /** Event-specific props only — stored as the `props` JSON column. */
  132. props: JsonObject;
  133. }
  134. function clampTimestamp(v: unknown): string | undefined {
  135. if (typeof v !== 'string') return undefined;
  136. const t = Date.parse(v);
  137. if (!Number.isFinite(t)) return undefined;
  138. const now = Date.now();
  139. // Rollups arrive up to a few days late (offline buffers); reject implausible times.
  140. if (t > now + 10 * 60_000 || t < now - 30 * 86_400_000) return undefined;
  141. return new Date(t).toISOString();
  142. }
  143. function sanitizeEvent(raw: unknown): StoredEvent | null {
  144. if (typeof raw !== 'object' || raw === null) return null;
  145. const e = raw as JsonObject;
  146. if (typeof e.event !== 'string') return null;
  147. const spec = EVENTS[e.event];
  148. if (!spec) return null;
  149. const rawProps = (typeof e.props === 'object' && e.props !== null ? e.props : {}) as JsonObject;
  150. const props: JsonObject = {};
  151. for (const [key, sanitize] of Object.entries(spec.props)) {
  152. const val = sanitize(rawProps[key]);
  153. if (val !== undefined) props[key] = val;
  154. }
  155. for (const req of spec.required) {
  156. if (!(req in props)) return null;
  157. }
  158. const out: StoredEvent = { event: e.event, props };
  159. const ts = clampTimestamp(e.ts);
  160. if (ts !== undefined) out.ts = ts;
  161. return out;
  162. }
  163. /**
  164. * Re-narrow a sanitized envelope value for binding. The ENVELOPE_PROPS sanitizers
  165. * already guarantee these types; these just turn "absent" into a NULL bind.
  166. */
  167. const asText = (v: unknown): string | null => (typeof v === 'string' ? v : null);
  168. const asInt = (v: unknown): number | null => (typeof v === 'number' ? v : null);
  169. const asFlag = (v: unknown): number | null => (typeof v === 'boolean' ? (v ? 1 : 0) : null);
  170. const INSERT_EVENT = `INSERT INTO events (
  171. received_at, ts, day, event, machine_id,
  172. codegraph_version, os, arch, node_major, ci, schema_version, props
  173. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
  174. // prod = 0 only if EVERY event this machine sent that day carried ci = 1, so a later
  175. // non-CI batch flips the day to production and never back (max, not overwrite).
  176. const UPSERT_MACHINE_DAY = `INSERT INTO machine_days (machine_id, day, prod) VALUES (?, ?, ?)
  177. ON CONFLICT (machine_id, day) DO UPDATE SET prod = max(machine_days.prod, excluded.prod)`;
  178. // A late-arriving offline buffer can move a machine's first day earlier, never later.
  179. const UPSERT_FIRST_SEEN = `INSERT INTO machine_first_seen (machine_id, first_day) VALUES (?, ?)
  180. ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day)`;
  181. /**
  182. * Persist a sanitized batch: one `events` row per event, plus the machine×day and
  183. * first-seen bookkeeping the dashboard's retention/activation panels need. One D1
  184. * `batch()` = one implicit transaction = one round trip.
  185. *
  186. * Fail-silent by design: the client treats every response as final and never retries,
  187. * so a failed write loses a datapoint rather than costing availability. The error is
  188. * logged (Workers Logs) with counts only — never the payload.
  189. */
  190. async function writeToD1(
  191. env: Env,
  192. machineId: string,
  193. common: JsonObject,
  194. batch: StoredEvent[],
  195. ): Promise<void> {
  196. try {
  197. const receivedAt = new Date().toISOString();
  198. const insertEvent = env.DB.prepare(INSERT_EVENT);
  199. const stmts: D1PreparedStatement[] = [];
  200. // Envelope columns are identical for every row in the batch.
  201. const envelopeCols = [
  202. asText(common.codegraph_version),
  203. asText(common.os),
  204. asText(common.arch),
  205. asInt(common.node_major),
  206. asFlag(common.ci),
  207. asInt(common.schema_version),
  208. ] as const;
  209. // A batch can span days (offline buffers hold completed-day rollups), so
  210. // machine_days gets one row per distinct day rather than one per batch.
  211. const days = new Set<string>();
  212. for (const e of batch) {
  213. const day = (e.ts ?? receivedAt).slice(0, 10);
  214. days.add(day);
  215. stmts.push(
  216. insertEvent.bind(
  217. receivedAt,
  218. e.ts ?? null,
  219. day,
  220. e.event,
  221. machineId,
  222. ...envelopeCols,
  223. JSON.stringify(e.props),
  224. ),
  225. );
  226. }
  227. const prod = common.ci === true ? 0 : 1;
  228. const upsertDay = env.DB.prepare(UPSERT_MACHINE_DAY);
  229. for (const day of days) stmts.push(upsertDay.bind(machineId, day, prod));
  230. const firstDay = [...days].sort()[0];
  231. if (firstDay !== undefined) {
  232. stmts.push(env.DB.prepare(UPSERT_FIRST_SEEN).bind(machineId, firstDay));
  233. }
  234. await env.DB.batch(stmts);
  235. } catch (err) {
  236. console.error(JSON.stringify({ msg: 'd1 write failed', err: String(err), events: batch.length }));
  237. }
  238. }
  239. export default {
  240. async fetch(request, env, ctx): Promise<Response> {
  241. try {
  242. const url = new URL(request.url);
  243. if (request.method === 'GET' && url.pathname === '/') {
  244. return new Response(infoText(retentionDays(env)), {
  245. headers: { 'content-type': 'text/plain; charset=utf-8' },
  246. });
  247. }
  248. // Backfill/repair for the nightly rollup. 404s unless ADMIN_TOKEN is configured.
  249. if (url.pathname === '/admin/rollup') {
  250. return await handleAdminRollup(request, env, url);
  251. }
  252. if (url.pathname !== '/v1/events') {
  253. return new Response('not found\n', { status: 404 });
  254. }
  255. if (request.method !== 'POST') {
  256. return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
  257. }
  258. const contentLength = Number(request.headers.get('content-length'));
  259. if (!Number.isFinite(contentLength) || contentLength <= 0) {
  260. return new Response('length required\n', { status: 411 });
  261. }
  262. if (contentLength > MAX_BODY_BYTES) {
  263. return new Response('payload too large\n', { status: 413 });
  264. }
  265. let body: JsonObject;
  266. try {
  267. const text = await request.text();
  268. if (text.length > MAX_BODY_BYTES) return new Response('payload too large\n', { status: 413 });
  269. const parsed: unknown = JSON.parse(text);
  270. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
  271. return new Response('bad request\n', { status: 400 });
  272. }
  273. body = parsed as JsonObject;
  274. } catch {
  275. return new Response('bad request\n', { status: 400 });
  276. }
  277. const machineId = body.machine_id;
  278. if (typeof machineId !== 'string' || !UUID_RE.test(machineId)) {
  279. return new Response('bad request\n', { status: 400 });
  280. }
  281. // Best-effort rate limit; fails open — losing a data point beats losing availability.
  282. try {
  283. const { success } = await env.MACHINE_RATE_LIMITER.limit({ key: machineId });
  284. if (!success) return new Response('rate limited\n', { status: 429 });
  285. } catch (err) {
  286. console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
  287. }
  288. const common: JsonObject = {};
  289. for (const [key, sanitize] of Object.entries(ENVELOPE_PROPS)) {
  290. const val = sanitize(body[key]);
  291. if (val !== undefined) common[key] = val;
  292. }
  293. const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
  294. const batch: StoredEvent[] = [];
  295. for (const raw of rawEvents) {
  296. const sanitized = sanitizeEvent(raw);
  297. if (sanitized) batch.push(sanitized);
  298. }
  299. // Nothing survived the allowlist ⇒ nothing is written at all, not even the
  300. // machine×day bookkeeping: those tables must only ever describe stored events.
  301. if (batch.length > 0) {
  302. ctx.waitUntil(writeToD1(env, machineId, common, batch));
  303. }
  304. // Accepted (including "everything was dropped by the allowlist") — the
  305. // client treats every response as final and never retries.
  306. return new Response(null, { status: 204 });
  307. } catch (err) {
  308. console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
  309. return new Response('internal error\n', { status: 500 });
  310. }
  311. },
  312. /**
  313. * Nightly (00:30 UTC, see wrangler.jsonc): roll the completed day up into the
  314. * daily_* tables and purge raw events past the retention window. Awaited rather
  315. * than backgrounded so a failure marks the cron run failed — everything it does is
  316. * an idempotent upsert or a bounded delete, so the retry is safe.
  317. */
  318. async scheduled(event, env): Promise<void> {
  319. await runNightly(env, event.scheduledTime);
  320. },
  321. } satisfies ExportedHandler<Env>;