| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- /**
- * 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<DayResult> {
- 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<PurgeResult> {
- 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<void> {
- 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<boolean> {
- 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<Response> {
- // 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 });
- }
|