Files
Colby Mchenry 217190680d fix(telemetry): recover from the D1 10 GB outage; dashboard starts on today (#2317)
The telemetry database hit D1's 10 GB cap on 2026-08-11 and refused nearly
every write for seven weeks: the dashboard kept showing Aug 9 as the latest
day, and its activation query (a cohort join over raw events, ~55 s per
week) stalled D1's single query lane until every panel failed.

Root cause was the client: usage counters were aggregated per process and
appended on exit, so every `serve` launch, CLI command and prompt hook
uploaded its own `count: 1` line - 30.3M of the 30.8M event rows.

- Client: merge count lines per (day, kind, name, client) on append,
  stale-claim recovery (which also bypassed the size cap) and send.
- Ingest: usage counters ADD into usage_daily, one row per machine x day x
  tool (migration 0003), so storage no longer depends on upload frequency.
- Rollup: reads usage from usage_daily, folds legacy usage rows out of
  events in 50k-row transactions, maintains machine_first_seen.first_index_day
  (0002), catches up on days a failed run missed, purges usage_daily with
  retention. 0004 drops the now-unused events_machine_day index.
- Dashboard: ranges end today; rolled-up series return null past the
  rollup's last day instead of a phantom zero; no panel reads raw events
  (activation reads first_index_day); min/max lookups stay index-only;
  one retry on transient 5xx; banners when ingest stalls or the rollup
  falls behind.
- scripts/backfill-rollup.sh re-runs the rollup over a day range.

Already applied in production: DB 10 GB -> 467 MB, all usage counts kept,
rollups current through 2026-10-02.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:58:44 +00:00

514 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Renders the dashboard in a real browser against the fixture and checks that
* every panel drew, and drew the numbers the API returned.
*
* smoke-api.sh proves the SQL; this proves the other half — that each panel is
* wired to the right endpoint and plots it without mangling it. It reads the
* Chart.js instance off each canvas and compares its dataset arrays against the
* same endpoint fetched straight from Node, so a panel pointed at the wrong dim
* fails here even though both halves are individually fine.
*
* node scripts/render-check.mjs (or: npm run smoke:render)
*
* Zero new dependencies: it drives whatever Chromium is already on the machine
* over the DevTools protocol (Node 22 has WebSocket built in). With no browser
* installed it SKIPS rather than fails — the shell smoke suites stay the
* portable floor, and this is the deeper check where a browser exists.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const PORT = Number(process.env.DASH_PORT ?? 8790);
const BASE = `http://127.0.0.1:${PORT}`;
/** The fixture's own window — see scripts/fixture.sql. */
const FROM = '2026-07-01';
const TO = '2026-07-10';
let pass = 0;
let fail = 0;
const ok = (what) => {
console.log(` ok ${what}`);
pass++;
};
const bad = (what, detail) => {
console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`);
fail++;
};
const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail));
const same = (what, expected, actual) =>
check(
what,
JSON.stringify(expected) === JSON.stringify(actual),
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Finding a browser
// ---------------------------------------------------------------------------
/** Expands one `*` in a path segment, newest match first. */
function glob(pattern) {
const [head, ...rest] = pattern.split('*');
const base = dirname(head);
const prefix = head.slice(base.length + 1);
if (!existsSync(base)) return [];
return readdirSync(base)
.filter((name) => name.startsWith(prefix))
.sort()
.reverse()
.map((name) => join(base, name) + rest.join('*'));
}
function findBrowser() {
const home = process.env.HOME ?? '';
const candidates = [
process.env.CHROME_BIN,
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`),
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`),
...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`),
...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
];
return candidates.find((path) => path && existsSync(path)) ?? null;
}
// ---------------------------------------------------------------------------
// A minimal DevTools-protocol client
// ---------------------------------------------------------------------------
class CDP {
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.events = [];
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id !== undefined) {
const waiter = this.pending.get(message.id);
if (!waiter) return;
this.pending.delete(message.id);
if (message.error) waiter.reject(new Error(message.error.message));
else waiter.resolve(message.result);
} else {
this.events.push(message);
}
});
}
static async connect(url) {
const socket = new WebSocket(url);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true });
});
return new CDP(socket);
}
send(method, params = {}, sessionId) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
});
}
/** Runs an expression in the page and returns its value, awaiting promises. */
async evaluate(sessionId, expression) {
const result = await this.send(
'Runtime.evaluate',
{ expression, returnByValue: true, awaitPromise: true },
sessionId,
);
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description ?? 'page threw');
}
return result.result.value;
}
}
// ---------------------------------------------------------------------------
// The page probe
// ---------------------------------------------------------------------------
/**
* Runs inside the page. Reads what each panel actually rendered — including the
* live Chart.js instance behind each canvas — rather than trusting that a
* fetch resolved.
*/
const PROBE = `(() => {
const panels = [...document.querySelectorAll('[data-panel]')].map((section) => {
const canvas = section.querySelector('canvas');
const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null;
return {
id: section.dataset.panel,
state: section.dataset.state,
stale: section.dataset.stale,
title: section.querySelector('h2').textContent,
figure: section.querySelector('[data-role="figure"]').textContent,
note: section.querySelector('.panel-note')?.textContent ?? '',
message: section.querySelector('[data-role="state"]').textContent,
stat: section.querySelector('.stat-value')?.textContent ?? null,
funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent),
funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width),
chart: chart && {
type: chart.config.type,
labels: chart.data.labels,
datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })),
legend: chart.options.plugins?.legend?.display !== false,
},
tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length,
tableCols: section.querySelectorAll('[data-role="table"] thead th').length,
tableHidden: section.querySelector('[data-role="table"]').hidden,
};
});
const warning = document.getElementById('data-warning');
return {
ready: document.body.dataset.ready === 'true',
warning: warning.hidden ? null : warning.textContent,
customFrom: document.querySelector('[data-role="custom-from"]').value,
customTo: document.querySelector('[data-role="custom-to"]').value,
range: document.getElementById('range-summary').textContent,
dataThrough: document.getElementById('data-through').textContent,
refreshed: document.getElementById('refreshed-at').textContent,
selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null,
panels,
};
})()`;
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
const children = [];
let profileDir = null;
function cleanup() {
for (const child of children) {
try {
child.kill('SIGTERM');
} catch {
/* already gone */
}
}
if (profileDir) rmSync(profileDir, { recursive: true, force: true });
}
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));
function run(command, args, options = {}) {
const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options });
children.push(child);
return child;
}
async function waitFor(what, probe, attempts = 90) {
for (let i = 0; i < attempts; i++) {
try {
if (await probe()) return true;
} catch {
/* not up yet */
}
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
async function main() {
const browserPath = findBrowser();
if (!browserPath) {
console.log('render-check: no Chromium found — skipping.');
console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.');
return 0;
}
console.log(`Browser: ${browserPath}`);
console.log('Seeding the local D1 fixture…');
const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' });
const seeded = await new Promise((resolve) => seed.on('exit', resolve));
if (seeded !== 0) throw new Error('seeding failed');
console.log(`Starting wrangler dev on :${PORT}…`);
run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']);
await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok);
const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1];
if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars');
const login = await fetch(`${BASE}/login`, {
method: 'POST',
body: new URLSearchParams({ password }),
redirect: 'manual',
});
const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session='));
if (!cookie) throw new Error('login did not set a session cookie');
const [name, value] = cookie.split(';')[0].split('=');
profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-'));
// chrome-headless-shell is headless by construction and rejects the flag;
// a full Chrome needs it.
const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new'];
run(browserPath, [
...headlessFlag,
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=0',
`--user-data-dir=${profileDir}`,
'about:blank',
]);
let devtoolsPort = null;
await waitFor('the browser', () => {
const portFile = join(profileDir, 'DevToolsActivePort');
if (!existsSync(portFile)) return false;
devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
return Number.isFinite(devtoolsPort) && devtoolsPort > 0;
}, 30);
const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json();
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
await cdp.send('Page.enable', {}, sessionId);
await cdp.send('Runtime.enable', {}, sessionId);
await cdp.send('Log.enable', {}, sessionId);
await cdp.send('Network.enable', {}, sessionId);
await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId);
await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId);
await waitFor('the dashboard to finish rendering', async () => {
const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"');
return view === true;
}, 60);
let view = await cdp.evaluate(sessionId, PROBE);
// -- what loaded ---------------------------------------------------------
console.log('\nThe page renders');
// The very same registry the page just rendered from, imported here so the
// expectations cannot drift from the panels under test.
const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href);
const { shortDay } = await import(pathToFileURL(join(root, 'public', 'theme.js')).href);
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
const today = utcDay(Date.now());
check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`);
// The fixture lives in July, so a range ending today has nothing in it: every
// panel should say so plainly, and none should error.
const broken = view.panels.filter((p) => p.state !== 'ready' && p.state !== 'empty');
check(
'every panel settled without an error',
broken.length === 0,
broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
);
check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset);
same('…ending today, not on the last rolled-up day', today, view.customTo);
same('…and starting 29 days before', utcDay(Date.now() - 29 * 864e5), view.customFrom);
check('the range is stated in the filter row', view.range.includes(shortDay(today)), view.range);
check('the rollup horizon is stated', view.dataThrough.includes('Event counts through Jul 10'), view.dataThrough);
check('…with yesterday\'s activity beside it', view.dataThrough.includes('0 machines active yesterday'), view.dataThrough);
check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed);
// Nothing has arrived since the fixture's last day: the page must say so rather
// than present July as current.
check(
'a stalled ingest is called out above the panels',
view.warning?.includes('No new events since Jul 10.') === true,
String(view.warning),
);
// A CSP violation surfaces here as a `security` log entry, which is the point
// of the check: the page must work under `script-src 'self'` with no inline
// styles at all. The favicon 404 is expected — there isn't one — and is the
// only network noise allowed through.
const errors = cdp.events.filter(
(e) =>
(e.method === 'Log.entryAdded' &&
e.params.entry.level === 'error' &&
!/favicon/.test(e.params.entry.url ?? '')) ||
e.method === 'Runtime.exceptionThrown',
);
check(
'no console errors — the strict CSP allows everything the page needs',
errors.length === 0,
errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '),
);
// -- the range picker really re-queries ----------------------------------
console.log('\nChanging the range re-queries every panel');
await cdp.evaluate(
sessionId,
`document.body.dataset.ready = "";
[...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`,
);
await waitFor('the 7-day render', async () =>
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
);
view = await cdp.evaluate(sessionId, PROBE);
same('the range now starts 6 days back', utcDay(Date.now() - 6 * 864e5), view.customFrom);
check('…and still ends today', view.customTo === today, view.customTo);
check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset);
check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready' || p.state === 'empty'));
console.log('\nA custom range works the same way');
await cdp.evaluate(
sessionId,
`document.body.dataset.ready = "";
document.querySelector('[data-role="custom-from"]').value = "${FROM}";
document.querySelector('[data-role="custom-to"]').value = "${TO}";
document.querySelector('[data-role="custom-apply"]').click();`,
);
await waitFor('the custom-range render', async () =>
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
);
view = await cdp.evaluate(sessionId, PROBE);
check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10);
check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset);
const notReady = view.panels.filter((p) => p.state !== 'ready');
check(
'every panel reached its ready state',
notReady.length === 0,
notReady.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
);
// -- every panel plots what the API returned ------------------------------
console.log('\nEvery panel plots the API’s own numbers');
const query = `from=${FROM}&to=${TO}`;
const fetched = new Map();
const apiGet = async (path) => {
if (!fetched.has(path)) {
fetched.set(
path,
fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()),
);
}
return fetched.get(path);
};
for (const panel of PANELS) {
const rendered = view.panels.find((p) => p.id === panel.id);
const data = await apiGet(panel.source(query));
if (panel.kind === 'chart') {
const plotted = rendered.chart?.datasets.map((d) => d.data);
same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted);
// A legend is owed wherever colour carries identity: any multi-series
// chart, and every pie (whose slices are identities inside one dataset).
// A single line needs none — the panel title already names it.
const owed = rendered.chart.type === 'pie' || data.datasets.length > 1;
check(
`${panel.id}: a legend exactly where colour carries identity`,
rendered.chart.legend === owed,
`legend ${rendered.chart.legend}, expected ${owed}`,
);
} else if (panel.kind === 'stat') {
same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat);
} else if (panel.kind === 'funnel') {
same(
`${panel.id}: shows both funnel stages`,
panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')),
rendered.funnelValues,
);
}
const table = panel.table(data);
check(
`${panel.id}: the table twin carries every row`,
rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length,
`${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`,
);
}
// -- a few numbers checked against the fixture by hand --------------------
console.log('\nSpot checks against the fixture, worked out by hand');
const byId = Object.fromEntries(view.panels.map((p) => [p.id, p]));
same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat);
same('installs is 12', '12', byId['installs'].stat);
same('uninstalls is 2', '2', byId['uninstalls'].stat);
same('indexing runs is 13', '13', byId['indexing-runs'].stat);
same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues);
const widths = byId['activation-funnel'].funnelWidths;
check(
'…and draws the drop as a shorter bar',
widths[0] === '100%' && widths[1].startsWith('83.3'),
widths.join(' / '),
);
same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels);
same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data));
check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure);
same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels);
same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]);
check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100);
console.log('\nPast the rollup\'s last day, lines stop instead of dropping to zero');
await cdp.evaluate(
sessionId,
`document.body.dataset.ready = "";
document.querySelector('[data-role="custom-from"]').value = "2026-07-05";
document.querySelector('[data-role="custom-to"]').value = "2026-07-14";
document.querySelector('[data-role="custom-apply"]').click();`,
);
await waitFor('the over-the-edge render', async () =>
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
);
const edge = Object.fromEntries((await cdp.evaluate(sessionId, PROBE)).panels.map((p) => [p.id, p]));
same(
'daily production users ends in gaps after Jul 10',
[[2, 3, 2, 1, 1, 1, null, null, null, null]],
edge['daily-production-users'].chart?.datasets.map((d) => d.data),
);
same(
'new installs are live, so they stay zero rather than gap',
[[2, 0, 0, 1, 2, 0, 0, 0, 0, 0]],
edge['new-installs'].chart?.datasets.map((d) => d.data),
);
// Colour, spacing and label collisions are not things an assertion catches.
// RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it.
if (process.env.RENDER_SHOT) {
await cdp.send(
'Emulation.setDeviceMetricsOverride',
{ width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
sessionId,
);
await sleep(500);
const shot = await cdp.send(
'Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: true },
sessionId,
);
writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64'));
console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`);
}
console.log('\nPanel copy follows the house rules');
const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title));
check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', '));
check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20));
check('tables start closed', view.panels.every((p) => p.tableHidden));
return fail;
}
try {
const failures = await main();
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(failures === 0 ? 0 : 1);
} catch (err) {
console.error(`\nrender-check: ${err.message}`);
process.exit(1);
}