Public: anyone can read this; the crew can change it.

The forge keeps its own errors: the last hour's error lines are counted and the latest few are readable in health #359

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/344-the-forge-keeps-its-own-errors-the-last → main 1 commit fork

  • Merged. scout/344-the-forge-keeps-its-own-errors-the-last was merged into main as ffd6a50.

Review

Sign in to review this change.
10 files changed +117 −1
src/apps/scaffold.ts +4
@@ -1985,6 +1985,7 @@ export function serve(options: ServeOptions) {
19851985 // own error page, which carries source and paths, and never without
19861986 // the headers. The detail goes to the log, where it belongs.
19871987 error(error) {
1988 // Inside the generated app, which has no forge log module.
19881989 console.error(error);
19891990 return html("<h1>Something went wrong</h1>", 500);
19901991 },
@@ -3304,6 +3305,7 @@ export function lint(root: string): string[] {
33043305
33053306 if (import.meta.main) {
33063307 const problems = lint(join(import.meta.dir, ".."));
3308 // Generated app's lint CLI: stderr is its output.
33073309 for (const problem of problems) console.error(problem);
33083310 process.exit(problems.length === 0 ? 0 : 1);
33093311 }
@@ -3390,6 +3392,7 @@ export function record(db: Database, attempt: Attempt): void {
33903392 Number(lastInsertRowid) - KEEP,
33913393 );
33923394 } catch (error) {
3395 // Generated app: one JSON line to stderr, its own log.
33933396 console.error(
33943397 JSON.stringify({
33953398 audit: row,
@@ -4220,6 +4223,7 @@ test("contract/openapi.json is what the routes say now (else: bun run contract:e
42204223 const now = JSON.parse(emit());
42214224 const stale = !Bun.deepEquals(JSON.parse(committed), now);
42224225 if (stale)
4226 // Generated app's contract CLI: stderr is its output.
42234227 console.error(
42244228 "contract/openapi.json is stale: run \`bun run contract:emit\` and commit it",
42254229 );
src/git/hook-entry.ts +1
@@ -10,6 +10,7 @@ import { isHookName, runHookClient } from "./hooks.ts";
1010
1111 const name = process.argv[process.argv.length - 1];
1212 if (!isHookName(name)) {
13 // Git relays a hook's stderr to the pusher; it must be stderr.
1314 console.error(`forge: unknown hook ${name}`);
1415 process.exit(1);
1516 }
src/git/hooks.ts +3
@@ -84,6 +84,7 @@ export const HOOK_RELAY_TIMEOUT_MS = 120_000;
8484 export async function runHookClient(hook: HookName): Promise<never> {
8585 const socket = process.env.FORGE_SOCKET;
8686 if (!socket) {
87 // Hook stderr is shown to the pusher by git; it must be stderr.
8788 console.error("forge hook: FORGE_SOCKET unset");
8889 process.exit(1);
8990 }
@@ -115,10 +116,12 @@ export async function runHookClient(hook: HookName): Promise<never> {
115116 }),
116117 });
117118 const text = await response.text();
119 // The relayed refusal text, for the pusher to read.
118120 if (text !== "") console.error(text);
119121 process.exit(response.ok ? 0 : 1);
120122 } catch (error) {
121123 // pre-receive fails closed: an unreachable forge must not admit a push.
124 // Hook stderr is shown to the pusher by git.
122125 console.error(`forge: hook relay failed (${String(error)})`);
123126 process.exit(hook === "pre-receive" ? 1 : 0);
124127 }
src/kernel/log.ts +60
@@ -47,6 +47,62 @@ export function logLevel(): LogLevel {
4747 return threshold;
4848 }
4949
50 /** The most recent error lines kept in memory; older ones fall off. */
51 export const ERROR_RING = 100;
52 const HOUR_MS = 3_600_000;
53 const MINUTE_MS = 60_000;
54
55 export interface ErrorLine {
56 readonly at: string;
57 readonly scope: string;
58 readonly message: string;
59 }
60
61 const ring: ErrorLine[] = [];
62 /** scope → minute bucket → count: an hour of counts in at most 61 buckets per scope. */
63 const buckets = new Map<string, Map<number, number>>();
64
65 function prune(now: number): void {
66 const floor = Math.floor((now - HOUR_MS) / MINUTE_MS);
67 for (const [scope, minutes] of buckets) {
68 for (const minute of minutes.keys()) if (minute <= floor) minutes.delete(minute);
69 if (minutes.size === 0) buckets.delete(scope);
70 }
71 }
72
73 function record(scope: string, message: string, now: number): void {
74 ring.push({ at: new Date(now).toISOString(), scope, message });
75 if (ring.length > ERROR_RING) ring.splice(0, ring.length - ERROR_RING);
76 prune(now);
77 const minutes = buckets.get(scope) ?? new Map<number, number>();
78 const minute = Math.floor(now / MINUTE_MS);
79 minutes.set(minute, (minutes.get(minute) ?? 0) + 1);
80 buckets.set(scope, minutes);
81 }
82
83 /** Errors in the last hour, in total and per scope, and the newest lines first. */
84 export function recentErrors(
85 limit = 5,
86 now = Date.now(),
87 ): { lastHour: number; byScope: Record<string, number>; latest: ErrorLine[] } {
88 prune(now);
89 const byScope: Record<string, number> = {};
90 let lastHour = 0;
91 for (const [scope, minutes] of buckets) {
92 let n = 0;
93 for (const c of minutes.values()) n += c;
94 byScope[scope] = n;
95 lastHour += n;
96 }
97 return { lastHour, byScope, latest: ring.slice(-limit).reverse() };
98 }
99
100 /** Forget everything kept. For tests. */
101 export function clearErrors(): void {
102 ring.length = 0;
103 buckets.clear();
104 }
105
50106 export function createLog(scope: string): Log {
51107 const emit = (level: LogLevel, message: string, fields?: LogFields) => {
52108 if (order[level] < order[threshold]) return;
@@ -59,6 +115,10 @@ export function createLog(scope: string): Log {
59115 }
60116 }
61117 line = redactSecrets(line);
118 if (level === "error") {
119 const body = line.slice(line.indexOf(`[${scope}] `) + scope.length + 3);
120 record(scope, body, Date.now());
121 }
62122 console[level === "debug" ? "log" : level](line);
63123 };
64124 return {
src/kernel/vitals.ts +11
@@ -9,11 +9,14 @@
99 import type { Database } from "bun:sqlite";
1010 import { statfsSync } from "node:fs";
1111 import { freemem, totalmem } from "node:os";
12 import { recentErrors, type ErrorLine } from "./log.ts";
1213 import { leaseLapsed, listRunning, queueDepth } from "../core/runs.ts";
1314 import { STALE_AFTER_MS } from "../runs/lease.ts";
1415
1516 /** Free disk under this share of the volume is something to say. */
1617 export const DISK_FREE_MIN = 0.1;
18 /** This many error lines in an hour is a burst worth saying. */
19 export const ERROR_BURST = 20;
1720 /** A run still running this long after it started is stuck even if it breathes. */
1821 export const NO_PROGRESS_MS = 6 * 3_600_000;
1922 /** A run that has waited this long to start is a queue that is not moving. */
@@ -41,6 +44,7 @@ export interface Vitals {
4144 since: string;
4245 }[];
4346 readonly apps: { serving: number; asleep: number; previews: number };
47 readonly errors: { lastHour: number; latest: ErrorLine[] };
4448 readonly at: string;
4549 }
4650
@@ -99,6 +103,12 @@ export function vitals(input: VitalsInput): Vitals {
99103 else if (e.state === "running") apps.serving++;
100104 }
101105
106 const seen = recentErrors(5, now);
107 if (seen.lastHour >= ERROR_BURST)
108 needs.push(
109 `${seen.lastHour} errors in the last hour (${ERROR_BURST} or more is a burst).`,
110 );
111
102112 return {
103113 ok: needs.length === 0,
104114 needs,
@@ -111,6 +121,7 @@ export function vitals(input: VitalsInput): Vitals {
111121 queue,
112122 stuck,
113123 apps,
124 errors: { lastHour: seen.lastHour, latest: seen.latest },
114125 at: new Date(now).toISOString(),
115126 };
116127 }
src/main.ts +1
@@ -34,6 +34,7 @@ import { pruneTokens } from "./core/tokens.ts";
3434 if (process.argv[2] === "hook") {
3535 const name = process.argv[3];
3636 if (!isHookName(name)) {
37 // CLI message on stderr for the person at the terminal.
3738 console.error(`forge: unknown hook ${name}`);
3839 process.exit(1);
3940 }
src/snapshot/restore.ts +2
@@ -95,6 +95,7 @@ export async function restoreSnapshot(
9595 if (import.meta.main) {
9696 const [name, dataDir] = process.argv.slice(2);
9797 if (!name || !dataDir) {
98 // CLI usage on stderr for the person at the terminal, not a forge event.
9899 console.error(
99100 "usage: bun run src/snapshot/restore.ts <snapshot-name> <data-dir>",
100101 );
@@ -104,6 +105,7 @@ if (import.meta.main) {
104105 const s3 = s3FromEnv();
105106 const store = dir ? dirStore(dir) : s3 ? s3Store(s3) : undefined;
106107 if (store === undefined) {
108 // CLI message on stderr for the person at the terminal.
107109 console.error(
108110 "no store: set S3_* as the forge has them, or FORGE_SNAPSHOT_DIR",
109111 );
src/tui/main.ts +1
@@ -40,6 +40,7 @@ const username = flag("user");
4040 const user =
4141 username === undefined ? undefined : getUserByName(forge.db, username);
4242 if (username !== undefined && user === undefined) {
43 // CLI message on stderr for the person at the terminal.
4344 console.error(`forge: no such user: ${username}`);
4445 process.exit(1);
4546 }
src/tui/term/terminal.ts +1
@@ -267,6 +267,7 @@ export class TerminalSession {
267267 const fatal = (e: unknown): void => {
268268 this.leave();
269269 // The terminal is restored; now let the error be seen normally.
270 // Terminal is restored; the crash goes to the user's own stderr.
270271 console.error(e);
271272 process.exit(1);
272273 };
test/vitals.test.ts +33 −1
@@ -78,7 +78,7 @@ test("a site admin with a read-only token gets the shape; others get a 404", asy
7878 expect(res.status).toBe(200);
7979 const v = (await res.json()) as Record<string, any>;
8080 expect(Object.keys(v).sort()).toEqual(
81 ["apps", "at", "disk", "memory", "needs", "ok", "queue", "stuck"].sort(),
81 ["apps", "at", "disk", "errors", "memory", "needs", "ok", "queue", "stuck"].sort(),
8282 );
8383 expect(v.disk.totalBytes).toBeGreaterThan(0);
8484 expect(v.memory.rssBytes).toBeGreaterThan(0);
@@ -139,3 +139,35 @@ test("the number of queries does not grow with runs", () => {
139139 expect(count()).toBe(before);
140140 expect(before).toBe(2);
141141 });
142
143 test("errors are counted, bounded, redacted, and a burst is said", async () => {
144 const { createLog, clearErrors, recentErrors, ERROR_RING } = await import(
145 "../src/kernel/log.ts"
146 );
147 const { ERROR_BURST } = await import("../src/kernel/vitals.ts");
148 const orig = console.error;
149 console.error = () => {};
150 try {
151 clearErrors();
152 const log = createLog("probe");
153 log.error("upstream refused", { token: "ghp_abcdefghijklmnop1234567890" });
154 const v = (await (await health(admin)).json()) as ReturnType<typeof vitals>;
155 expect(v.errors.lastHour).toBe(1);
156 expect(v.errors.latest[0]!.scope).toBe("probe");
157 const text = JSON.stringify(v);
158 expect(text).toContain("[redacted]");
159 expect(text).not.toContain("ghp_abcdefghijklmnop1234567890");
160 expect(v.needs.some((n) => n.includes("errors in the last hour"))).toBe(false);
161
162 for (let i = 0; i < ERROR_RING + 50; i++) log.error(`boom ${i}`);
163 const seen = recentErrors(1000);
164 expect(seen.latest.length).toBe(ERROR_RING);
165 expect(seen.lastHour).toBe(ERROR_RING + 51);
166 expect(seen.lastHour).toBeGreaterThanOrEqual(ERROR_BURST);
167 expect(vitals({ db: app.db, dataDir }).needs.some((n) => n.includes("errors in the last hour"))).toBe(true);
168 expect(vitals({ db: app.db, dataDir, now: Date.now() + 2 * 3_600_000 }).errors.lastHour).toBe(0);
169 } finally {
170 console.error = orig;
171 clearErrors();
172 }
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs