| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- /**
- * codegraph telemetry ingest — telemetry.getcodegraph.com
- *
- * This file is public on purpose: it is the exact code that receives codegraph's
- * anonymous usage telemetry, so anyone can audit what is (and is not) stored.
- * The schema contract lives in docs/design/telemetry.md.
- *
- * Guarantees enforced here:
- * - strict allowlist: unknown events are dropped, unknown properties are stripped
- * - the client IP is never read, logged, or forwarded
- * - per-machine rate limiting, bounded body/batch sizes
- * - forwarding happens off the response path (ctx.waitUntil); bodies are never logged
- */
- const MAX_BODY_BYTES = 64 * 1024;
- const MAX_EVENTS_PER_BATCH = 100;
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- // Bare identifiers: tool/command/target/language names, versions.
- const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
- // Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
- const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
- const INFO_TEXT = `codegraph anonymous-telemetry ingest.
- What gets collected (and what never does) is documented field-by-field:
- https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
- This endpoint's full source:
- https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
- Disable any time: codegraph telemetry off | CODEGRAPH_TELEMETRY=0 | DO_NOT_TRACK=1
- `;
- type JsonObject = Record<string, unknown>;
- /** Returns the sanitized value, or undefined to strip the property. */
- type Sanitize = (v: unknown) => unknown;
- const oneOf =
- (allowed: readonly string[]): Sanitize =>
- (v) =>
- typeof v === 'string' && allowed.includes(v) ? v : undefined;
- const matching =
- (re: RegExp, maxLen: number): Sanitize =>
- (v) =>
- typeof v === 'string' && v.length > 0 && v.length <= maxLen && re.test(v) ? v : undefined;
- const token = (maxLen: number): Sanitize => matching(TOKEN_RE, maxLen);
- const label = (maxLen: number): Sanitize => matching(LABEL_RE, maxLen);
- const tokenArray =
- (maxItems: number, maxLen: number): Sanitize =>
- (v) =>
- Array.isArray(v) &&
- v.length <= maxItems &&
- v.every((s) => typeof s === 'string' && s.length > 0 && s.length <= maxLen && TOKEN_RE.test(s))
- ? v
- : undefined;
- const nonNegInt =
- (max: number): Sanitize =>
- (v) =>
- typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= max ? v : undefined;
- /**
- * THE allowlist. This mirrors docs/design/telemetry.md exactly — changing one
- * without the other is a bug. Anything not listed here does not exist as far
- * as this endpoint is concerned.
- */
- // `sqlite_backend` (`native`/`wasm`) below is a LEGACY field: pre-schema-v2 clients
- // (≤ June 2026) sent it, but node:sqlite is now the only backend so current clients
- // omit it. Kept here so old clients' events still validate; safe to drop once their
- // share is negligible. Never `required`.
- const EVENTS: Record<string, { required: readonly string[]; props: Record<string, Sanitize> }> = {
- install: {
- required: ['scope', 'kind'],
- props: {
- targets: tokenArray(12, 24),
- scope: oneOf(['local', 'global']),
- kind: oneOf(['fresh', 'upgrade', 'reinstall']),
- sqlite_backend: oneOf(['native', 'wasm']),
- },
- },
- index: {
- required: [],
- props: {
- languages: tokenArray(32, 24),
- file_count_bucket: oneOf(['<100', '100-1k', '1k-10k', '10k+']),
- duration_bucket: oneOf(['<10s', '10-60s', '1-5m', '5m+']),
- sqlite_backend: oneOf(['native', 'wasm']),
- },
- },
- usage_rollup: {
- required: ['kind', 'name', 'count'],
- props: {
- kind: oneOf(['mcp_tool', 'cli_command']),
- name: token(64),
- count: nonNegInt(1_000_000),
- error_count: nonNegInt(1_000_000),
- client_name: label(64),
- client_version: label(32),
- },
- },
- uninstall: {
- required: [],
- props: { targets: tokenArray(12, 24) },
- },
- };
- /** Envelope fields shared by every event in a batch (sanitized, all optional). */
- const ENVELOPE_PROPS: Record<string, Sanitize> = {
- codegraph_version: token(32),
- os: token(16),
- arch: token(16),
- node_major: nonNegInt(99),
- ci: (v) => (typeof v === 'boolean' ? v : undefined),
- schema_version: nonNegInt(99),
- };
- interface PostHogEvent {
- event: string;
- distinct_id: string;
- timestamp?: string;
- properties: JsonObject;
- }
- function clampTimestamp(v: unknown): string | undefined {
- if (typeof v !== 'string') return undefined;
- const t = Date.parse(v);
- if (!Number.isFinite(t)) return undefined;
- const now = Date.now();
- // Rollups arrive up to a few days late (offline buffers); reject implausible times.
- if (t > now + 10 * 60_000 || t < now - 30 * 86_400_000) return undefined;
- return new Date(t).toISOString();
- }
- function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): PostHogEvent | null {
- if (typeof raw !== 'object' || raw === null) return null;
- const e = raw as JsonObject;
- if (typeof e.event !== 'string') return null;
- const spec = EVENTS[e.event];
- if (!spec) return null;
- const rawProps = (typeof e.props === 'object' && e.props !== null ? e.props : {}) as JsonObject;
- const props: JsonObject = {};
- for (const [key, sanitize] of Object.entries(spec.props)) {
- const val = sanitize(rawProps[key]);
- if (val !== undefined) props[key] = val;
- }
- for (const req of spec.required) {
- if (!(req in props)) return null;
- }
- const out: PostHogEvent = {
- event: e.event,
- distinct_id: machineId,
- properties: {
- ...props,
- ...common,
- // Anonymous events: no person profiles, no geo enrichment.
- $process_person_profile: false,
- $geoip_disable: true,
- $lib: 'codegraph-telemetry-worker',
- },
- };
- const ts = clampTimestamp(e.ts);
- if (ts !== undefined) out.timestamp = ts;
- return out;
- }
- async function forwardToPostHog(env: Env, batch: PostHogEvent[]): Promise<void> {
- try {
- const res = await fetch(`${env.POSTHOG_HOST}/batch/`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ api_key: env.POSTHOG_KEY, batch }),
- signal: AbortSignal.timeout(5000),
- });
- if (!res.ok) {
- console.error(JSON.stringify({ msg: 'posthog forward failed', status: res.status, events: batch.length }));
- }
- } catch (err) {
- console.error(JSON.stringify({ msg: 'posthog forward error', err: String(err), events: batch.length }));
- }
- }
- export default {
- async fetch(request, env, ctx): Promise<Response> {
- try {
- const url = new URL(request.url);
- if (request.method === 'GET' && url.pathname === '/') {
- return new Response(INFO_TEXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
- }
- if (url.pathname !== '/v1/events') {
- return new Response('not found\n', { status: 404 });
- }
- if (request.method !== 'POST') {
- return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
- }
- const contentLength = Number(request.headers.get('content-length'));
- if (!Number.isFinite(contentLength) || contentLength <= 0) {
- return new Response('length required\n', { status: 411 });
- }
- if (contentLength > MAX_BODY_BYTES) {
- return new Response('payload too large\n', { status: 413 });
- }
- let body: JsonObject;
- try {
- const text = await request.text();
- if (text.length > MAX_BODY_BYTES) return new Response('payload too large\n', { status: 413 });
- const parsed: unknown = JSON.parse(text);
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
- return new Response('bad request\n', { status: 400 });
- }
- body = parsed as JsonObject;
- } catch {
- return new Response('bad request\n', { status: 400 });
- }
- const machineId = body.machine_id;
- if (typeof machineId !== 'string' || !UUID_RE.test(machineId)) {
- return new Response('bad request\n', { status: 400 });
- }
- // Best-effort rate limit; fails open — losing a data point beats losing availability.
- try {
- const { success } = await env.MACHINE_RATE_LIMITER.limit({ key: machineId });
- if (!success) return new Response('rate limited\n', { status: 429 });
- } catch (err) {
- console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
- }
- const common: JsonObject = {};
- for (const [key, sanitize] of Object.entries(ENVELOPE_PROPS)) {
- const val = sanitize(body[key]);
- if (val !== undefined) common[key] = val;
- }
- const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
- const batch: PostHogEvent[] = [];
- for (const raw of rawEvents) {
- const sanitized = sanitizeEvent(raw, machineId, common);
- if (sanitized) batch.push(sanitized);
- }
- if (batch.length > 0) {
- ctx.waitUntil(forwardToPostHog(env, batch));
- }
- // Accepted (including "everything was dropped by the allowlist") — the
- // client treats every response as final and never retries.
- return new Response(null, { status: 204 });
- } catch (err) {
- console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
- return new Response('internal error\n', { status: 500 });
- }
- },
- } satisfies ExportedHandler<Env>;
|