* feat(telemetry): D1 schema + migrations for raw events and daily rollups First step of replacing PostHog with self-hosted telemetry on Cloudflare D1. Creates the codegraph-telemetry database binding and the initial migration; no worker code paths change yet (the ingest write path and the nightly rollup cron land next). Schema is raw events plus daily rollups: `events` holds one row per sanitized event with the envelope broken out into columns and event-specific props as JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the nightly rollups the dashboard reads; `machine_first_seen` and `machine_days` carry the retention cohorts and are never purged. One generic dimension table covers every bar and pie, so a new breakdown is a cron change rather than a migration. The migration is commented as an audit surface, like the rest of this worker — every column, and which dashboard chart each rollup table serves. Three judgment calls worth flagging, all documented in the file: - `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)` indexes. D1 bills a row write per index touched, so a third index on the hot table costs ~97k writes/day, and `(day, event)` is a covering index for plain day-range scans anyway (verified with EXPLAIN QUERY PLAN). - `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and `machine_days` a `prod` flag. The "users by ..." panels and the production-user count are distinct-machine numbers, not event counts, and they are unrecoverable once raw events are purged. - No CHECK constraint on `event`: the worker's allowlist is the source of truth and the write path is fail-silent, so a rejected INSERT would lose data quietly instead of erroring loudly. Volume note in the migration footer: ~30M row writes/month against the 50M included on Workers Paid. Storage is the tighter constraint — raw events grow ~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180, which would exceed D1's 10 GB per-database cap. * feat(telemetry): admin dashboard worker — scaffold + shared-password auth New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and bound read-only to the same D1 database. Serves a static frontend plus a JSON API behind a shared password, on stats.getcodegraph.com. Auth is the simplest thing that is actually safe for exactly two users: one password in a secret, compared in constant time over SHA-256 digests, and an HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year expiry so you sign in once per browser. The cookie is a signed assertion, not a lookup key — no session store. Its payload carries a fingerprint of the password it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login attempts are capped at 5/min per IP via a ratelimit binding. Everything is deny-by-default: assets.run_worker_first routes every request through the worker before the static-asset server sees it, so the dashboard HTML, its JS, its CSS and the chart library are all behind the session check. The login page is rendered inline by the worker rather than served from public/, which leaves no "is this file public?" judgement calls in the asset directory. Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing secret fails closed rather than opening the dashboard. scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway `wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/ truncated cookies, open-redirect refusal, brute-force capping, and password rotation invalidating live sessions. Refs CG-11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(telemetry-dashboard): simplify the chart-library probe in the shell Refs CG-11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10) Adds a scheduled() handler to the ingest worker that recomputes daily_event_counts / daily_dim_counts / daily_machines for the just-completed UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw events past the retention window. Rollup writes are idempotent upserts, so a re-run never double-counts. Also adds an ADMIN_TOKEN-guarded POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog forwarding path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13) Replaces the scaffold page with the dashboard proper: 19 panels covering every view of the PostHog dashboard this retires, driven by one filter row. src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries, breakdown,activation,retention}, all range-scoped, all parameterized against a closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend does no arithmetic. Rollups answer everything except the activation funnel, which needs raw events and says where they start. The frontend splits into a DOM-free panel registry (public/panels.js) and the page that mounts it (public/app.js), so the render check can drive the same registry the browser rendered from. Panels fail alone, refetch dims rather than flashing, and every chart carries a table twin. Two numbers are labelled rather than rounded off: range-wide "users" per dimension is machine-days (the rollups cannot give distinct machines, and per-day counts are taken as the largest single-event count so one machine's install + index + usage is not counted three times), and recent activation and retention cohorts are marked as still-converting instead of drawn as a cliff. Both colour scales were run through the data-viz validator against the panel surface, not picked by eye; the results are recorded in public/theme.js. Verification, all against the committed fixture (12 machines over 10 days, every expected number worked out by hand from the events, not recorded from a run): scripts/smoke-api.sh 98 assertions scripts/render-check.mjs 79 assertions — real Chromium over CDP, no new deps scripts/smoke-auth.sh 54 assertions (unchanged, still green) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14) The account-level steps of the PostHog cutover are the maintainer's to run, so this lands the runbook they follow and the check that has to pass first. The runbook (telemetry-worker/README.md) walks the six steps in the order that keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the subscription. Step 3 records the outgoing version id because `wrangler rollback` is the escape hatch for the whole verification window, and that window is precisely why the PostHog key is deleted last rather than first. The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one seam nothing else did. Both workers declare the same D1 database_id, so pointing them at a single --persist-to directory runs the real chain: a client batch → the ingest worker → D1 → the nightly rollup → the dashboard API reading the numbers back. Every other suite stops at one link — smoke-ingest at the events table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture that the cron never touched. That left the dimension names the rollup WRITES versus the ones the dashboard READS agreeing by convention across two branches, where a mismatch is silent: no error, no failed request, just a panel reading zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a ci machine that is active but not a production user, usage_rollup counts that must be summed rather than tallied, and an uninstall's `targets` that must not leak into the install-scoped breakdown. Writing it caught that the activation funnel's denominator is first-seen machines, not install events (deliberate — a reinstall must not re-enter the funnel), so the suite now pins that distinction rather than assuming it. Also rewords the last PostHog reference in dashboard code: a comment justifying the 14-day retention curve by pointing at a dashboard step 6 deletes. The reasoning now stands on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(telemetry): tell the truth about where events are stored (CG-15) The telemetry docs are a privacy contract, and they still described a managed analytics store that no longer receives anything. Replace that with what actually happens now — events land in our own D1 database on Cloudflare, the endpoint makes no outbound requests, raw events are purged after 90 days and only anonymous daily rollups outlive them. This strengthens the guarantee rather than restating it: there is no second party to share with. - TELEMETRY.md: new "Where it is stored" section; the never-collected IP bullet no longer leans on a vendor-side setting to hold. - docs/design/telemetry.md: ingest section rewritten around D1 + the nightly rollup/retention cron; volume math redone on Workers Paid and the D1 quota (storage, not writes, is what sets the 90-day window); new section documenting the dashboard worker and cross-linking it. - Fixed three drifts from the worker allowlist the sweep surfaced: schema_version was still 1, client_name/client_version was still marked "plumbing to add" though session.ts passes it today, and the legacy sqlite_backend field the worker still accepts was undocumented. - telemetry-worker/README.md: step 6 claimed a repo-wide grep came back clean, which this runbook itself falsifies. Added step 7 — deleting the runbook is what makes that grep true, and is the completion check. - smoke-cutover.sh: the vendor guarantee is now asserted by class (no analytics-ingest endpoint referenced) rather than by one vendor's name, so it keeps working once the name is gone. Verified it still catches a planted forwarding URL. 61/61 pass. Retention is documented as 90 days, not the 180 in the task notes: 180 days of raw events exceeds D1's 10 GB per-database cap, and the code purges at 90. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: untrack local Kommandr issue DB and ignore its sqlite artifacts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
828 lines
31 KiB
TypeScript
828 lines
31 KiB
TypeScript
/**
|
||
* The dashboard's read API: one JSON endpoint per chart shape, all of them
|
||
* scoped by the same `?from=&to=` range the picker drives.
|
||
*
|
||
* Rules this file keeps:
|
||
* - **Rollups first.** Every panel is answered from `daily_*` / `machine_days`,
|
||
* which are kept forever. Only the activation funnel touches raw `events`,
|
||
* because "did this machine ever run an index" is not a daily aggregate — and
|
||
* that is also the only endpoint with a horizon (the retention window).
|
||
* - **Parameterized, always.** No value from the query string is ever
|
||
* concatenated into SQL. Dimensions and metrics are looked up in the tables
|
||
* below and rejected with a 400 if they are not there, so even the column
|
||
* *names* a caller can reach are a closed set.
|
||
* - **Chart-shaped.** Responses come back as `labels[] + datasets[]` so the
|
||
* frontend does no arithmetic; every response also carries `rows` in its
|
||
* natural shape, which is what the per-panel table view renders.
|
||
* - **No Response objects.** Handlers return plain data and let src/index.ts
|
||
* apply the security headers, so there is exactly one place where headers on
|
||
* an authenticated response are decided.
|
||
*/
|
||
|
||
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||
const DAY_MS = 86_400_000;
|
||
|
||
/** A year of daily points is already more than the charts can draw legibly. */
|
||
const MAX_RANGE_DAYS = 366;
|
||
const DEFAULT_RANGE_DAYS = 30;
|
||
|
||
/**
|
||
* Retention curve length. Two weeks covers the day-1 and day-7 cliffs where nearly
|
||
* all churn happens, and matches the window the previous analytics dashboard drew,
|
||
* so the numbers stay comparable across the cutover.
|
||
*/
|
||
const RETENTION_DAYS = 14;
|
||
/** Days a machine gets to run its first index before it counts as churned. */
|
||
const DEFAULT_ACTIVATION_WINDOW = 7;
|
||
const MAX_ACTIVATION_WINDOW = 30;
|
||
|
||
/** Bars past this fold into "Other" — see the note on `Other` in breakdown(). */
|
||
const DEFAULT_BREAKDOWN_LIMIT = 12;
|
||
const MAX_BREAKDOWN_LIMIT = 50;
|
||
|
||
/** Panels read at most every few minutes; the underlying data moves once a night. */
|
||
const CACHE_CONTROL = 'private, max-age=300';
|
||
|
||
export interface ApiResult {
|
||
body: unknown;
|
||
status?: number;
|
||
/** Omitted ⇒ src/index.ts keeps its `no-store` default. */
|
||
cacheControl?: string;
|
||
}
|
||
|
||
const fail = (error: string, status = 400): ApiResult => ({ body: { error }, status });
|
||
|
||
/**
|
||
* `noUncheckedIndexedAccess` types every slot of a `batch()` result as possibly
|
||
* undefined. These two keep that out of the query code, which reads better for
|
||
* being about rows rather than about array bounds.
|
||
*/
|
||
const rowsOf = <T>(result: D1Result | undefined): T[] => (result?.results ?? []) as unknown as T[];
|
||
const firstOf = <T>(result: D1Result | undefined): T | undefined => rowsOf<T>(result)[0];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Days
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const utcDay = (atMs: number): string => new Date(atMs).toISOString().slice(0, 10);
|
||
|
||
/** Rejects the wrong shape and impossible dates alike (`2026-02-31` round-trips as March). */
|
||
function isValidDay(day: string): boolean {
|
||
if (!DAY_RE.test(day)) return false;
|
||
const t = Date.parse(`${day}T00:00:00Z`);
|
||
return Number.isFinite(t) && utcDay(t) === day;
|
||
}
|
||
|
||
const dayMs = (day: string): number => Date.parse(`${day}T00:00:00Z`);
|
||
const addDays = (day: string, delta: number): string => utcDay(dayMs(day) + delta * DAY_MS);
|
||
const daysApart = (from: string, to: string): number => Math.round((dayMs(to) - dayMs(from)) / DAY_MS);
|
||
|
||
export interface Range {
|
||
from: string;
|
||
to: string;
|
||
/** Inclusive length. */
|
||
days: number;
|
||
/** The request asked for more than MAX_RANGE_DAYS and `from` was moved up. */
|
||
clamped: boolean;
|
||
}
|
||
|
||
/**
|
||
* The range every endpoint shares. Absent params default to the last 30 days
|
||
* ending today so a bare `curl /api/summary` still answers something sensible;
|
||
* the dashboard itself always sends both, anchored on /api/meta's latest day so
|
||
* no chart ends on a day the nightly rollup has not written yet.
|
||
*/
|
||
function parseRange(url: URL): Range | ApiResult {
|
||
const rawTo = url.searchParams.get('to');
|
||
const rawFrom = url.searchParams.get('from');
|
||
|
||
if (rawTo !== null && !isValidDay(rawTo)) return fail('to must be YYYY-MM-DD');
|
||
if (rawFrom !== null && !isValidDay(rawFrom)) return fail('from must be YYYY-MM-DD');
|
||
|
||
const to = rawTo ?? utcDay(Date.now());
|
||
const from = rawFrom ?? addDays(to, -(DEFAULT_RANGE_DAYS - 1));
|
||
if (from > to) return fail('from must not be after to');
|
||
|
||
const requested = daysApart(from, to) + 1;
|
||
const clamped = requested > MAX_RANGE_DAYS;
|
||
return {
|
||
from: clamped ? addDays(to, -(MAX_RANGE_DAYS - 1)) : from,
|
||
to,
|
||
days: clamped ? MAX_RANGE_DAYS : requested,
|
||
clamped,
|
||
};
|
||
}
|
||
|
||
const isApiResult = (v: Range | ApiResult): v is ApiResult => 'body' in v;
|
||
|
||
/** Every day in the range, so a chart's x-axis has no holes where nothing happened. */
|
||
function dayList(range: Range): string[] {
|
||
const days: string[] = [];
|
||
for (let i = 0; i < range.days; i++) days.push(addDays(range.from, i));
|
||
return days;
|
||
}
|
||
|
||
/** Turns day-keyed rows into a dense series aligned to `labels`. */
|
||
function densify(labels: string[], byDay: Map<string, number>): number[] {
|
||
return labels.map((day) => byDay.get(day) ?? 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Dimensions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type Order = 'value_desc' | 'bucket' | 'version_desc';
|
||
|
||
interface DimSpec {
|
||
/** Axis/legend label for the values of this dimension. */
|
||
label: string;
|
||
/** Which number the chart plots when the caller does not say. */
|
||
metric: 'machines' | 'count';
|
||
/**
|
||
* Restrict to one event type. Set wherever the same dim is emitted by more
|
||
* than one event and the panel means a specific one — `target` rides both
|
||
* install and uninstall, and "AI agent targets" means the installs.
|
||
*/
|
||
event?: string;
|
||
order: Order;
|
||
/** Fixed display order for bucket dims, whose meaning IS their order. */
|
||
buckets?: readonly string[];
|
||
}
|
||
|
||
const FILE_COUNT_BUCKETS = ['<100', '100-1k', '1k-10k', '10k+'] as const;
|
||
const DURATION_BUCKETS = ['<10s', '10-60s', '1-5m', '5m+'] as const;
|
||
|
||
/**
|
||
* The closed set of breakdowns. A dim not in here is a 400, which is what keeps
|
||
* `?dim=` from being a way to ask the database questions of the caller's own design.
|
||
* Values mirror the cron's dimension list (telemetry-worker/src/rollup.ts).
|
||
*/
|
||
const DIMS: Record<string, DimSpec> = {
|
||
os: { label: 'Operating system', metric: 'machines', order: 'value_desc' },
|
||
arch: { label: 'Architecture', metric: 'machines', order: 'value_desc' },
|
||
codegraph_version: { label: 'Version', metric: 'machines', order: 'version_desc' },
|
||
node_major: { label: 'Node major', metric: 'machines', order: 'version_desc' },
|
||
language: { label: 'Language', metric: 'count', order: 'value_desc' },
|
||
file_count_bucket: {
|
||
label: 'Files in project',
|
||
metric: 'count',
|
||
order: 'bucket',
|
||
buckets: FILE_COUNT_BUCKETS,
|
||
},
|
||
duration_bucket: {
|
||
label: 'Indexing run length',
|
||
metric: 'count',
|
||
order: 'bucket',
|
||
buckets: DURATION_BUCKETS,
|
||
},
|
||
target: { label: 'Agent target', metric: 'count', event: 'install', order: 'value_desc' },
|
||
scope: { label: 'Install scope', metric: 'count', event: 'install', order: 'value_desc' },
|
||
kind: { label: 'Install kind', metric: 'count', event: 'install', order: 'value_desc' },
|
||
name: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
|
||
client_name: { label: 'Agent', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
|
||
name_error: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
|
||
};
|
||
|
||
/** Newest first, numerically per segment, so 1.10.0 sorts above 1.9.0. */
|
||
function compareVersionsDesc(a: string, b: string): number {
|
||
const pa = a.split(/[.-]/);
|
||
const pb = b.split(/[.-]/);
|
||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||
const na = Number(pa[i]);
|
||
const nb = Number(pb[i]);
|
||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||
if (na !== nb) return nb - na;
|
||
} else {
|
||
const sa = pa[i] ?? '';
|
||
const sb = pb[i] ?? '';
|
||
if (sa !== sb) return sb.localeCompare(sa);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/meta
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface MetaRow {
|
||
latest_rollup_day: string | null;
|
||
earliest_rollup_day: string | null;
|
||
latest_active_day: string | null;
|
||
earliest_active_day: string | null;
|
||
earliest_raw_day: string | null;
|
||
latest_raw_day: string | null;
|
||
}
|
||
|
||
/**
|
||
* What the picker anchors on. The dashboard asks for this first and ends every
|
||
* default range on `latest_day`, because the nightly cron has not rolled up
|
||
* today yet — anchoring on the wall clock would put a phantom zero on the right
|
||
* edge of every line chart.
|
||
*/
|
||
async function meta(env: Env): Promise<ApiResult> {
|
||
const row = await env.DB.prepare(
|
||
`SELECT (SELECT max(day) FROM daily_event_counts) AS latest_rollup_day,
|
||
(SELECT min(day) FROM daily_event_counts) AS earliest_rollup_day,
|
||
(SELECT max(day) FROM machine_days) AS latest_active_day,
|
||
(SELECT min(day) FROM machine_days) AS earliest_active_day,
|
||
(SELECT min(day) FROM events) AS earliest_raw_day,
|
||
(SELECT max(day) FROM events) AS latest_raw_day`,
|
||
).first<MetaRow>();
|
||
|
||
const latest = row?.latest_rollup_day ?? row?.latest_active_day ?? null;
|
||
const earliest = row?.earliest_rollup_day ?? row?.earliest_active_day ?? null;
|
||
return {
|
||
body: {
|
||
latest_day: latest,
|
||
earliest_day: earliest,
|
||
latest_rollup_day: row?.latest_rollup_day ?? null,
|
||
latest_active_day: row?.latest_active_day ?? null,
|
||
/** Below this day the activation funnel is blind — raw events are purged. */
|
||
earliest_raw_day: row?.earliest_raw_day ?? null,
|
||
latest_raw_day: row?.latest_raw_day ?? null,
|
||
max_range_days: MAX_RANGE_DAYS,
|
||
retention_days: RETENTION_DAYS,
|
||
generated_at: new Date().toISOString(),
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/summary — the big numbers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* One D1 batch, one round trip. Distinct-machine numbers come from
|
||
* `machine_days` rather than by summing `daily_machines`: a machine active on
|
||
* five days is one user, and summing the daily counts would call it five.
|
||
*/
|
||
async function summary(env: Env, range: Range): Promise<ApiResult> {
|
||
const { from, to } = range;
|
||
const batch = await env.DB.batch([
|
||
env.DB.prepare(
|
||
`SELECT count(DISTINCT machine_id) AS n FROM machine_days
|
||
WHERE day BETWEEN ? AND ? AND prod = 1`,
|
||
).bind(from, to),
|
||
env.DB.prepare(
|
||
`SELECT count(DISTINCT machine_id) AS n FROM machine_days WHERE day BETWEEN ? AND ?`,
|
||
).bind(from, to),
|
||
env.DB.prepare(
|
||
`SELECT count(*) AS n FROM machine_first_seen WHERE first_day BETWEEN ? AND ?`,
|
||
).bind(from, to),
|
||
env.DB.prepare(
|
||
`SELECT event, sum(count) AS events, sum(machines) AS machines
|
||
FROM daily_event_counts WHERE day BETWEEN ? AND ? GROUP BY event`,
|
||
).bind(from, to),
|
||
]);
|
||
|
||
const byEvent = new Map<string, number>();
|
||
for (const row of rowsOf<{ event: string; events: number }>(batch[3])) {
|
||
byEvent.set(row.event, row.events ?? 0);
|
||
}
|
||
const eventCount = (name: string): number => byEvent.get(name) ?? 0;
|
||
|
||
return {
|
||
body: {
|
||
range,
|
||
production_users: firstOf<{ n: number }>(batch[0])?.n ?? 0,
|
||
active_machines: firstOf<{ n: number }>(batch[1])?.n ?? 0,
|
||
new_machines: firstOf<{ n: number }>(batch[2])?.n ?? 0,
|
||
installs: eventCount('install'),
|
||
uninstalls: eventCount('uninstall'),
|
||
index_runs: eventCount('index'),
|
||
tool_calls: eventCount('usage_rollup'),
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/timeseries — the line charts
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface DayValueRow {
|
||
day: string;
|
||
a: number | null;
|
||
b: number | null;
|
||
}
|
||
|
||
interface SeriesSpec {
|
||
title: string;
|
||
/** Series labels, in the order their data lands in `datasets`. */
|
||
labels: [string] | [string, string];
|
||
sql: string;
|
||
binds: (range: Range) => (string | number)[];
|
||
}
|
||
|
||
/**
|
||
* Every metric here reads a rollup table, so a line stays correct for days whose
|
||
* raw events are long gone. Each query returns (day, a[, b]) and is densified
|
||
* against the full day list, because a day with no rows means zero, not a gap.
|
||
*/
|
||
const SERIES: Record<string, SeriesSpec> = {
|
||
installs_uninstalls: {
|
||
title: 'Installs and uninstalls',
|
||
labels: ['Installs', 'Uninstalls'],
|
||
sql: `SELECT day,
|
||
sum(CASE WHEN event = 'install' THEN count ELSE 0 END) AS a,
|
||
sum(CASE WHEN event = 'uninstall' THEN count ELSE 0 END) AS b
|
||
FROM daily_event_counts
|
||
WHERE day BETWEEN ? AND ? AND event IN ('install', 'uninstall')
|
||
GROUP BY day`,
|
||
binds: (r) => [r.from, r.to],
|
||
},
|
||
new_installs: {
|
||
title: 'New installs',
|
||
labels: ['New machines'],
|
||
sql: `SELECT first_day AS day, count(*) AS a, NULL AS b
|
||
FROM machine_first_seen
|
||
WHERE first_day BETWEEN ? AND ?
|
||
GROUP BY first_day`,
|
||
binds: (r) => [r.from, r.to],
|
||
},
|
||
production_users: {
|
||
title: 'Daily production users',
|
||
labels: ['Production users'],
|
||
sql: `SELECT day, prod_machines AS a, NULL AS b
|
||
FROM daily_machines WHERE day BETWEEN ? AND ?`,
|
||
binds: (r) => [r.from, r.to],
|
||
},
|
||
indexing_activity: {
|
||
title: 'Daily indexing activity',
|
||
labels: ['Indexing runs', 'Machines indexing'],
|
||
sql: `SELECT day, count AS a, machines AS b
|
||
FROM daily_event_counts
|
||
WHERE day BETWEEN ? AND ? AND event = 'index'`,
|
||
binds: (r) => [r.from, r.to],
|
||
},
|
||
tool_calls: {
|
||
title: 'Daily tool and command calls',
|
||
labels: ['Calls', 'Machines'],
|
||
sql: `SELECT day, count AS a, machines AS b
|
||
FROM daily_event_counts
|
||
WHERE day BETWEEN ? AND ? AND event = 'usage_rollup'`,
|
||
binds: (r) => [r.from, r.to],
|
||
},
|
||
};
|
||
|
||
async function timeseries(env: Env, url: URL, range: Range): Promise<ApiResult> {
|
||
const metric = url.searchParams.get('metric') ?? 'installs_uninstalls';
|
||
|
||
// The one metric whose series are data-driven rather than fixed: one line per
|
||
// duration bucket, in bucket order (an ordered scale, so the order is meaning).
|
||
if (metric === 'duration_buckets') return durationBucketSeries(env, range);
|
||
|
||
const spec = SERIES[metric];
|
||
if (!spec) {
|
||
return fail(`unknown metric — one of: ${[...Object.keys(SERIES), 'duration_buckets'].join(', ')}`);
|
||
}
|
||
|
||
const { results } = await env.DB.prepare(spec.sql)
|
||
.bind(...spec.binds(range))
|
||
.all<DayValueRow>();
|
||
|
||
const labels = dayList(range);
|
||
const a = new Map<string, number>();
|
||
const b = new Map<string, number>();
|
||
for (const row of results) {
|
||
a.set(row.day, row.a ?? 0);
|
||
b.set(row.day, row.b ?? 0);
|
||
}
|
||
|
||
const datasets = [{ label: spec.labels[0], data: densify(labels, a) }];
|
||
if (spec.labels.length === 2) datasets.push({ label: spec.labels[1], data: densify(labels, b) });
|
||
|
||
return {
|
||
body: {
|
||
range,
|
||
metric,
|
||
title: spec.title,
|
||
labels,
|
||
datasets,
|
||
rows: labels.map((day, i) => ({
|
||
day,
|
||
...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
|
||
})),
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
/** "Session run length over time": one series per duration bucket, bucket-ordered. */
|
||
async function durationBucketSeries(env: Env, range: Range): Promise<ApiResult> {
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT day, value, sum(count) AS n
|
||
FROM daily_dim_counts
|
||
WHERE dim = 'duration_bucket' AND event = 'index' AND day BETWEEN ? AND ?
|
||
GROUP BY day, value`,
|
||
)
|
||
.bind(range.from, range.to)
|
||
.all<{ day: string; value: string; n: number }>();
|
||
|
||
const labels = dayList(range);
|
||
const perBucket = new Map<string, Map<string, number>>();
|
||
for (const row of results) {
|
||
let series = perBucket.get(row.value);
|
||
if (!series) perBucket.set(row.value, (series = new Map()));
|
||
series.set(row.day, row.n ?? 0);
|
||
}
|
||
|
||
// Fixed buckets first and always present (a bucket with no runs is a real zero,
|
||
// and dropping it would silently renumber the ordinal colour ramp); anything
|
||
// unexpected from an older client is appended rather than hidden.
|
||
const extra = [...perBucket.keys()].filter((v) => !DURATION_BUCKETS.includes(v as never)).sort();
|
||
const order = [...DURATION_BUCKETS, ...extra];
|
||
|
||
const datasets = order.map((bucket) => ({
|
||
label: bucket,
|
||
data: densify(labels, perBucket.get(bucket) ?? new Map()),
|
||
}));
|
||
|
||
return {
|
||
body: {
|
||
range,
|
||
metric: 'duration_buckets',
|
||
title: 'Indexing run length over time',
|
||
labels,
|
||
datasets,
|
||
rows: labels.map((day, i) => ({
|
||
day,
|
||
...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
|
||
})),
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/breakdown — the bars and pies
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface BreakdownRow {
|
||
value: string;
|
||
count: number;
|
||
machines: number;
|
||
}
|
||
|
||
/**
|
||
* Sums one dimension over the range.
|
||
*
|
||
* On the `machines` metric: `daily_dim_counts.machines` is per day, so summing
|
||
* it over a range gives **machine-days**, not distinct machines — a machine
|
||
* seen on ten days counts ten times. A range-wide distinct count per dimension
|
||
* value is not recoverable from the rollups at all (it would need the raw
|
||
* events, which are purged), so rather than quietly presenting one as the
|
||
* other, the number is honestly named machine-days everywhere it appears, and
|
||
* the panels that use it are share-of-total panels where the distinction does
|
||
* not move the shape.
|
||
*
|
||
* The inner `max(machines)` is the other half of that honesty: the same machine
|
||
* emits install *and* index *and* usage_rollup on one day, each carrying `os`,
|
||
* so summing `machines` across event types would triple-count it. Taking the
|
||
* largest single-event count for the day is the closest lower bound the rollups
|
||
* can give. When a dim belongs to exactly one event (or `?event=` pins it) the
|
||
* `max` is over a single row and the question does not arise.
|
||
*/
|
||
async function breakdown(env: Env, url: URL, range: Range): Promise<ApiResult> {
|
||
const dim = url.searchParams.get('dim') ?? '';
|
||
const spec = DIMS[dim];
|
||
if (!spec) return fail(`unknown dim — one of: ${Object.keys(DIMS).join(', ')}`);
|
||
|
||
const requestedMetric = url.searchParams.get('metric');
|
||
if (requestedMetric !== null && requestedMetric !== 'count' && requestedMetric !== 'machines') {
|
||
return fail('metric must be count or machines');
|
||
}
|
||
const metric = requestedMetric ?? spec.metric;
|
||
|
||
const rawLimit = url.searchParams.get('limit');
|
||
const limit = rawLimit === null ? DEFAULT_BREAKDOWN_LIMIT : Number(rawLimit);
|
||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BREAKDOWN_LIMIT) {
|
||
return fail(`limit must be an integer between 1 and ${MAX_BREAKDOWN_LIMIT}`);
|
||
}
|
||
|
||
const event = url.searchParams.get('event') ?? spec.event ?? null;
|
||
if (event !== null && !/^[a-z_]{1,32}$/.test(event)) return fail('event must be a bare event name');
|
||
|
||
const binds: (string | number)[] = [dim, range.from, range.to];
|
||
if (event !== null) binds.push(event);
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT value, sum(day_count) AS count, sum(day_machines) AS machines
|
||
FROM (SELECT day, value, sum(count) AS day_count, max(machines) AS day_machines
|
||
FROM daily_dim_counts
|
||
WHERE dim = ? AND day BETWEEN ? AND ?${event !== null ? ' AND event = ?' : ''}
|
||
GROUP BY day, value)
|
||
GROUP BY value`,
|
||
)
|
||
.bind(...binds)
|
||
.all<BreakdownRow>();
|
||
|
||
const rows = results.map((r) => ({
|
||
value: r.value,
|
||
count: r.count ?? 0,
|
||
machines: r.machines ?? 0,
|
||
}));
|
||
|
||
const pick = (r: BreakdownRow): number => (metric === 'machines' ? r.machines : r.count);
|
||
|
||
let ordered: BreakdownRow[];
|
||
let truncated = false;
|
||
if (spec.order === 'bucket' && spec.buckets) {
|
||
// An ordered scale: the buckets keep their own order and all of them show,
|
||
// including empty ones, so the ordinal colour ramp always means the same thing.
|
||
const found = new Map(rows.map((r) => [r.value, r]));
|
||
const extra = rows.filter((r) => !spec.buckets?.includes(r.value)).sort((x, y) => pick(y) - pick(x));
|
||
ordered = [
|
||
...spec.buckets.map((b) => found.get(b) ?? { value: b, count: 0, machines: 0 }),
|
||
...extra,
|
||
];
|
||
} else {
|
||
const sorted = [...rows].sort(
|
||
spec.order === 'version_desc'
|
||
? (x, y) => compareVersionsDesc(x.value, y.value)
|
||
: (x, y) => pick(y) - pick(x) || x.value.localeCompare(y.value),
|
||
);
|
||
if (sorted.length > limit) {
|
||
// Fold rather than truncate: a chopped bar chart quietly changes what the
|
||
// total means, and "Other" keeps the panel's total honest.
|
||
const head = sorted.slice(0, limit);
|
||
const tail = sorted.slice(limit);
|
||
ordered = [
|
||
...head,
|
||
{
|
||
value: 'Other',
|
||
count: tail.reduce((n, r) => n + r.count, 0),
|
||
machines: tail.reduce((n, r) => n + r.machines, 0),
|
||
},
|
||
];
|
||
truncated = true;
|
||
} else {
|
||
ordered = sorted;
|
||
}
|
||
}
|
||
|
||
const data = ordered.map(pick);
|
||
return {
|
||
body: {
|
||
range,
|
||
dim,
|
||
event,
|
||
metric,
|
||
title: spec.label,
|
||
labels: ordered.map((r) => r.value),
|
||
datasets: [{ label: metric === 'machines' ? 'Machine-days' : 'Events', data }],
|
||
rows: ordered,
|
||
total: data.reduce((n, v) => n + v, 0),
|
||
/** True when the tail was folded into an "Other" bar. */
|
||
truncated,
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/activation — install → first index
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface ActivationRow {
|
||
day: string;
|
||
installs: number;
|
||
activated: number;
|
||
}
|
||
|
||
/**
|
||
* Of the machines whose FIRST day falls in the range, how many ran an index
|
||
* within `window` days of it.
|
||
*
|
||
* The cohort key is `machine_first_seen`, not install events: a machine that
|
||
* reinstalls does not re-enter the funnel, which is what makes this a
|
||
* conversion rate rather than an install-event ratio.
|
||
*
|
||
* The LEFT JOIN rides events_machine_day (machine_id, day) and `count(DISTINCT)`
|
||
* absorbs the fan-out from a machine that indexed many times. This is the one
|
||
* endpoint that reads raw `events`, so it is bounded by the retention window —
|
||
* `raw_events_from` tells the caller where the data actually starts, and the UI
|
||
* says so rather than drawing a cliff and calling it a drop in conversion.
|
||
*/
|
||
async function activation(env: Env, url: URL, range: Range): Promise<ApiResult> {
|
||
const rawWindow = url.searchParams.get('window');
|
||
const window = rawWindow === null ? DEFAULT_ACTIVATION_WINDOW : Number(rawWindow);
|
||
if (!Number.isInteger(window) || window < 1 || window > MAX_ACTIVATION_WINDOW) {
|
||
return fail(`window must be an integer between 1 and ${MAX_ACTIVATION_WINDOW}`);
|
||
}
|
||
|
||
const batch = await env.DB.batch([
|
||
env.DB.prepare(
|
||
`SELECT f.first_day AS day,
|
||
count(DISTINCT f.machine_id) AS installs,
|
||
count(DISTINCT CASE WHEN e.machine_id IS NOT NULL THEN f.machine_id END) AS activated
|
||
FROM machine_first_seen f
|
||
LEFT JOIN events e
|
||
ON e.machine_id = f.machine_id
|
||
AND e.event = 'index'
|
||
AND e.day >= f.first_day
|
||
AND e.day <= date(f.first_day, ?)
|
||
WHERE f.first_day BETWEEN ? AND ?
|
||
GROUP BY f.first_day`,
|
||
// A bound modifier string, built from an integer this function validated —
|
||
// date() takes the modifier as data, so nothing is concatenated into SQL.
|
||
).bind(`+${window} days`, range.from, range.to),
|
||
env.DB.prepare(`SELECT min(day) AS raw_from, max(day) AS raw_to FROM events`),
|
||
]);
|
||
|
||
const rows = rowsOf<ActivationRow>(batch[0]);
|
||
const byDay = new Map(rows.map((r) => [r.day, r]));
|
||
const labels = dayList(range);
|
||
|
||
const installs = rows.reduce((n, r) => n + (r.installs ?? 0), 0);
|
||
const activated = rows.reduce((n, r) => n + (r.activated ?? 0), 0);
|
||
|
||
// Cohorts younger than the window have not finished converting yet, so their
|
||
// rate is a floor, not a result. Marked rather than dropped: hiding the last
|
||
// week of a conversion chart is its own kind of lie.
|
||
const boundsRow = firstOf<{ raw_from: string | null; raw_to: string | null }>(batch[1]);
|
||
const latestRaw = boundsRow?.raw_to ?? utcDay(Date.now());
|
||
const incompleteFrom = addDays(latestRaw, -(window - 1));
|
||
|
||
const detail = labels.map((day) => {
|
||
const row = byDay.get(day);
|
||
const dayInstalls = row?.installs ?? 0;
|
||
const dayActivated = row?.activated ?? 0;
|
||
return {
|
||
day,
|
||
installs: dayInstalls,
|
||
activated: dayActivated,
|
||
rate: dayInstalls > 0 ? dayActivated / dayInstalls : null,
|
||
complete: day < incompleteFrom,
|
||
};
|
||
});
|
||
|
||
return {
|
||
body: {
|
||
range,
|
||
window_days: window,
|
||
installs,
|
||
activated,
|
||
dropped: installs - activated,
|
||
rate: installs > 0 ? activated / installs : null,
|
||
/** Cohorts from this day on have not had the full window to convert. */
|
||
incomplete_from: incompleteFrom,
|
||
/** Raw events start here; a range reaching further back under-counts. */
|
||
raw_events_from: boundsRow?.raw_from ?? null,
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: 'Activation rate',
|
||
data: detail.map((d) => (d.rate === null ? null : Math.round(d.rate * 1000) / 10)),
|
||
},
|
||
],
|
||
rows: detail,
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/retention — day 0–14 cohort curve
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* For machines first seen in the range, the share still active k days later.
|
||
*
|
||
* Read entirely off `machine_days` + `machine_first_seen`, neither of which the
|
||
* retention purge touches, so this answers for any range in history.
|
||
*
|
||
* The denominator is per-k, not the whole cohort: a machine first seen
|
||
* yesterday cannot have a day-7 data point, and dividing by it anyway would
|
||
* bend every recent cohort's curve toward zero. So day k is measured only over
|
||
* the machines that have actually had k days to come back — `eligible[k]`. The
|
||
* numerator needs no matching filter, since a machine with fewer than k days
|
||
* elapsed contributes zero to day k by construction.
|
||
*/
|
||
async function retention(env: Env, range: Range): Promise<ApiResult> {
|
||
const batch = await env.DB.batch([
|
||
env.DB.prepare(
|
||
`SELECT CAST(julianday(d.day) - julianday(f.first_day) AS INTEGER) AS k,
|
||
count(DISTINCT d.machine_id) AS machines
|
||
FROM machine_first_seen f
|
||
JOIN machine_days d ON d.machine_id = f.machine_id
|
||
WHERE f.first_day BETWEEN ? AND ?
|
||
AND d.day >= f.first_day
|
||
AND d.day <= date(f.first_day, ?)
|
||
GROUP BY k`,
|
||
).bind(range.from, range.to, `+${RETENTION_DAYS} days`),
|
||
env.DB.prepare(
|
||
`SELECT first_day AS day, count(*) AS machines
|
||
FROM machine_first_seen WHERE first_day BETWEEN ? AND ? GROUP BY first_day`,
|
||
).bind(range.from, range.to),
|
||
env.DB.prepare(`SELECT max(day) AS day FROM machine_days`),
|
||
]);
|
||
|
||
const retained = new Map(
|
||
rowsOf<{ k: number; machines: number }>(batch[0]).map((r) => [r.k, r.machines ?? 0]),
|
||
);
|
||
const cohortDays = rowsOf<{ day: string; machines: number }>(batch[1]);
|
||
const cohortSize = cohortDays.reduce((n, r) => n + (r.machines ?? 0), 0);
|
||
const latestDay = firstOf<{ day: string | null }>(batch[2])?.day ?? utcDay(Date.now());
|
||
|
||
const rows = [];
|
||
for (let k = 0; k <= RETENTION_DAYS; k++) {
|
||
// Machines whose first day is early enough that day k has already happened.
|
||
const cutoff = addDays(latestDay, -k);
|
||
const eligible = cohortDays.reduce((n, r) => (r.day <= cutoff ? n + (r.machines ?? 0) : n), 0);
|
||
const back = retained.get(k) ?? 0;
|
||
rows.push({
|
||
day: k,
|
||
eligible,
|
||
retained: back,
|
||
rate: eligible > 0 ? back / eligible : null,
|
||
});
|
||
}
|
||
|
||
return {
|
||
body: {
|
||
range,
|
||
cohort: cohortSize,
|
||
window_days: RETENTION_DAYS,
|
||
labels: rows.map((r) => `Day ${r.day}`),
|
||
datasets: [
|
||
{
|
||
label: 'Retained',
|
||
data: rows.map((r) => (r.rate === null ? null : Math.round(r.rate * 1000) / 10)),
|
||
},
|
||
],
|
||
rows,
|
||
},
|
||
cacheControl: CACHE_CONTROL,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// /api/health — liveness, and the only endpoint that is not range-scoped
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function health(env: Env): Promise<ApiResult> {
|
||
try {
|
||
const batch = await env.DB.batch<{ day: string | null }>([
|
||
env.DB.prepare('SELECT max(day) AS day FROM events'),
|
||
env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
|
||
]);
|
||
return {
|
||
body: {
|
||
ok: true,
|
||
database: {
|
||
latest_event_day: batch[0]?.results[0]?.day ?? null,
|
||
latest_rollup_day: batch[1]?.results[0]?.day ?? null,
|
||
},
|
||
},
|
||
};
|
||
} catch (err) {
|
||
console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
|
||
return { body: { ok: false, error: 'database unavailable' }, status: 503 };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Router
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Called only for an authenticated GET — src/index.ts owns the session gate and
|
||
* turns what comes back into a Response.
|
||
*/
|
||
export async function handleApi(env: Env, url: URL): Promise<ApiResult> {
|
||
if (url.pathname === '/api/session') return { body: { authenticated: true } };
|
||
if (url.pathname === '/api/health') return health(env);
|
||
if (url.pathname === '/api/meta') return meta(env);
|
||
|
||
const ranged = new Set(['/api/summary', '/api/timeseries', '/api/breakdown', '/api/activation', '/api/retention']);
|
||
if (!ranged.has(url.pathname)) return fail('not found', 404);
|
||
|
||
const range = parseRange(url);
|
||
if (isApiResult(range)) return range;
|
||
|
||
try {
|
||
switch (url.pathname) {
|
||
case '/api/summary':
|
||
return await summary(env, range);
|
||
case '/api/timeseries':
|
||
return await timeseries(env, url, range);
|
||
case '/api/breakdown':
|
||
return await breakdown(env, url, range);
|
||
case '/api/activation':
|
||
return await activation(env, url, range);
|
||
case '/api/retention':
|
||
return await retention(env, range);
|
||
default:
|
||
return fail('not found', 404);
|
||
}
|
||
} catch (err) {
|
||
// The query failed, not the caller. Log the cause, tell the page something
|
||
// it can put in the panel, and let the other panels carry on.
|
||
console.error(JSON.stringify({ msg: 'api query failed', path: url.pathname, err: String(err) }));
|
||
return { body: { error: 'query failed' }, status: 503 };
|
||
}
|
||
}
|