api.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. /**
  2. * The dashboard's read API: one JSON endpoint per chart shape, all of them
  3. * scoped by the same `?from=&to=` range the picker drives.
  4. *
  5. * Rules this file keeps:
  6. * - **Rollups first.** Every panel is answered from `daily_*` / `machine_days`,
  7. * which are kept forever. Only the activation funnel touches raw `events`,
  8. * because "did this machine ever run an index" is not a daily aggregate — and
  9. * that is also the only endpoint with a horizon (the retention window).
  10. * - **Parameterized, always.** No value from the query string is ever
  11. * concatenated into SQL. Dimensions and metrics are looked up in the tables
  12. * below and rejected with a 400 if they are not there, so even the column
  13. * *names* a caller can reach are a closed set.
  14. * - **Chart-shaped.** Responses come back as `labels[] + datasets[]` so the
  15. * frontend does no arithmetic; every response also carries `rows` in its
  16. * natural shape, which is what the per-panel table view renders.
  17. * - **No Response objects.** Handlers return plain data and let src/index.ts
  18. * apply the security headers, so there is exactly one place where headers on
  19. * an authenticated response are decided.
  20. */
  21. const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
  22. const DAY_MS = 86_400_000;
  23. /** A year of daily points is already more than the charts can draw legibly. */
  24. const MAX_RANGE_DAYS = 366;
  25. const DEFAULT_RANGE_DAYS = 30;
  26. /**
  27. * Retention curve length. Two weeks covers the day-1 and day-7 cliffs where nearly
  28. * all churn happens, and matches the window the previous analytics dashboard drew,
  29. * so the numbers stay comparable across the cutover.
  30. */
  31. const RETENTION_DAYS = 14;
  32. /** Days a machine gets to run its first index before it counts as churned. */
  33. const DEFAULT_ACTIVATION_WINDOW = 7;
  34. const MAX_ACTIVATION_WINDOW = 30;
  35. /** Bars past this fold into "Other" — see the note on `Other` in breakdown(). */
  36. const DEFAULT_BREAKDOWN_LIMIT = 12;
  37. const MAX_BREAKDOWN_LIMIT = 50;
  38. /** Panels read at most every few minutes; the underlying data moves once a night. */
  39. const CACHE_CONTROL = 'private, max-age=300';
  40. export interface ApiResult {
  41. body: unknown;
  42. status?: number;
  43. /** Omitted ⇒ src/index.ts keeps its `no-store` default. */
  44. cacheControl?: string;
  45. }
  46. const fail = (error: string, status = 400): ApiResult => ({ body: { error }, status });
  47. /**
  48. * `noUncheckedIndexedAccess` types every slot of a `batch()` result as possibly
  49. * undefined. These two keep that out of the query code, which reads better for
  50. * being about rows rather than about array bounds.
  51. */
  52. const rowsOf = <T>(result: D1Result | undefined): T[] => (result?.results ?? []) as unknown as T[];
  53. const firstOf = <T>(result: D1Result | undefined): T | undefined => rowsOf<T>(result)[0];
  54. // ---------------------------------------------------------------------------
  55. // Days
  56. // ---------------------------------------------------------------------------
  57. const utcDay = (atMs: number): string => new Date(atMs).toISOString().slice(0, 10);
  58. /** Rejects the wrong shape and impossible dates alike (`2026-02-31` round-trips as March). */
  59. function isValidDay(day: string): boolean {
  60. if (!DAY_RE.test(day)) return false;
  61. const t = Date.parse(`${day}T00:00:00Z`);
  62. return Number.isFinite(t) && utcDay(t) === day;
  63. }
  64. const dayMs = (day: string): number => Date.parse(`${day}T00:00:00Z`);
  65. const addDays = (day: string, delta: number): string => utcDay(dayMs(day) + delta * DAY_MS);
  66. const daysApart = (from: string, to: string): number => Math.round((dayMs(to) - dayMs(from)) / DAY_MS);
  67. export interface Range {
  68. from: string;
  69. to: string;
  70. /** Inclusive length. */
  71. days: number;
  72. /** The request asked for more than MAX_RANGE_DAYS and `from` was moved up. */
  73. clamped: boolean;
  74. }
  75. /**
  76. * The range every endpoint shares. Absent params default to the last 30 days
  77. * ending today so a bare `curl /api/summary` still answers something sensible;
  78. * the dashboard itself always sends both, anchored on /api/meta's latest day so
  79. * no chart ends on a day the nightly rollup has not written yet.
  80. */
  81. function parseRange(url: URL): Range | ApiResult {
  82. const rawTo = url.searchParams.get('to');
  83. const rawFrom = url.searchParams.get('from');
  84. if (rawTo !== null && !isValidDay(rawTo)) return fail('to must be YYYY-MM-DD');
  85. if (rawFrom !== null && !isValidDay(rawFrom)) return fail('from must be YYYY-MM-DD');
  86. const to = rawTo ?? utcDay(Date.now());
  87. const from = rawFrom ?? addDays(to, -(DEFAULT_RANGE_DAYS - 1));
  88. if (from > to) return fail('from must not be after to');
  89. const requested = daysApart(from, to) + 1;
  90. const clamped = requested > MAX_RANGE_DAYS;
  91. return {
  92. from: clamped ? addDays(to, -(MAX_RANGE_DAYS - 1)) : from,
  93. to,
  94. days: clamped ? MAX_RANGE_DAYS : requested,
  95. clamped,
  96. };
  97. }
  98. const isApiResult = (v: Range | ApiResult): v is ApiResult => 'body' in v;
  99. /** Every day in the range, so a chart's x-axis has no holes where nothing happened. */
  100. function dayList(range: Range): string[] {
  101. const days: string[] = [];
  102. for (let i = 0; i < range.days; i++) days.push(addDays(range.from, i));
  103. return days;
  104. }
  105. /** Turns day-keyed rows into a dense series aligned to `labels`. */
  106. function densify(labels: string[], byDay: Map<string, number>): number[] {
  107. return labels.map((day) => byDay.get(day) ?? 0);
  108. }
  109. // ---------------------------------------------------------------------------
  110. // Dimensions
  111. // ---------------------------------------------------------------------------
  112. type Order = 'value_desc' | 'bucket' | 'version_desc';
  113. interface DimSpec {
  114. /** Axis/legend label for the values of this dimension. */
  115. label: string;
  116. /** Which number the chart plots when the caller does not say. */
  117. metric: 'machines' | 'count';
  118. /**
  119. * Restrict to one event type. Set wherever the same dim is emitted by more
  120. * than one event and the panel means a specific one — `target` rides both
  121. * install and uninstall, and "AI agent targets" means the installs.
  122. */
  123. event?: string;
  124. order: Order;
  125. /** Fixed display order for bucket dims, whose meaning IS their order. */
  126. buckets?: readonly string[];
  127. }
  128. const FILE_COUNT_BUCKETS = ['<100', '100-1k', '1k-10k', '10k+'] as const;
  129. const DURATION_BUCKETS = ['<10s', '10-60s', '1-5m', '5m+'] as const;
  130. /**
  131. * The closed set of breakdowns. A dim not in here is a 400, which is what keeps
  132. * `?dim=` from being a way to ask the database questions of the caller's own design.
  133. * Values mirror the cron's dimension list (telemetry-worker/src/rollup.ts).
  134. */
  135. const DIMS: Record<string, DimSpec> = {
  136. os: { label: 'Operating system', metric: 'machines', order: 'value_desc' },
  137. arch: { label: 'Architecture', metric: 'machines', order: 'value_desc' },
  138. codegraph_version: { label: 'Version', metric: 'machines', order: 'version_desc' },
  139. node_major: { label: 'Node major', metric: 'machines', order: 'version_desc' },
  140. language: { label: 'Language', metric: 'count', order: 'value_desc' },
  141. file_count_bucket: {
  142. label: 'Files in project',
  143. metric: 'count',
  144. order: 'bucket',
  145. buckets: FILE_COUNT_BUCKETS,
  146. },
  147. duration_bucket: {
  148. label: 'Indexing run length',
  149. metric: 'count',
  150. order: 'bucket',
  151. buckets: DURATION_BUCKETS,
  152. },
  153. target: { label: 'Agent target', metric: 'count', event: 'install', order: 'value_desc' },
  154. scope: { label: 'Install scope', metric: 'count', event: 'install', order: 'value_desc' },
  155. kind: { label: 'Install kind', metric: 'count', event: 'install', order: 'value_desc' },
  156. name: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
  157. client_name: { label: 'Agent', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
  158. name_error: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
  159. };
  160. /** Newest first, numerically per segment, so 1.10.0 sorts above 1.9.0. */
  161. function compareVersionsDesc(a: string, b: string): number {
  162. const pa = a.split(/[.-]/);
  163. const pb = b.split(/[.-]/);
  164. for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
  165. const na = Number(pa[i]);
  166. const nb = Number(pb[i]);
  167. if (Number.isFinite(na) && Number.isFinite(nb)) {
  168. if (na !== nb) return nb - na;
  169. } else {
  170. const sa = pa[i] ?? '';
  171. const sb = pb[i] ?? '';
  172. if (sa !== sb) return sb.localeCompare(sa);
  173. }
  174. }
  175. return 0;
  176. }
  177. // ---------------------------------------------------------------------------
  178. // /api/meta
  179. // ---------------------------------------------------------------------------
  180. interface MetaRow {
  181. latest_rollup_day: string | null;
  182. earliest_rollup_day: string | null;
  183. latest_active_day: string | null;
  184. earliest_active_day: string | null;
  185. earliest_raw_day: string | null;
  186. latest_raw_day: string | null;
  187. }
  188. /**
  189. * What the picker anchors on. The dashboard asks for this first and ends every
  190. * default range on `latest_day`, because the nightly cron has not rolled up
  191. * today yet — anchoring on the wall clock would put a phantom zero on the right
  192. * edge of every line chart.
  193. */
  194. async function meta(env: Env): Promise<ApiResult> {
  195. const row = await env.DB.prepare(
  196. `SELECT (SELECT max(day) FROM daily_event_counts) AS latest_rollup_day,
  197. (SELECT min(day) FROM daily_event_counts) AS earliest_rollup_day,
  198. (SELECT max(day) FROM machine_days) AS latest_active_day,
  199. (SELECT min(day) FROM machine_days) AS earliest_active_day,
  200. (SELECT min(day) FROM events) AS earliest_raw_day,
  201. (SELECT max(day) FROM events) AS latest_raw_day`,
  202. ).first<MetaRow>();
  203. const latest = row?.latest_rollup_day ?? row?.latest_active_day ?? null;
  204. const earliest = row?.earliest_rollup_day ?? row?.earliest_active_day ?? null;
  205. return {
  206. body: {
  207. latest_day: latest,
  208. earliest_day: earliest,
  209. latest_rollup_day: row?.latest_rollup_day ?? null,
  210. latest_active_day: row?.latest_active_day ?? null,
  211. /** Below this day the activation funnel is blind — raw events are purged. */
  212. earliest_raw_day: row?.earliest_raw_day ?? null,
  213. latest_raw_day: row?.latest_raw_day ?? null,
  214. max_range_days: MAX_RANGE_DAYS,
  215. retention_days: RETENTION_DAYS,
  216. generated_at: new Date().toISOString(),
  217. },
  218. cacheControl: CACHE_CONTROL,
  219. };
  220. }
  221. // ---------------------------------------------------------------------------
  222. // /api/summary — the big numbers
  223. // ---------------------------------------------------------------------------
  224. /**
  225. * One D1 batch, one round trip. Distinct-machine numbers come from
  226. * `machine_days` rather than by summing `daily_machines`: a machine active on
  227. * five days is one user, and summing the daily counts would call it five.
  228. */
  229. async function summary(env: Env, range: Range): Promise<ApiResult> {
  230. const { from, to } = range;
  231. const batch = await env.DB.batch([
  232. env.DB.prepare(
  233. `SELECT count(DISTINCT machine_id) AS n FROM machine_days
  234. WHERE day BETWEEN ? AND ? AND prod = 1`,
  235. ).bind(from, to),
  236. env.DB.prepare(
  237. `SELECT count(DISTINCT machine_id) AS n FROM machine_days WHERE day BETWEEN ? AND ?`,
  238. ).bind(from, to),
  239. env.DB.prepare(
  240. `SELECT count(*) AS n FROM machine_first_seen WHERE first_day BETWEEN ? AND ?`,
  241. ).bind(from, to),
  242. env.DB.prepare(
  243. `SELECT event, sum(count) AS events, sum(machines) AS machines
  244. FROM daily_event_counts WHERE day BETWEEN ? AND ? GROUP BY event`,
  245. ).bind(from, to),
  246. ]);
  247. const byEvent = new Map<string, number>();
  248. for (const row of rowsOf<{ event: string; events: number }>(batch[3])) {
  249. byEvent.set(row.event, row.events ?? 0);
  250. }
  251. const eventCount = (name: string): number => byEvent.get(name) ?? 0;
  252. return {
  253. body: {
  254. range,
  255. production_users: firstOf<{ n: number }>(batch[0])?.n ?? 0,
  256. active_machines: firstOf<{ n: number }>(batch[1])?.n ?? 0,
  257. new_machines: firstOf<{ n: number }>(batch[2])?.n ?? 0,
  258. installs: eventCount('install'),
  259. uninstalls: eventCount('uninstall'),
  260. index_runs: eventCount('index'),
  261. tool_calls: eventCount('usage_rollup'),
  262. },
  263. cacheControl: CACHE_CONTROL,
  264. };
  265. }
  266. // ---------------------------------------------------------------------------
  267. // /api/timeseries — the line charts
  268. // ---------------------------------------------------------------------------
  269. interface DayValueRow {
  270. day: string;
  271. a: number | null;
  272. b: number | null;
  273. }
  274. interface SeriesSpec {
  275. title: string;
  276. /** Series labels, in the order their data lands in `datasets`. */
  277. labels: [string] | [string, string];
  278. sql: string;
  279. binds: (range: Range) => (string | number)[];
  280. }
  281. /**
  282. * Every metric here reads a rollup table, so a line stays correct for days whose
  283. * raw events are long gone. Each query returns (day, a[, b]) and is densified
  284. * against the full day list, because a day with no rows means zero, not a gap.
  285. */
  286. const SERIES: Record<string, SeriesSpec> = {
  287. installs_uninstalls: {
  288. title: 'Installs and uninstalls',
  289. labels: ['Installs', 'Uninstalls'],
  290. sql: `SELECT day,
  291. sum(CASE WHEN event = 'install' THEN count ELSE 0 END) AS a,
  292. sum(CASE WHEN event = 'uninstall' THEN count ELSE 0 END) AS b
  293. FROM daily_event_counts
  294. WHERE day BETWEEN ? AND ? AND event IN ('install', 'uninstall')
  295. GROUP BY day`,
  296. binds: (r) => [r.from, r.to],
  297. },
  298. new_installs: {
  299. title: 'New installs',
  300. labels: ['New machines'],
  301. sql: `SELECT first_day AS day, count(*) AS a, NULL AS b
  302. FROM machine_first_seen
  303. WHERE first_day BETWEEN ? AND ?
  304. GROUP BY first_day`,
  305. binds: (r) => [r.from, r.to],
  306. },
  307. production_users: {
  308. title: 'Daily production users',
  309. labels: ['Production users'],
  310. sql: `SELECT day, prod_machines AS a, NULL AS b
  311. FROM daily_machines WHERE day BETWEEN ? AND ?`,
  312. binds: (r) => [r.from, r.to],
  313. },
  314. indexing_activity: {
  315. title: 'Daily indexing activity',
  316. labels: ['Indexing runs', 'Machines indexing'],
  317. sql: `SELECT day, count AS a, machines AS b
  318. FROM daily_event_counts
  319. WHERE day BETWEEN ? AND ? AND event = 'index'`,
  320. binds: (r) => [r.from, r.to],
  321. },
  322. tool_calls: {
  323. title: 'Daily tool and command calls',
  324. labels: ['Calls', 'Machines'],
  325. sql: `SELECT day, count AS a, machines AS b
  326. FROM daily_event_counts
  327. WHERE day BETWEEN ? AND ? AND event = 'usage_rollup'`,
  328. binds: (r) => [r.from, r.to],
  329. },
  330. };
  331. async function timeseries(env: Env, url: URL, range: Range): Promise<ApiResult> {
  332. const metric = url.searchParams.get('metric') ?? 'installs_uninstalls';
  333. // The one metric whose series are data-driven rather than fixed: one line per
  334. // duration bucket, in bucket order (an ordered scale, so the order is meaning).
  335. if (metric === 'duration_buckets') return durationBucketSeries(env, range);
  336. const spec = SERIES[metric];
  337. if (!spec) {
  338. return fail(`unknown metric — one of: ${[...Object.keys(SERIES), 'duration_buckets'].join(', ')}`);
  339. }
  340. const { results } = await env.DB.prepare(spec.sql)
  341. .bind(...spec.binds(range))
  342. .all<DayValueRow>();
  343. const labels = dayList(range);
  344. const a = new Map<string, number>();
  345. const b = new Map<string, number>();
  346. for (const row of results) {
  347. a.set(row.day, row.a ?? 0);
  348. b.set(row.day, row.b ?? 0);
  349. }
  350. const datasets = [{ label: spec.labels[0], data: densify(labels, a) }];
  351. if (spec.labels.length === 2) datasets.push({ label: spec.labels[1], data: densify(labels, b) });
  352. return {
  353. body: {
  354. range,
  355. metric,
  356. title: spec.title,
  357. labels,
  358. datasets,
  359. rows: labels.map((day, i) => ({
  360. day,
  361. ...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
  362. })),
  363. },
  364. cacheControl: CACHE_CONTROL,
  365. };
  366. }
  367. /** "Session run length over time": one series per duration bucket, bucket-ordered. */
  368. async function durationBucketSeries(env: Env, range: Range): Promise<ApiResult> {
  369. const { results } = await env.DB.prepare(
  370. `SELECT day, value, sum(count) AS n
  371. FROM daily_dim_counts
  372. WHERE dim = 'duration_bucket' AND event = 'index' AND day BETWEEN ? AND ?
  373. GROUP BY day, value`,
  374. )
  375. .bind(range.from, range.to)
  376. .all<{ day: string; value: string; n: number }>();
  377. const labels = dayList(range);
  378. const perBucket = new Map<string, Map<string, number>>();
  379. for (const row of results) {
  380. let series = perBucket.get(row.value);
  381. if (!series) perBucket.set(row.value, (series = new Map()));
  382. series.set(row.day, row.n ?? 0);
  383. }
  384. // Fixed buckets first and always present (a bucket with no runs is a real zero,
  385. // and dropping it would silently renumber the ordinal colour ramp); anything
  386. // unexpected from an older client is appended rather than hidden.
  387. const extra = [...perBucket.keys()].filter((v) => !DURATION_BUCKETS.includes(v as never)).sort();
  388. const order = [...DURATION_BUCKETS, ...extra];
  389. const datasets = order.map((bucket) => ({
  390. label: bucket,
  391. data: densify(labels, perBucket.get(bucket) ?? new Map()),
  392. }));
  393. return {
  394. body: {
  395. range,
  396. metric: 'duration_buckets',
  397. title: 'Indexing run length over time',
  398. labels,
  399. datasets,
  400. rows: labels.map((day, i) => ({
  401. day,
  402. ...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
  403. })),
  404. },
  405. cacheControl: CACHE_CONTROL,
  406. };
  407. }
  408. // ---------------------------------------------------------------------------
  409. // /api/breakdown — the bars and pies
  410. // ---------------------------------------------------------------------------
  411. interface BreakdownRow {
  412. value: string;
  413. count: number;
  414. machines: number;
  415. }
  416. /**
  417. * Sums one dimension over the range.
  418. *
  419. * On the `machines` metric: `daily_dim_counts.machines` is per day, so summing
  420. * it over a range gives **machine-days**, not distinct machines — a machine
  421. * seen on ten days counts ten times. A range-wide distinct count per dimension
  422. * value is not recoverable from the rollups at all (it would need the raw
  423. * events, which are purged), so rather than quietly presenting one as the
  424. * other, the number is honestly named machine-days everywhere it appears, and
  425. * the panels that use it are share-of-total panels where the distinction does
  426. * not move the shape.
  427. *
  428. * The inner `max(machines)` is the other half of that honesty: the same machine
  429. * emits install *and* index *and* usage_rollup on one day, each carrying `os`,
  430. * so summing `machines` across event types would triple-count it. Taking the
  431. * largest single-event count for the day is the closest lower bound the rollups
  432. * can give. When a dim belongs to exactly one event (or `?event=` pins it) the
  433. * `max` is over a single row and the question does not arise.
  434. */
  435. async function breakdown(env: Env, url: URL, range: Range): Promise<ApiResult> {
  436. const dim = url.searchParams.get('dim') ?? '';
  437. const spec = DIMS[dim];
  438. if (!spec) return fail(`unknown dim — one of: ${Object.keys(DIMS).join(', ')}`);
  439. const requestedMetric = url.searchParams.get('metric');
  440. if (requestedMetric !== null && requestedMetric !== 'count' && requestedMetric !== 'machines') {
  441. return fail('metric must be count or machines');
  442. }
  443. const metric = requestedMetric ?? spec.metric;
  444. const rawLimit = url.searchParams.get('limit');
  445. const limit = rawLimit === null ? DEFAULT_BREAKDOWN_LIMIT : Number(rawLimit);
  446. if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BREAKDOWN_LIMIT) {
  447. return fail(`limit must be an integer between 1 and ${MAX_BREAKDOWN_LIMIT}`);
  448. }
  449. const event = url.searchParams.get('event') ?? spec.event ?? null;
  450. if (event !== null && !/^[a-z_]{1,32}$/.test(event)) return fail('event must be a bare event name');
  451. const binds: (string | number)[] = [dim, range.from, range.to];
  452. if (event !== null) binds.push(event);
  453. const { results } = await env.DB.prepare(
  454. `SELECT value, sum(day_count) AS count, sum(day_machines) AS machines
  455. FROM (SELECT day, value, sum(count) AS day_count, max(machines) AS day_machines
  456. FROM daily_dim_counts
  457. WHERE dim = ? AND day BETWEEN ? AND ?${event !== null ? ' AND event = ?' : ''}
  458. GROUP BY day, value)
  459. GROUP BY value`,
  460. )
  461. .bind(...binds)
  462. .all<BreakdownRow>();
  463. const rows = results.map((r) => ({
  464. value: r.value,
  465. count: r.count ?? 0,
  466. machines: r.machines ?? 0,
  467. }));
  468. const pick = (r: BreakdownRow): number => (metric === 'machines' ? r.machines : r.count);
  469. let ordered: BreakdownRow[];
  470. let truncated = false;
  471. if (spec.order === 'bucket' && spec.buckets) {
  472. // An ordered scale: the buckets keep their own order and all of them show,
  473. // including empty ones, so the ordinal colour ramp always means the same thing.
  474. const found = new Map(rows.map((r) => [r.value, r]));
  475. const extra = rows.filter((r) => !spec.buckets?.includes(r.value)).sort((x, y) => pick(y) - pick(x));
  476. ordered = [
  477. ...spec.buckets.map((b) => found.get(b) ?? { value: b, count: 0, machines: 0 }),
  478. ...extra,
  479. ];
  480. } else {
  481. const sorted = [...rows].sort(
  482. spec.order === 'version_desc'
  483. ? (x, y) => compareVersionsDesc(x.value, y.value)
  484. : (x, y) => pick(y) - pick(x) || x.value.localeCompare(y.value),
  485. );
  486. if (sorted.length > limit) {
  487. // Fold rather than truncate: a chopped bar chart quietly changes what the
  488. // total means, and "Other" keeps the panel's total honest.
  489. const head = sorted.slice(0, limit);
  490. const tail = sorted.slice(limit);
  491. ordered = [
  492. ...head,
  493. {
  494. value: 'Other',
  495. count: tail.reduce((n, r) => n + r.count, 0),
  496. machines: tail.reduce((n, r) => n + r.machines, 0),
  497. },
  498. ];
  499. truncated = true;
  500. } else {
  501. ordered = sorted;
  502. }
  503. }
  504. const data = ordered.map(pick);
  505. return {
  506. body: {
  507. range,
  508. dim,
  509. event,
  510. metric,
  511. title: spec.label,
  512. labels: ordered.map((r) => r.value),
  513. datasets: [{ label: metric === 'machines' ? 'Machine-days' : 'Events', data }],
  514. rows: ordered,
  515. total: data.reduce((n, v) => n + v, 0),
  516. /** True when the tail was folded into an "Other" bar. */
  517. truncated,
  518. },
  519. cacheControl: CACHE_CONTROL,
  520. };
  521. }
  522. // ---------------------------------------------------------------------------
  523. // /api/activation — install → first index
  524. // ---------------------------------------------------------------------------
  525. interface ActivationRow {
  526. day: string;
  527. installs: number;
  528. activated: number;
  529. }
  530. /**
  531. * Of the machines whose FIRST day falls in the range, how many ran an index
  532. * within `window` days of it.
  533. *
  534. * The cohort key is `machine_first_seen`, not install events: a machine that
  535. * reinstalls does not re-enter the funnel, which is what makes this a
  536. * conversion rate rather than an install-event ratio.
  537. *
  538. * The LEFT JOIN rides events_machine_day (machine_id, day) and `count(DISTINCT)`
  539. * absorbs the fan-out from a machine that indexed many times. This is the one
  540. * endpoint that reads raw `events`, so it is bounded by the retention window —
  541. * `raw_events_from` tells the caller where the data actually starts, and the UI
  542. * says so rather than drawing a cliff and calling it a drop in conversion.
  543. */
  544. async function activation(env: Env, url: URL, range: Range): Promise<ApiResult> {
  545. const rawWindow = url.searchParams.get('window');
  546. const window = rawWindow === null ? DEFAULT_ACTIVATION_WINDOW : Number(rawWindow);
  547. if (!Number.isInteger(window) || window < 1 || window > MAX_ACTIVATION_WINDOW) {
  548. return fail(`window must be an integer between 1 and ${MAX_ACTIVATION_WINDOW}`);
  549. }
  550. const batch = await env.DB.batch([
  551. env.DB.prepare(
  552. `SELECT f.first_day AS day,
  553. count(DISTINCT f.machine_id) AS installs,
  554. count(DISTINCT CASE WHEN e.machine_id IS NOT NULL THEN f.machine_id END) AS activated
  555. FROM machine_first_seen f
  556. LEFT JOIN events e
  557. ON e.machine_id = f.machine_id
  558. AND e.event = 'index'
  559. AND e.day >= f.first_day
  560. AND e.day <= date(f.first_day, ?)
  561. WHERE f.first_day BETWEEN ? AND ?
  562. GROUP BY f.first_day`,
  563. // A bound modifier string, built from an integer this function validated —
  564. // date() takes the modifier as data, so nothing is concatenated into SQL.
  565. ).bind(`+${window} days`, range.from, range.to),
  566. env.DB.prepare(`SELECT min(day) AS raw_from, max(day) AS raw_to FROM events`),
  567. ]);
  568. const rows = rowsOf<ActivationRow>(batch[0]);
  569. const byDay = new Map(rows.map((r) => [r.day, r]));
  570. const labels = dayList(range);
  571. const installs = rows.reduce((n, r) => n + (r.installs ?? 0), 0);
  572. const activated = rows.reduce((n, r) => n + (r.activated ?? 0), 0);
  573. // Cohorts younger than the window have not finished converting yet, so their
  574. // rate is a floor, not a result. Marked rather than dropped: hiding the last
  575. // week of a conversion chart is its own kind of lie.
  576. const boundsRow = firstOf<{ raw_from: string | null; raw_to: string | null }>(batch[1]);
  577. const latestRaw = boundsRow?.raw_to ?? utcDay(Date.now());
  578. const incompleteFrom = addDays(latestRaw, -(window - 1));
  579. const detail = labels.map((day) => {
  580. const row = byDay.get(day);
  581. const dayInstalls = row?.installs ?? 0;
  582. const dayActivated = row?.activated ?? 0;
  583. return {
  584. day,
  585. installs: dayInstalls,
  586. activated: dayActivated,
  587. rate: dayInstalls > 0 ? dayActivated / dayInstalls : null,
  588. complete: day < incompleteFrom,
  589. };
  590. });
  591. return {
  592. body: {
  593. range,
  594. window_days: window,
  595. installs,
  596. activated,
  597. dropped: installs - activated,
  598. rate: installs > 0 ? activated / installs : null,
  599. /** Cohorts from this day on have not had the full window to convert. */
  600. incomplete_from: incompleteFrom,
  601. /** Raw events start here; a range reaching further back under-counts. */
  602. raw_events_from: boundsRow?.raw_from ?? null,
  603. labels,
  604. datasets: [
  605. {
  606. label: 'Activation rate',
  607. data: detail.map((d) => (d.rate === null ? null : Math.round(d.rate * 1000) / 10)),
  608. },
  609. ],
  610. rows: detail,
  611. },
  612. cacheControl: CACHE_CONTROL,
  613. };
  614. }
  615. // ---------------------------------------------------------------------------
  616. // /api/retention — day 0–14 cohort curve
  617. // ---------------------------------------------------------------------------
  618. /**
  619. * For machines first seen in the range, the share still active k days later.
  620. *
  621. * Read entirely off `machine_days` + `machine_first_seen`, neither of which the
  622. * retention purge touches, so this answers for any range in history.
  623. *
  624. * The denominator is per-k, not the whole cohort: a machine first seen
  625. * yesterday cannot have a day-7 data point, and dividing by it anyway would
  626. * bend every recent cohort's curve toward zero. So day k is measured only over
  627. * the machines that have actually had k days to come back — `eligible[k]`. The
  628. * numerator needs no matching filter, since a machine with fewer than k days
  629. * elapsed contributes zero to day k by construction.
  630. */
  631. async function retention(env: Env, range: Range): Promise<ApiResult> {
  632. const batch = await env.DB.batch([
  633. env.DB.prepare(
  634. `SELECT CAST(julianday(d.day) - julianday(f.first_day) AS INTEGER) AS k,
  635. count(DISTINCT d.machine_id) AS machines
  636. FROM machine_first_seen f
  637. JOIN machine_days d ON d.machine_id = f.machine_id
  638. WHERE f.first_day BETWEEN ? AND ?
  639. AND d.day >= f.first_day
  640. AND d.day <= date(f.first_day, ?)
  641. GROUP BY k`,
  642. ).bind(range.from, range.to, `+${RETENTION_DAYS} days`),
  643. env.DB.prepare(
  644. `SELECT first_day AS day, count(*) AS machines
  645. FROM machine_first_seen WHERE first_day BETWEEN ? AND ? GROUP BY first_day`,
  646. ).bind(range.from, range.to),
  647. env.DB.prepare(`SELECT max(day) AS day FROM machine_days`),
  648. ]);
  649. const retained = new Map(
  650. rowsOf<{ k: number; machines: number }>(batch[0]).map((r) => [r.k, r.machines ?? 0]),
  651. );
  652. const cohortDays = rowsOf<{ day: string; machines: number }>(batch[1]);
  653. const cohortSize = cohortDays.reduce((n, r) => n + (r.machines ?? 0), 0);
  654. const latestDay = firstOf<{ day: string | null }>(batch[2])?.day ?? utcDay(Date.now());
  655. const rows = [];
  656. for (let k = 0; k <= RETENTION_DAYS; k++) {
  657. // Machines whose first day is early enough that day k has already happened.
  658. const cutoff = addDays(latestDay, -k);
  659. const eligible = cohortDays.reduce((n, r) => (r.day <= cutoff ? n + (r.machines ?? 0) : n), 0);
  660. const back = retained.get(k) ?? 0;
  661. rows.push({
  662. day: k,
  663. eligible,
  664. retained: back,
  665. rate: eligible > 0 ? back / eligible : null,
  666. });
  667. }
  668. return {
  669. body: {
  670. range,
  671. cohort: cohortSize,
  672. window_days: RETENTION_DAYS,
  673. labels: rows.map((r) => `Day ${r.day}`),
  674. datasets: [
  675. {
  676. label: 'Retained',
  677. data: rows.map((r) => (r.rate === null ? null : Math.round(r.rate * 1000) / 10)),
  678. },
  679. ],
  680. rows,
  681. },
  682. cacheControl: CACHE_CONTROL,
  683. };
  684. }
  685. // ---------------------------------------------------------------------------
  686. // /api/health — liveness, and the only endpoint that is not range-scoped
  687. // ---------------------------------------------------------------------------
  688. async function health(env: Env): Promise<ApiResult> {
  689. try {
  690. const batch = await env.DB.batch<{ day: string | null }>([
  691. env.DB.prepare('SELECT max(day) AS day FROM events'),
  692. env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
  693. ]);
  694. return {
  695. body: {
  696. ok: true,
  697. database: {
  698. latest_event_day: batch[0]?.results[0]?.day ?? null,
  699. latest_rollup_day: batch[1]?.results[0]?.day ?? null,
  700. },
  701. },
  702. };
  703. } catch (err) {
  704. console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
  705. return { body: { ok: false, error: 'database unavailable' }, status: 503 };
  706. }
  707. }
  708. // ---------------------------------------------------------------------------
  709. // Router
  710. // ---------------------------------------------------------------------------
  711. /**
  712. * Called only for an authenticated GET — src/index.ts owns the session gate and
  713. * turns what comes back into a Response.
  714. */
  715. export async function handleApi(env: Env, url: URL): Promise<ApiResult> {
  716. if (url.pathname === '/api/session') return { body: { authenticated: true } };
  717. if (url.pathname === '/api/health') return health(env);
  718. if (url.pathname === '/api/meta') return meta(env);
  719. const ranged = new Set(['/api/summary', '/api/timeseries', '/api/breakdown', '/api/activation', '/api/retention']);
  720. if (!ranged.has(url.pathname)) return fail('not found', 404);
  721. const range = parseRange(url);
  722. if (isApiResult(range)) return range;
  723. try {
  724. switch (url.pathname) {
  725. case '/api/summary':
  726. return await summary(env, range);
  727. case '/api/timeseries':
  728. return await timeseries(env, url, range);
  729. case '/api/breakdown':
  730. return await breakdown(env, url, range);
  731. case '/api/activation':
  732. return await activation(env, url, range);
  733. case '/api/retention':
  734. return await retention(env, range);
  735. default:
  736. return fail('not found', 404);
  737. }
  738. } catch (err) {
  739. // The query failed, not the caller. Log the cause, tell the page something
  740. // it can put in the panel, and let the other panels carry on.
  741. console.error(JSON.stringify({ msg: 'api query failed', path: url.pathname, err: String(err) }));
  742. return { body: { error: 'query failed' }, status: 503 };
  743. }
  744. }