/** * codegraph telemetry — nightly rollup + raw-event retention purge. * * Public for the same reason the ingest path is: this is every read and every write * we make over the stored events, including the one that deletes them. * * Two jobs, both driven by the cron trigger in wrangler.jsonc (00:30 UTC daily): * * 1. ROLL UP the just-completed UTC day into `daily_machines`, `daily_event_counts` * and `daily_dim_counts` — plus the two days before it, because clients buffer * offline and ship completed-day rollups late, so a day keeps growing after it * ends. Every write is an upsert that OVERWRITES the recomputed value rather than * adding to it, so re-running a day is a no-op and never double-counts. * * 2. PURGE raw `events` past the retention window, in bounded batches. Rollups are * kept forever, so only ad-hoc drill-down has a horizon; `machine_days` and * `machine_first_seen` are never purged, because retention cohorts need the full * history and they are two orders of magnitude smaller than the raw rows. * * `POST /admin/rollup` re-runs a day (or a short range) on demand for backfill and * repair, guarded by the ADMIN_TOKEN secret. Like everything else here it makes no * outbound requests — the only thing this worker talks to is its own D1 database. */ /** Raw-event retention when RETENTION_DAYS is unset or nonsense. Storage-bound — see README. */ export const DEFAULT_RETENTION_DAYS = 90; /** The just-completed day, plus the two before it (late offline buffers). */ export const ROLLUP_LOOKBACK_DAYS = 3; /** Widest range one manual /admin/rollup call will attempt. */ export const MAX_MANUAL_DAYS = 31; /** Rows per purge DELETE — bounded so one statement stays well inside D1's limits. */ const PURGE_BATCH_ROWS = 5_000; /** Ceiling on one night's deletions (≈1.5 days of ingest at current volume). */ const PURGE_MAX_BATCHES = 60; const DAY_MS = 86_400_000; /** UTC YYYY-MM-DD — the key every event, rollup and chart is bucketed on. */ export function utcDay(atMs: number): string { return new Date(atMs).toISOString().slice(0, 10); } /** Rejects both the wrong shape and impossible dates (`2026-02-31` round-trips as `2026-03-03`). */ export function isValidDay(day: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false; const t = Date.parse(`${day}T00:00:00Z`); return Number.isFinite(t) && utcDay(t) === day; } /** Configured retention, clamped to something sane; falls back to the default. */ export function retentionDays(env: Env): number { const raw = Number(env.RETENTION_DAYS); return Number.isInteger(raw) && raw >= 1 && raw <= 3650 ? raw : DEFAULT_RETENTION_DAYS; } /** Oldest day kept: everything strictly before this is purged. */ export function retentionCutoff(atMs: number, keepDays: number): string { return utcDay(atMs - keepDays * DAY_MS); } // --------------------------------------------------------------------------- // The rollup statements // --------------------------------------------------------------------------- // One `INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension: the whole // aggregation happens inside D1, so a day rolls up in one round trip and no event // row ever crosses the wire. Each takes exactly one bound parameter — the day. // // Adding a breakdown is a line in ROLLUP_STATEMENTS, never a migration — that is // what the generic (dim, value) shape of daily_dim_counts buys. /** * A group's event volume. For install/index/uninstall one row is one event, but a * usage_rollup row is a counter the client pre-aggregated (one per machine × day × * tool), so its `count` prop is what has to be summed — counting rows there would * silently report "machines that used the tool" and undercount by an order of magnitude. */ const COUNT = `CASE WHEN e.event = 'usage_rollup' THEN sum(coalesce(json_extract(e.props, '$.count'), 0)) ELSE count(*) END`; const DIM_CONFLICT = `ON CONFLICT (day, event, dim, value) DO UPDATE SET count = excluded.count, machines = excluded.machines`; const prop = (name: string): string => `json_extract(e.props, '$.${name}')`; const quoted = (values: readonly string[]): string => values.map((v) => `'${v}'`).join(', '); const onlyEvents = (...events: readonly string[]): string => ` AND e.event IN (${quoted(events)})`; /** One dimension whose value is a scalar column or a scalar prop. */ function dimStatement(dim: string, value: string, where = ''): string { return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) SELECT e.day, e.event, '${dim}', CAST(${value} AS TEXT), ${COUNT}, count(DISTINCT e.machine_id) FROM events e WHERE e.day = ? AND ${value} IS NOT NULL AND ${value} <> ''${where} GROUP BY e.day, e.event, ${value} ${DIM_CONFLICT}`; } /** * One dimension unnested from a JSON array prop — one row per element, so an index * of a TypeScript+Go repo counts once under each language. `json_each` over a path * the props do not have yields no rows, which is exactly the wanted behaviour for * events that omit the array. */ function arrayDimStatement(dim: string, path: string, events: readonly string[]): string { return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) SELECT e.day, e.event, '${dim}', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id) FROM events e, json_each(e.props, '${path}') j WHERE e.day = ? AND e.event IN (${quoted(events)}) AND j.value <> '' GROUP BY e.day, e.event, j.value ${DIM_CONFLICT}`; } /** * Rebuilt from `machine_days`, not from `events`: that table is never purged, so this * number stays right for days whose raw rows are long gone. `prod` is already the * per-machine-day maximum the ingest path maintains (0 only if every event that * machine sent that day carried ci = 1). * * So this is also the one rollup that can still be rebuilt for a day whose raw events * are long gone. */ const DAILY_MACHINES = `INSERT INTO daily_machines (day, machines, prod_machines) SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days WHERE day = ? GROUP BY day ON CONFLICT (day) DO UPDATE SET machines = excluded.machines, prod_machines = excluded.prod_machines`; const ROLLUP_STATEMENTS: readonly string[] = [ DAILY_MACHINES, `INSERT INTO daily_event_counts (day, event, count, machines) SELECT e.day, e.event, ${COUNT}, count(DISTINCT e.machine_id) FROM events e WHERE e.day = ? GROUP BY e.day, e.event ON CONFLICT (day, event) DO UPDATE SET count = excluded.count, machines = excluded.machines`, // Envelope dimensions — every event type carries them. dimStatement('os', 'e.os'), dimStatement('arch', 'e.arch'), dimStatement('codegraph_version', 'e.codegraph_version'), dimStatement('node_major', 'e.node_major'), // Event-specific scalar props. dimStatement('file_count_bucket', prop('file_count_bucket'), onlyEvents('index')), dimStatement('duration_bucket', prop('duration_bucket'), onlyEvents('index')), dimStatement('scope', prop('scope'), onlyEvents('install')), // `kind` is fresh/upgrade/reinstall on install and mcp_tool/cli_command on // usage_rollup; `event` is part of the primary key, so both live here without colliding. dimStatement('kind', prop('kind'), onlyEvents('install', 'usage_rollup')), dimStatement('name', prop('name'), onlyEvents('usage_rollup')), dimStatement('client_name', prop('client_name'), onlyEvents('usage_rollup')), // Array props. arrayDimStatement('language', '$.languages', ['index']), arrayDimStatement('target', '$.targets', ['install', 'uninstall']), // Errors per tool/command. Not in the migration's documented dim list because dims // are a cron concern rather than a schema one, but rolled up because it is the one // usage number that is gone for good after the purge. Only groups with at least one // error are stored, so `count` is errors and `machines` is the machines that saw one // — NOT the machines that ran the tool (that is the `name` dim). `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) SELECT e.day, e.event, 'name_error', CAST(${prop('name')} AS TEXT), sum(${prop('error_count')}), count(DISTINCT e.machine_id) FROM events e WHERE e.day = ? AND e.event = 'usage_rollup' AND ${prop('name')} IS NOT NULL AND coalesce(${prop('error_count')}, 0) > 0 GROUP BY e.day, e.event, ${prop('name')} ${DIM_CONFLICT}`, ]; /** Rollup tables derived from raw `events` — the ones `reset` wipes before recomputing. */ const EVENT_DERIVED_TABLES = ['daily_event_counts', 'daily_dim_counts'] as const; // --------------------------------------------------------------------------- // Running it // --------------------------------------------------------------------------- export interface DayResult { day: string; /** Rollup rows written for the day. */ rows: number; /** Day is past the retention window — a `reset` on it is ignored (see below). */ pastRetention: boolean; } /** * Recompute every rollup for one UTC day. One D1 `batch()` = one implicit * transaction, so a day is either fully recomputed or not touched at all. * * Plain (upsert-only) runs are safe on any day: a day whose raw events are already * purged selects nothing, so nothing is written and the rollups it earned while the * events were still around survive untouched. That is what keeps rollups permanent. * * `reset` drops the day's event-derived rollup rows first instead of upserting over * them — repair for when the dimension list itself changes and a value that no longer * exists would otherwise linger. It is IGNORED past the retention window, where it * would delete rows and then find no events to rebuild them from: silently blanking a * real day is the one irreversible thing this file could do. */ export async function rollupDay( env: Env, day: string, opts: { cutoff: string; reset?: boolean }, ): Promise { const pastRetention = day < opts.cutoff; const statements: D1PreparedStatement[] = []; if (opts.reset && !pastRetention) { for (const table of EVENT_DERIVED_TABLES) { statements.push(env.DB.prepare(`DELETE FROM ${table} WHERE day = ?`).bind(day)); } } for (const sql of ROLLUP_STATEMENTS) { statements.push(env.DB.prepare(sql).bind(day)); } const results = await env.DB.batch(statements); const rows = results.reduce((total, r) => total + (r.meta?.changes ?? 0), 0); return { day, rows, pastRetention }; } export interface PurgeResult { /** Everything strictly before this day was deleted. */ cutoff: string; deleted: number; batches: number; /** Hit the per-run batch ceiling — more rows are still due, next run takes them. */ capped: boolean; } /** * Delete raw events older than the window, oldest first, in bounded batches. * `id` is a rowid alias and the purge only ever removes the oldest rows, so the * keyset subquery stays a cheap index range scan on (day, event). */ export async function purgeOldEvents(env: Env, cutoff: string): Promise { const del = env.DB.prepare( `DELETE FROM events WHERE id IN (SELECT id FROM events WHERE day < ? LIMIT ${PURGE_BATCH_ROWS})`, ); let deleted = 0; for (let batch = 1; batch <= PURGE_MAX_BATCHES; batch++) { const { meta } = await del.bind(cutoff).run(); const removed = meta?.changes ?? 0; deleted += removed; if (removed < PURGE_BATCH_ROWS) return { cutoff, deleted, batches: batch, capped: false }; } return { cutoff, deleted, batches: PURGE_MAX_BATCHES, capped: true }; } /** * The cron body: roll up the completed day and the two before it, then purge. * * Logs one line of counts — never a day's contents, never a machine id. Throws if * anything failed so the invocation is marked failed (and retried) rather than * quietly skipping a day; every write here is idempotent, so a retry is safe. */ export async function runNightly(env: Env, atMs: number): Promise { const started = Date.now(); const keepDays = retentionDays(env); const cutoff = retentionCutoff(atMs, keepDays); const rolled: string[] = []; const failed: string[] = []; let rows = 0; for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) { const day = utcDay(atMs - back * DAY_MS); try { rows += (await rollupDay(env, day, { cutoff })).rows; rolled.push(day); } catch (err) { failed.push(day); console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) })); } } let purge: PurgeResult | null = null; try { purge = await purgeOldEvents(env, cutoff); } catch (err) { console.error(JSON.stringify({ msg: 'purge failed', cutoff, err: String(err) })); } console.log( JSON.stringify({ msg: 'nightly rollup', days: rolled, rows, failed: failed.length, retention_days: keepDays, purged_before: cutoff, purged: purge?.deleted ?? null, purge_batches: purge?.batches ?? null, purge_capped: purge?.capped ?? null, ms: Date.now() - started, }), ); if (failed.length > 0 || purge === null) { throw new Error(`nightly rollup incomplete: ${failed.length} day(s) failed, purge ${purge ? 'ok' : 'failed'}`); } } // --------------------------------------------------------------------------- // POST /admin/rollup — manual backfill / repair // --------------------------------------------------------------------------- const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' }, }); /** Constant-time over digests, so neither the length nor a prefix of the token leaks. */ async function tokenMatches(provided: string, expected: string): Promise { const encoder = new TextEncoder(); const [a, b] = await Promise.all([ crypto.subtle.digest('SHA-256', encoder.encode(provided)), crypto.subtle.digest('SHA-256', encoder.encode(expected)), ]); return crypto.subtle.timingSafeEqual(a, b); } /** * `POST /admin/rollup?day=YYYY-MM-DD[&days=N][&reset=1]`, header `x-admin-token`. * * Re-runs the rollup for `day` (default: yesterday), or for the `N` days ending on it. * Exists so a backfill or a repair never needs a redeploy. It only ever recomputes * aggregates from stored rows — there is no path here that deletes raw events; the * purge runs on the cron and nowhere else. */ export async function handleAdminRollup(request: Request, env: Env, url: URL): Promise { // No secret configured ⇒ no admin surface at all, and nothing that hints there is one. const expected = env.ADMIN_TOKEN; if (typeof expected !== 'string' || expected.length === 0) { return new Response('not found\n', { status: 404 }); } if (request.method !== 'POST') { return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } }); } if (!(await tokenMatches(request.headers.get('x-admin-token') ?? '', expected))) { // Cap how fast the token can be guessed at. Only failures spend the budget, so a // chunked backfill loop is never throttled. Best-effort and fails open like the // ingest limiter — the token itself is the guard, this only slows a guesser down. try { const { success } = await env.ADMIN_RATE_LIMITER.limit({ key: 'admin' }); if (!success) return new Response('rate limited\n', { status: 429 }); } catch (err) { console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) })); } return new Response('unauthorized\n', { status: 401 }); } const now = Date.now(); const day = url.searchParams.get('day') ?? utcDay(now - DAY_MS); if (!isValidDay(day)) return json({ error: 'day must be YYYY-MM-DD' }, 400); const requested = url.searchParams.get('days'); const span = requested === null ? 1 : Number(requested); if (!Number.isInteger(span) || span < 1 || span > MAX_MANUAL_DAYS) { return json({ error: `days must be an integer between 1 and ${MAX_MANUAL_DAYS}` }, 400); } const reset = url.searchParams.get('reset') === '1'; const cutoff = retentionCutoff(now, retentionDays(env)); const endMs = Date.parse(`${day}T00:00:00Z`); const days: DayResult[] = []; try { for (let back = span - 1; back >= 0; back--) { days.push(await rollupDay(env, utcDay(endMs - back * DAY_MS), { cutoff, reset })); } } catch (err) { console.error(JSON.stringify({ msg: 'manual rollup failed', through: day, err: String(err) })); return json({ error: 'rollup failed', through: day, completed: days }, 500); } const rows = days.reduce((total, d) => total + d.rows, 0); // A day past the window kept its rollups but ignored the reset — say so rather than // reporting a repair that did not happen. const resetIgnored = reset ? days.filter((d) => d.pastRetention).map((d) => d.day) : []; console.log( JSON.stringify({ msg: 'manual rollup', through: day, days: span, reset, reset_ignored: resetIgnored.length, rows, ms: Date.now() - now, }), ); return json({ ok: true, through: day, retention_cutoff: cutoff, rows, reset_ignored: resetIgnored, days }); }