chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Copy to .dev.vars for local development (`npm run dev`) and so that
|
||||
# `wrangler types` includes POSTHOG_KEY in the generated Env.
|
||||
# The real key lives only in the deployed secret (`wrangler secret put POSTHOG_KEY`).
|
||||
POSTHOG_KEY="phc_dev_placeholder"
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
# generated by `wrangler types` (npm run types) — includes .dev.vars keys
|
||||
worker-configuration.d.ts
|
||||
@@ -0,0 +1,60 @@
|
||||
# codegraph telemetry ingest worker
|
||||
|
||||
The first-party endpoint behind `telemetry.getcodegraph.com`. This directory is in the
|
||||
public repo **on purpose**: it is the exact code that receives codegraph's anonymous usage
|
||||
telemetry, so anyone can audit what is stored. The schema contract (every event, every
|
||||
field, and everything that is never collected) is in
|
||||
[`docs/design/telemetry.md`](../docs/design/telemetry.md).
|
||||
|
||||
What it does, in one breath: validates incoming batches against a strict allowlist (unknown
|
||||
events dropped, unknown properties stripped), never reads or forwards the client IP,
|
||||
rate-limits per machine ID, and forwards to PostHog off the response path. It ships nowhere
|
||||
with the npm package — the engine's `files` allowlist excludes it.
|
||||
|
||||
## Endpoint contract
|
||||
|
||||
- `POST /v1/events` — JSON body: envelope (`machine_id` UUID, `codegraph_version`, `os`,
|
||||
`arch`, `node_major`, `ci`, `schema_version`) + `events: [{event, ts?, props?}]`.
|
||||
Responds `204` when accepted (including events dropped by the allowlist), honest `4xx`
|
||||
for malformed/oversized/rate-limited requests. Clients treat every response as final —
|
||||
no retries.
|
||||
- `GET /` — plain-text pointer to the docs and the off-switches.
|
||||
|
||||
## Deploy
|
||||
|
||||
Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom
|
||||
domain route auto-provisions DNS + cert), wrangler ≥ 4.36 (the `ratelimits` binding).
|
||||
|
||||
```bash
|
||||
cd telemetry-worker
|
||||
npm install
|
||||
npx wrangler login # once
|
||||
npx wrangler secret put POSTHOG_KEY # the phc_… project write key — never committed
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
The PostHog project itself must have **"Discard client IP data"** enabled — defense in
|
||||
depth on top of this worker never forwarding IPs (`$geoip_disable` is also set per event).
|
||||
|
||||
## Local dev & checks
|
||||
|
||||
```bash
|
||||
cp .dev.vars.example .dev.vars # placeholder key; also feeds `wrangler types`
|
||||
npm run check # wrangler types + tsc --noEmit + deploy --dry-run
|
||||
npm run dev # http://localhost:8787
|
||||
|
||||
curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
|
||||
"machine_id": "00000000-0000-4000-8000-000000000000",
|
||||
"codegraph_version": "0.9.9", "os": "darwin", "arch": "arm64",
|
||||
"node_major": 22, "ci": false, "schema_version": 1,
|
||||
"events": [{ "event": "usage_rollup",
|
||||
"props": { "kind": "mcp_tool", "name": "codegraph_explore",
|
||||
"count": 12, "error_count": 0, "client_name": "Claude Code" } }]
|
||||
}'
|
||||
```
|
||||
|
||||
## Changing the schema
|
||||
|
||||
The allowlist in `src/index.ts` mirrors `docs/design/telemetry.md` (and the user-facing
|
||||
`TELEMETRY.md`). A field is added by one PR touching all of them together — that is the
|
||||
whole point of the design.
|
||||
Generated
+1520
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "codegraph-telemetry-worker",
|
||||
"private": true,
|
||||
"description": "First-party ingest endpoint for codegraph's anonymous usage telemetry (telemetry.getcodegraph.com)",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"check": "wrangler types && tsc --noEmit && wrangler deploy --dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"wrangler": "^4.36.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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>;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*", "worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// codegraph telemetry ingest — see README.md and docs/design/telemetry.md.
|
||||
// Secrets are NOT configured here: POSTHOG_KEY is set via `wrangler secret put POSTHOG_KEY`.
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "codegraph-telemetry",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-06-12",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
|
||||
// First-party endpoint. The custom domain auto-provisions DNS + cert when the
|
||||
// getcodegraph.com zone is on the deploying account. workers.dev stays off so
|
||||
// the only public surface is the documented one.
|
||||
"routes": [{ "pattern": "telemetry.getcodegraph.com", "custom_domain": true }],
|
||||
"workers_dev": false,
|
||||
|
||||
"observability": { "enabled": true, "head_sampling_rate": 1 },
|
||||
|
||||
// Non-secret config. Swap host here if the backend ever moves (EU, self-hosted…).
|
||||
"vars": { "POSTHOG_HOST": "https://us.i.posthog.com" },
|
||||
|
||||
// Per-machine_id rate limit. Legit clients flush a handful of times per day;
|
||||
// 6/min absorbs install+index bursts while capping abuse.
|
||||
"ratelimits": [
|
||||
{
|
||||
"name": "MACHINE_RATE_LIMITER",
|
||||
"namespace_id": "1001",
|
||||
"simple": { "limit": 6, "period": 60 }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user