1
0

index.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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.
  7. *
  8. * Guarantees enforced here:
  9. * - strict allowlist: unknown events are dropped, unknown properties are stripped
  10. * - the client IP is never read, logged, or forwarded
  11. * - per-machine rate limiting, bounded body/batch sizes
  12. * - forwarding happens off the response path (ctx.waitUntil); bodies are never logged
  13. */
  14. const MAX_BODY_BYTES = 64 * 1024;
  15. const MAX_EVENTS_PER_BATCH = 100;
  16. const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  17. // Bare identifiers: tool/command/target/language names, versions.
  18. const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
  19. // Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
  20. const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
  21. const INFO_TEXT = `codegraph anonymous-telemetry ingest.
  22. What gets collected (and what never does) is documented field-by-field:
  23. https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
  24. This endpoint's full source:
  25. https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
  26. Disable any time: codegraph telemetry off | CODEGRAPH_TELEMETRY=0 | DO_NOT_TRACK=1
  27. `;
  28. type JsonObject = Record<string, unknown>;
  29. /** Returns the sanitized value, or undefined to strip the property. */
  30. type Sanitize = (v: unknown) => unknown;
  31. const oneOf =
  32. (allowed: readonly string[]): Sanitize =>
  33. (v) =>
  34. typeof v === 'string' && allowed.includes(v) ? v : undefined;
  35. const matching =
  36. (re: RegExp, maxLen: number): Sanitize =>
  37. (v) =>
  38. typeof v === 'string' && v.length > 0 && v.length <= maxLen && re.test(v) ? v : undefined;
  39. const token = (maxLen: number): Sanitize => matching(TOKEN_RE, maxLen);
  40. const label = (maxLen: number): Sanitize => matching(LABEL_RE, maxLen);
  41. const tokenArray =
  42. (maxItems: number, maxLen: number): Sanitize =>
  43. (v) =>
  44. Array.isArray(v) &&
  45. v.length <= maxItems &&
  46. v.every((s) => typeof s === 'string' && s.length > 0 && s.length <= maxLen && TOKEN_RE.test(s))
  47. ? v
  48. : undefined;
  49. const nonNegInt =
  50. (max: number): Sanitize =>
  51. (v) =>
  52. typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= max ? v : undefined;
  53. /**
  54. * THE allowlist. This mirrors docs/design/telemetry.md exactly — changing one
  55. * without the other is a bug. Anything not listed here does not exist as far
  56. * as this endpoint is concerned.
  57. */
  58. // `sqlite_backend` (`native`/`wasm`) below is a LEGACY field: pre-schema-v2 clients
  59. // (≤ June 2026) sent it, but node:sqlite is now the only backend so current clients
  60. // omit it. Kept here so old clients' events still validate; safe to drop once their
  61. // share is negligible. Never `required`.
  62. const EVENTS: Record<string, { required: readonly string[]; props: Record<string, Sanitize> }> = {
  63. install: {
  64. required: ['scope', 'kind'],
  65. props: {
  66. targets: tokenArray(12, 24),
  67. scope: oneOf(['local', 'global']),
  68. kind: oneOf(['fresh', 'upgrade', 'reinstall']),
  69. sqlite_backend: oneOf(['native', 'wasm']),
  70. },
  71. },
  72. index: {
  73. required: [],
  74. props: {
  75. languages: tokenArray(32, 24),
  76. file_count_bucket: oneOf(['<100', '100-1k', '1k-10k', '10k+']),
  77. duration_bucket: oneOf(['<10s', '10-60s', '1-5m', '5m+']),
  78. sqlite_backend: oneOf(['native', 'wasm']),
  79. },
  80. },
  81. usage_rollup: {
  82. required: ['kind', 'name', 'count'],
  83. props: {
  84. kind: oneOf(['mcp_tool', 'cli_command']),
  85. name: token(64),
  86. count: nonNegInt(1_000_000),
  87. error_count: nonNegInt(1_000_000),
  88. client_name: label(64),
  89. client_version: label(32),
  90. },
  91. },
  92. uninstall: {
  93. required: [],
  94. props: { targets: tokenArray(12, 24) },
  95. },
  96. };
  97. /** Envelope fields shared by every event in a batch (sanitized, all optional). */
  98. const ENVELOPE_PROPS: Record<string, Sanitize> = {
  99. codegraph_version: token(32),
  100. os: token(16),
  101. arch: token(16),
  102. node_major: nonNegInt(99),
  103. ci: (v) => (typeof v === 'boolean' ? v : undefined),
  104. schema_version: nonNegInt(99),
  105. };
  106. interface PostHogEvent {
  107. event: string;
  108. distinct_id: string;
  109. timestamp?: string;
  110. properties: JsonObject;
  111. }
  112. function clampTimestamp(v: unknown): string | undefined {
  113. if (typeof v !== 'string') return undefined;
  114. const t = Date.parse(v);
  115. if (!Number.isFinite(t)) return undefined;
  116. const now = Date.now();
  117. // Rollups arrive up to a few days late (offline buffers); reject implausible times.
  118. if (t > now + 10 * 60_000 || t < now - 30 * 86_400_000) return undefined;
  119. return new Date(t).toISOString();
  120. }
  121. function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): PostHogEvent | null {
  122. if (typeof raw !== 'object' || raw === null) return null;
  123. const e = raw as JsonObject;
  124. if (typeof e.event !== 'string') return null;
  125. const spec = EVENTS[e.event];
  126. if (!spec) return null;
  127. const rawProps = (typeof e.props === 'object' && e.props !== null ? e.props : {}) as JsonObject;
  128. const props: JsonObject = {};
  129. for (const [key, sanitize] of Object.entries(spec.props)) {
  130. const val = sanitize(rawProps[key]);
  131. if (val !== undefined) props[key] = val;
  132. }
  133. for (const req of spec.required) {
  134. if (!(req in props)) return null;
  135. }
  136. const out: PostHogEvent = {
  137. event: e.event,
  138. distinct_id: machineId,
  139. properties: {
  140. ...props,
  141. ...common,
  142. // Anonymous events: no person profiles, no geo enrichment.
  143. $process_person_profile: false,
  144. $geoip_disable: true,
  145. $lib: 'codegraph-telemetry-worker',
  146. },
  147. };
  148. const ts = clampTimestamp(e.ts);
  149. if (ts !== undefined) out.timestamp = ts;
  150. return out;
  151. }
  152. async function forwardToPostHog(env: Env, batch: PostHogEvent[]): Promise<void> {
  153. try {
  154. const res = await fetch(`${env.POSTHOG_HOST}/batch/`, {
  155. method: 'POST',
  156. headers: { 'content-type': 'application/json' },
  157. body: JSON.stringify({ api_key: env.POSTHOG_KEY, batch }),
  158. signal: AbortSignal.timeout(5000),
  159. });
  160. if (!res.ok) {
  161. console.error(JSON.stringify({ msg: 'posthog forward failed', status: res.status, events: batch.length }));
  162. }
  163. } catch (err) {
  164. console.error(JSON.stringify({ msg: 'posthog forward error', err: String(err), events: batch.length }));
  165. }
  166. }
  167. export default {
  168. async fetch(request, env, ctx): Promise<Response> {
  169. try {
  170. const url = new URL(request.url);
  171. if (request.method === 'GET' && url.pathname === '/') {
  172. return new Response(INFO_TEXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
  173. }
  174. if (url.pathname !== '/v1/events') {
  175. return new Response('not found\n', { status: 404 });
  176. }
  177. if (request.method !== 'POST') {
  178. return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
  179. }
  180. const contentLength = Number(request.headers.get('content-length'));
  181. if (!Number.isFinite(contentLength) || contentLength <= 0) {
  182. return new Response('length required\n', { status: 411 });
  183. }
  184. if (contentLength > MAX_BODY_BYTES) {
  185. return new Response('payload too large\n', { status: 413 });
  186. }
  187. let body: JsonObject;
  188. try {
  189. const text = await request.text();
  190. if (text.length > MAX_BODY_BYTES) return new Response('payload too large\n', { status: 413 });
  191. const parsed: unknown = JSON.parse(text);
  192. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
  193. return new Response('bad request\n', { status: 400 });
  194. }
  195. body = parsed as JsonObject;
  196. } catch {
  197. return new Response('bad request\n', { status: 400 });
  198. }
  199. const machineId = body.machine_id;
  200. if (typeof machineId !== 'string' || !UUID_RE.test(machineId)) {
  201. return new Response('bad request\n', { status: 400 });
  202. }
  203. // Best-effort rate limit; fails open — losing a data point beats losing availability.
  204. try {
  205. const { success } = await env.MACHINE_RATE_LIMITER.limit({ key: machineId });
  206. if (!success) return new Response('rate limited\n', { status: 429 });
  207. } catch (err) {
  208. console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
  209. }
  210. const common: JsonObject = {};
  211. for (const [key, sanitize] of Object.entries(ENVELOPE_PROPS)) {
  212. const val = sanitize(body[key]);
  213. if (val !== undefined) common[key] = val;
  214. }
  215. const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
  216. const batch: PostHogEvent[] = [];
  217. for (const raw of rawEvents) {
  218. const sanitized = sanitizeEvent(raw, machineId, common);
  219. if (sanitized) batch.push(sanitized);
  220. }
  221. if (batch.length > 0) {
  222. ctx.waitUntil(forwardToPostHog(env, batch));
  223. }
  224. // Accepted (including "everything was dropped by the allowlist") — the
  225. // client treats every response as final and never retries.
  226. return new Response(null, { status: 204 });
  227. } catch (err) {
  228. console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
  229. return new Response('internal error\n', { status: 500 });
  230. }
  231. },
  232. } satisfies ExportedHandler<Env>;