rollup.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. /**
  2. * codegraph telemetry — nightly rollup + raw-event retention purge.
  3. *
  4. * Public for the same reason the ingest path is: this is every read and every write
  5. * we make over the stored events, including the one that deletes them.
  6. *
  7. * Two jobs, both driven by the cron trigger in wrangler.jsonc (00:30 UTC daily):
  8. *
  9. * 1. ROLL UP the just-completed UTC day into `daily_machines`, `daily_event_counts`
  10. * and `daily_dim_counts` — plus the two days before it, because clients buffer
  11. * offline and ship completed-day rollups late, so a day keeps growing after it
  12. * ends. Every write is an upsert that OVERWRITES the recomputed value rather than
  13. * adding to it, so re-running a day is a no-op and never double-counts.
  14. *
  15. * 2. PURGE raw `events` past the retention window, in bounded batches. Rollups are
  16. * kept forever, so only ad-hoc drill-down has a horizon; `machine_days` and
  17. * `machine_first_seen` are never purged, because retention cohorts need the full
  18. * history and they are two orders of magnitude smaller than the raw rows.
  19. *
  20. * `POST /admin/rollup` re-runs a day (or a short range) on demand for backfill and
  21. * repair, guarded by the ADMIN_TOKEN secret. Like everything else here it makes no
  22. * outbound requests — the only thing this worker talks to is its own D1 database.
  23. */
  24. /** Raw-event retention when RETENTION_DAYS is unset or nonsense. Storage-bound — see README. */
  25. export const DEFAULT_RETENTION_DAYS = 90;
  26. /** The just-completed day, plus the two before it (late offline buffers). */
  27. export const ROLLUP_LOOKBACK_DAYS = 3;
  28. /** Widest range one manual /admin/rollup call will attempt. */
  29. export const MAX_MANUAL_DAYS = 31;
  30. /** Rows per purge DELETE — bounded so one statement stays well inside D1's limits. */
  31. const PURGE_BATCH_ROWS = 5_000;
  32. /** Ceiling on one night's deletions (≈1.5 days of ingest at current volume). */
  33. const PURGE_MAX_BATCHES = 60;
  34. const DAY_MS = 86_400_000;
  35. /** UTC YYYY-MM-DD — the key every event, rollup and chart is bucketed on. */
  36. export function utcDay(atMs: number): string {
  37. return new Date(atMs).toISOString().slice(0, 10);
  38. }
  39. /** Rejects both the wrong shape and impossible dates (`2026-02-31` round-trips as `2026-03-03`). */
  40. export function isValidDay(day: string): boolean {
  41. if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
  42. const t = Date.parse(`${day}T00:00:00Z`);
  43. return Number.isFinite(t) && utcDay(t) === day;
  44. }
  45. /** Configured retention, clamped to something sane; falls back to the default. */
  46. export function retentionDays(env: Env): number {
  47. const raw = Number(env.RETENTION_DAYS);
  48. return Number.isInteger(raw) && raw >= 1 && raw <= 3650 ? raw : DEFAULT_RETENTION_DAYS;
  49. }
  50. /** Oldest day kept: everything strictly before this is purged. */
  51. export function retentionCutoff(atMs: number, keepDays: number): string {
  52. return utcDay(atMs - keepDays * DAY_MS);
  53. }
  54. // ---------------------------------------------------------------------------
  55. // The rollup statements
  56. // ---------------------------------------------------------------------------
  57. // One `INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension: the whole
  58. // aggregation happens inside D1, so a day rolls up in one round trip and no event
  59. // row ever crosses the wire. Each takes exactly one bound parameter — the day.
  60. //
  61. // Adding a breakdown is a line in ROLLUP_STATEMENTS, never a migration — that is
  62. // what the generic (dim, value) shape of daily_dim_counts buys.
  63. /**
  64. * A group's event volume. For install/index/uninstall one row is one event, but a
  65. * usage_rollup row is a counter the client pre-aggregated (one per machine × day ×
  66. * tool), so its `count` prop is what has to be summed — counting rows there would
  67. * silently report "machines that used the tool" and undercount by an order of magnitude.
  68. */
  69. const COUNT = `CASE WHEN e.event = 'usage_rollup'
  70. THEN sum(coalesce(json_extract(e.props, '$.count'), 0))
  71. ELSE count(*) END`;
  72. const DIM_CONFLICT = `ON CONFLICT (day, event, dim, value) DO UPDATE
  73. SET count = excluded.count, machines = excluded.machines`;
  74. const prop = (name: string): string => `json_extract(e.props, '$.${name}')`;
  75. const quoted = (values: readonly string[]): string => values.map((v) => `'${v}'`).join(', ');
  76. const onlyEvents = (...events: readonly string[]): string => ` AND e.event IN (${quoted(events)})`;
  77. /** One dimension whose value is a scalar column or a scalar prop. */
  78. function dimStatement(dim: string, value: string, where = ''): string {
  79. return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
  80. SELECT e.day, e.event, '${dim}', CAST(${value} AS TEXT), ${COUNT}, count(DISTINCT e.machine_id)
  81. FROM events e
  82. WHERE e.day = ? AND ${value} IS NOT NULL AND ${value} <> ''${where}
  83. GROUP BY e.day, e.event, ${value}
  84. ${DIM_CONFLICT}`;
  85. }
  86. /**
  87. * One dimension unnested from a JSON array prop — one row per element, so an index
  88. * of a TypeScript+Go repo counts once under each language. `json_each` over a path
  89. * the props do not have yields no rows, which is exactly the wanted behaviour for
  90. * events that omit the array.
  91. */
  92. function arrayDimStatement(dim: string, path: string, events: readonly string[]): string {
  93. return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
  94. SELECT e.day, e.event, '${dim}', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
  95. FROM events e, json_each(e.props, '${path}') j
  96. WHERE e.day = ? AND e.event IN (${quoted(events)}) AND j.value <> ''
  97. GROUP BY e.day, e.event, j.value
  98. ${DIM_CONFLICT}`;
  99. }
  100. /**
  101. * Rebuilt from `machine_days`, not from `events`: that table is never purged, so this
  102. * number stays right for days whose raw rows are long gone. `prod` is already the
  103. * per-machine-day maximum the ingest path maintains (0 only if every event that
  104. * machine sent that day carried ci = 1).
  105. *
  106. * So this is also the one rollup that can still be rebuilt for a day whose raw events
  107. * are long gone.
  108. */
  109. const DAILY_MACHINES = `INSERT INTO daily_machines (day, machines, prod_machines)
  110. SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days WHERE day = ? GROUP BY day
  111. ON CONFLICT (day) DO UPDATE
  112. SET machines = excluded.machines, prod_machines = excluded.prod_machines`;
  113. const ROLLUP_STATEMENTS: readonly string[] = [
  114. DAILY_MACHINES,
  115. `INSERT INTO daily_event_counts (day, event, count, machines)
  116. SELECT e.day, e.event, ${COUNT}, count(DISTINCT e.machine_id)
  117. FROM events e
  118. WHERE e.day = ?
  119. GROUP BY e.day, e.event
  120. ON CONFLICT (day, event) DO UPDATE
  121. SET count = excluded.count, machines = excluded.machines`,
  122. // Envelope dimensions — every event type carries them.
  123. dimStatement('os', 'e.os'),
  124. dimStatement('arch', 'e.arch'),
  125. dimStatement('codegraph_version', 'e.codegraph_version'),
  126. dimStatement('node_major', 'e.node_major'),
  127. // Event-specific scalar props.
  128. dimStatement('file_count_bucket', prop('file_count_bucket'), onlyEvents('index')),
  129. dimStatement('duration_bucket', prop('duration_bucket'), onlyEvents('index')),
  130. dimStatement('scope', prop('scope'), onlyEvents('install')),
  131. // `kind` is fresh/upgrade/reinstall on install and mcp_tool/cli_command on
  132. // usage_rollup; `event` is part of the primary key, so both live here without colliding.
  133. dimStatement('kind', prop('kind'), onlyEvents('install', 'usage_rollup')),
  134. dimStatement('name', prop('name'), onlyEvents('usage_rollup')),
  135. dimStatement('client_name', prop('client_name'), onlyEvents('usage_rollup')),
  136. // Array props.
  137. arrayDimStatement('language', '$.languages', ['index']),
  138. arrayDimStatement('target', '$.targets', ['install', 'uninstall']),
  139. // Errors per tool/command. Not in the migration's documented dim list because dims
  140. // are a cron concern rather than a schema one, but rolled up because it is the one
  141. // usage number that is gone for good after the purge. Only groups with at least one
  142. // error are stored, so `count` is errors and `machines` is the machines that saw one
  143. // — NOT the machines that ran the tool (that is the `name` dim).
  144. `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
  145. SELECT e.day, e.event, 'name_error', CAST(${prop('name')} AS TEXT),
  146. sum(${prop('error_count')}), count(DISTINCT e.machine_id)
  147. FROM events e
  148. WHERE e.day = ? AND e.event = 'usage_rollup'
  149. AND ${prop('name')} IS NOT NULL AND coalesce(${prop('error_count')}, 0) > 0
  150. GROUP BY e.day, e.event, ${prop('name')}
  151. ${DIM_CONFLICT}`,
  152. ];
  153. /** Rollup tables derived from raw `events` — the ones `reset` wipes before recomputing. */
  154. const EVENT_DERIVED_TABLES = ['daily_event_counts', 'daily_dim_counts'] as const;
  155. // ---------------------------------------------------------------------------
  156. // Running it
  157. // ---------------------------------------------------------------------------
  158. export interface DayResult {
  159. day: string;
  160. /** Rollup rows written for the day. */
  161. rows: number;
  162. /** Day is past the retention window — a `reset` on it is ignored (see below). */
  163. pastRetention: boolean;
  164. }
  165. /**
  166. * Recompute every rollup for one UTC day. One D1 `batch()` = one implicit
  167. * transaction, so a day is either fully recomputed or not touched at all.
  168. *
  169. * Plain (upsert-only) runs are safe on any day: a day whose raw events are already
  170. * purged selects nothing, so nothing is written and the rollups it earned while the
  171. * events were still around survive untouched. That is what keeps rollups permanent.
  172. *
  173. * `reset` drops the day's event-derived rollup rows first instead of upserting over
  174. * them — repair for when the dimension list itself changes and a value that no longer
  175. * exists would otherwise linger. It is IGNORED past the retention window, where it
  176. * would delete rows and then find no events to rebuild them from: silently blanking a
  177. * real day is the one irreversible thing this file could do.
  178. */
  179. export async function rollupDay(
  180. env: Env,
  181. day: string,
  182. opts: { cutoff: string; reset?: boolean },
  183. ): Promise<DayResult> {
  184. const pastRetention = day < opts.cutoff;
  185. const statements: D1PreparedStatement[] = [];
  186. if (opts.reset && !pastRetention) {
  187. for (const table of EVENT_DERIVED_TABLES) {
  188. statements.push(env.DB.prepare(`DELETE FROM ${table} WHERE day = ?`).bind(day));
  189. }
  190. }
  191. for (const sql of ROLLUP_STATEMENTS) {
  192. statements.push(env.DB.prepare(sql).bind(day));
  193. }
  194. const results = await env.DB.batch(statements);
  195. const rows = results.reduce((total, r) => total + (r.meta?.changes ?? 0), 0);
  196. return { day, rows, pastRetention };
  197. }
  198. export interface PurgeResult {
  199. /** Everything strictly before this day was deleted. */
  200. cutoff: string;
  201. deleted: number;
  202. batches: number;
  203. /** Hit the per-run batch ceiling — more rows are still due, next run takes them. */
  204. capped: boolean;
  205. }
  206. /**
  207. * Delete raw events older than the window, oldest first, in bounded batches.
  208. * `id` is a rowid alias and the purge only ever removes the oldest rows, so the
  209. * keyset subquery stays a cheap index range scan on (day, event).
  210. */
  211. export async function purgeOldEvents(env: Env, cutoff: string): Promise<PurgeResult> {
  212. const del = env.DB.prepare(
  213. `DELETE FROM events WHERE id IN (SELECT id FROM events WHERE day < ? LIMIT ${PURGE_BATCH_ROWS})`,
  214. );
  215. let deleted = 0;
  216. for (let batch = 1; batch <= PURGE_MAX_BATCHES; batch++) {
  217. const { meta } = await del.bind(cutoff).run();
  218. const removed = meta?.changes ?? 0;
  219. deleted += removed;
  220. if (removed < PURGE_BATCH_ROWS) return { cutoff, deleted, batches: batch, capped: false };
  221. }
  222. return { cutoff, deleted, batches: PURGE_MAX_BATCHES, capped: true };
  223. }
  224. /**
  225. * The cron body: roll up the completed day and the two before it, then purge.
  226. *
  227. * Logs one line of counts — never a day's contents, never a machine id. Throws if
  228. * anything failed so the invocation is marked failed (and retried) rather than
  229. * quietly skipping a day; every write here is idempotent, so a retry is safe.
  230. */
  231. export async function runNightly(env: Env, atMs: number): Promise<void> {
  232. const started = Date.now();
  233. const keepDays = retentionDays(env);
  234. const cutoff = retentionCutoff(atMs, keepDays);
  235. const rolled: string[] = [];
  236. const failed: string[] = [];
  237. let rows = 0;
  238. for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) {
  239. const day = utcDay(atMs - back * DAY_MS);
  240. try {
  241. rows += (await rollupDay(env, day, { cutoff })).rows;
  242. rolled.push(day);
  243. } catch (err) {
  244. failed.push(day);
  245. console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) }));
  246. }
  247. }
  248. let purge: PurgeResult | null = null;
  249. try {
  250. purge = await purgeOldEvents(env, cutoff);
  251. } catch (err) {
  252. console.error(JSON.stringify({ msg: 'purge failed', cutoff, err: String(err) }));
  253. }
  254. console.log(
  255. JSON.stringify({
  256. msg: 'nightly rollup',
  257. days: rolled,
  258. rows,
  259. failed: failed.length,
  260. retention_days: keepDays,
  261. purged_before: cutoff,
  262. purged: purge?.deleted ?? null,
  263. purge_batches: purge?.batches ?? null,
  264. purge_capped: purge?.capped ?? null,
  265. ms: Date.now() - started,
  266. }),
  267. );
  268. if (failed.length > 0 || purge === null) {
  269. throw new Error(`nightly rollup incomplete: ${failed.length} day(s) failed, purge ${purge ? 'ok' : 'failed'}`);
  270. }
  271. }
  272. // ---------------------------------------------------------------------------
  273. // POST /admin/rollup — manual backfill / repair
  274. // ---------------------------------------------------------------------------
  275. const json = (body: unknown, status = 200): Response =>
  276. new Response(JSON.stringify(body), {
  277. status,
  278. headers: { 'content-type': 'application/json; charset=utf-8' },
  279. });
  280. /** Constant-time over digests, so neither the length nor a prefix of the token leaks. */
  281. async function tokenMatches(provided: string, expected: string): Promise<boolean> {
  282. const encoder = new TextEncoder();
  283. const [a, b] = await Promise.all([
  284. crypto.subtle.digest('SHA-256', encoder.encode(provided)),
  285. crypto.subtle.digest('SHA-256', encoder.encode(expected)),
  286. ]);
  287. return crypto.subtle.timingSafeEqual(a, b);
  288. }
  289. /**
  290. * `POST /admin/rollup?day=YYYY-MM-DD[&days=N][&reset=1]`, header `x-admin-token`.
  291. *
  292. * Re-runs the rollup for `day` (default: yesterday), or for the `N` days ending on it.
  293. * Exists so a backfill or a repair never needs a redeploy. It only ever recomputes
  294. * aggregates from stored rows — there is no path here that deletes raw events; the
  295. * purge runs on the cron and nowhere else.
  296. */
  297. export async function handleAdminRollup(request: Request, env: Env, url: URL): Promise<Response> {
  298. // No secret configured ⇒ no admin surface at all, and nothing that hints there is one.
  299. const expected = env.ADMIN_TOKEN;
  300. if (typeof expected !== 'string' || expected.length === 0) {
  301. return new Response('not found\n', { status: 404 });
  302. }
  303. if (request.method !== 'POST') {
  304. return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
  305. }
  306. if (!(await tokenMatches(request.headers.get('x-admin-token') ?? '', expected))) {
  307. // Cap how fast the token can be guessed at. Only failures spend the budget, so a
  308. // chunked backfill loop is never throttled. Best-effort and fails open like the
  309. // ingest limiter — the token itself is the guard, this only slows a guesser down.
  310. try {
  311. const { success } = await env.ADMIN_RATE_LIMITER.limit({ key: 'admin' });
  312. if (!success) return new Response('rate limited\n', { status: 429 });
  313. } catch (err) {
  314. console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
  315. }
  316. return new Response('unauthorized\n', { status: 401 });
  317. }
  318. const now = Date.now();
  319. const day = url.searchParams.get('day') ?? utcDay(now - DAY_MS);
  320. if (!isValidDay(day)) return json({ error: 'day must be YYYY-MM-DD' }, 400);
  321. const requested = url.searchParams.get('days');
  322. const span = requested === null ? 1 : Number(requested);
  323. if (!Number.isInteger(span) || span < 1 || span > MAX_MANUAL_DAYS) {
  324. return json({ error: `days must be an integer between 1 and ${MAX_MANUAL_DAYS}` }, 400);
  325. }
  326. const reset = url.searchParams.get('reset') === '1';
  327. const cutoff = retentionCutoff(now, retentionDays(env));
  328. const endMs = Date.parse(`${day}T00:00:00Z`);
  329. const days: DayResult[] = [];
  330. try {
  331. for (let back = span - 1; back >= 0; back--) {
  332. days.push(await rollupDay(env, utcDay(endMs - back * DAY_MS), { cutoff, reset }));
  333. }
  334. } catch (err) {
  335. console.error(JSON.stringify({ msg: 'manual rollup failed', through: day, err: String(err) }));
  336. return json({ error: 'rollup failed', through: day, completed: days }, 500);
  337. }
  338. const rows = days.reduce((total, d) => total + d.rows, 0);
  339. // A day past the window kept its rollups but ignored the reset — say so rather than
  340. // reporting a repair that did not happen.
  341. const resetIgnored = reset ? days.filter((d) => d.pastRetention).map((d) => d.day) : [];
  342. console.log(
  343. JSON.stringify({
  344. msg: 'manual rollup',
  345. through: day,
  346. days: span,
  347. reset,
  348. reset_ignored: resetIgnored.length,
  349. rows,
  350. ms: Date.now() - now,
  351. }),
  352. );
  353. return json({ ok: true, through: day, retention_cutoff: cutoff, rows, reset_ignored: resetIgnored, days });
  354. }