1// `forge metrics [n] [repo]`: the last n builds, and how each code version2// performs, from $FORGE_STATE/metrics.jsonl. With a lake (lib/lake.ts), its3// copy of every build line is read too, so the history goes back past what4// metrics.jsonl still has (backups leave that file out); lines it hasn't5// taken in yet come from the file.6import fs from 'node:fs';7import path from 'node:path';8import type { MetricsLine } from './metrics.ts';910const state = process.env.FORGE_STATE || path.resolve('demo/root/.forge');11const file = path.join(state, 'metrics.jsonl');12const n = Number(process.argv[2]) || 15;13const only = process.argv[3] || null;1415const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : [];16let older = 0;17if (fs.existsSync(path.join(state, 'lake', 'catalog.sqlite'))) {18 try {19 // Loaded only with a lake: DuckDB takes a moment to start.20 const { buildLines, lakeConfig } = await import('./lake.ts');21 const { lineHash } = await import('./lake-sources.ts');22 const have = new Set(lines.map(lineHash));23 const lake = (await buildLines(lakeConfig({ ...process.env, FORGE_ROOT: path.dirname(state) }))) ?? [];24 const extra = lake.filter((l) => !have.has(l.id)).map((l) => l.line);25 older = extra.length;26 lines.unshift(...extra);27 } catch (e) {28 console.error(`forge metrics: the lake can't be read, so this is metrics.jsonl only (${String((e as Error).message ?? e).split('\n')[0]})`);29 }30}31if (!lines.length) {32 console.log(`no builds recorded yet (${file})`);33 process.exit(0);34}35let rows = lines.map((l) => JSON.parse(l) as MetricsLine);36if (older) rows.sort((a, b) => a.at.localeCompare(b.at));37if (only) rows = rows.filter((r) => r.only === only || r.repos.some((x) => x.slug === only));38if (older) console.log(`${older} older ${older === 1 ? 'build' : 'builds'} from the lake, ${rows.length} in all\n`);3940const pad = (s: unknown, w: number): string => String(s).padEnd(w);41const num = (s: unknown, w: number): string => String(s).padStart(w);42const median = (xs: number[]): number => {43 const s = [...xs].sort((a, b) => a - b);44 return s.length ? s[Math.floor(s.length / 2)] : 0;45};46const slowest = (r: MetricsLine): string => {47 const stages: Record<string, number> = {};48 for (const x of r.repos) for (const [k, v] of Object.entries(x.ms)) if (k !== 'load') stages[k] = (stages[k] ?? 0) + v;49 return Object.entries(stages).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k} ${Math.round(v)}`).join(', ');50};51const pages = (r: MetricsLine): string => {52 const rendered = r.repos.reduce((s, x) => s + (x.counts.rendered ?? 0), 0);53 const skipped = r.repos.reduce((s, x) => s + (x.counts.skipped ?? 0), 0);54 return skipped ? `${rendered}/${rendered + skipped}` : String(r.written ?? rendered);55};5657console.log(`${pad('at', 17)} ${pad('scope', 22)} ${pad('reason', 7)} ${num('total', 7)} ${num('data', 7)} ${num('render', 7)} ${num('pages', 11)} ${num('rss', 5)} slowest stages (ms)`);58for (const r of rows.slice(-n)) {59 console.log(`${pad(r.at.slice(0, 16).replace('T', ' '), 17)} ${pad(r.only || 'all', 22)} ${pad(r.reason, 7)} ${num(r.ms.total, 7)} ${num(Math.round(r.ms.data), 7)} ${num(Math.round(r.ms.render), 7)} ${num(pages(r), 11)} ${num(r.rss_mb, 5)} ${slowest(r)}`);60}6162// Per code version: median total for single-repo builds, by repo, so a63// change to the forge shows up as a shift in the same repo's build time.64const byCode = new Map<string, number[]>();65for (const r of rows) {66 if (!r.only || r.only === '-') continue;67 const k = `${r.code} ${r.only}`;68 if (!byCode.has(k)) byCode.set(k, []);69 byCode.get(k)!.push(r.ms.total);70}71if (byCode.size) {72 console.log(`\n${pad('code', 11)} ${pad('repo', 22)} ${num('builds', 6)} ${num('median', 7)}`);73 for (const [k, v] of [...byCode].slice(-20)) {74 const [code, repo] = k.split(' ');75 console.log(`${pad(code, 11)} ${pad(repo, 22)} ${num(v.length, 6)} ${num(median(v), 7)}`);76 }77}