Make run log capture an explicit class

This commit is contained in:
Matt Isenhower
2026-09-07 17:22:40 -07:00
parent 09caa34d7d
commit 706b7ee0e8
3 changed files with 56 additions and 44 deletions

View File

@@ -1,24 +1,59 @@
import { AsyncLocalStorage } from 'node:async_hooks';
// Capture application messages for one run without replacing the global console.
// Associate messages with their run across awaits, without replacing the global console.
const currentRun = new AsyncLocalStorage();
const MAX_LINES = 200;
const MAX_LINE_LENGTH = 500;
const MAX_BYTES = 32_000;
const encoder = new TextEncoder();
export function createRunLog(secrets = []) {
let snapshot = { lines: [], omitted: 0 };
let context = {
snapshot,
bytes: 0,
secrets: secrets.filter(value => typeof value === 'string' && value.length >= 6),
};
// The admin panel reads snapshot during the run and saves it with the final result.
export class RunLog {
constructor(secrets = []) {
this.snapshot = { lines: [], omitted: 0 };
this.bytes = 0;
this.secrets = secrets.filter(value => typeof value === 'string' && value.length >= 6);
}
return {
snapshot,
run: callback => currentRun.run(context, callback),
};
run(callback) {
return currentRun.run(this, callback);
}
append(level, message, fields) {
let line = { at: Date.now(), level, text: this.formatMessage(message, fields) };
this.snapshot.lines.push(line);
this.bytes += this.lineSize(line);
while (this.snapshot.lines.length > MAX_LINES || this.bytes > MAX_BYTES) {
let removed = this.snapshot.lines.shift();
this.bytes -= this.lineSize(removed);
this.snapshot.omitted++;
}
}
formatMessage(message, fields) {
let text = message instanceof Error ? message.message : String(message);
// Keep readable progress messages; the full structured result has its own JSON view.
if (fields?.updater)
text = `[${fields.updater}] ${text}`;
if (fields?.error)
text += `: ${fields.error}`;
if (fields?.attempt)
text += ` (retry ${fields.attempt})`;
for (let secret of this.secrets)
text = text.replaceAll(secret, '[redacted]');
text = text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [redacted]');
if (text.length > MAX_LINE_LENGTH)
text = text.slice(0, MAX_LINE_LENGTH) + '…';
return text;
}
lineSize(line) {
return encoder.encode(JSON.stringify(line)).length;
}
}
export function logMessage(level, message, fields) {
@@ -30,30 +65,7 @@ export function logMessage(level, message, fields) {
console[level](message, fields);
}
let context = currentRun.getStore();
if (!context)
return;
let text = message instanceof Error ? message.message : String(message);
// Keep readable progress messages; the full structured result has its own JSON view.
if (fields?.updater)
text = `[${fields.updater}] ${text}`;
if (fields?.error)
text += `: ${fields.error}`;
if (fields?.attempt)
text += ` (retry ${fields.attempt})`;
for (let secret of context.secrets)
text = text.replaceAll(secret, '[redacted]');
text = text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [redacted]');
const { snapshot } = context;
snapshot.lines.push({
at: Date.now(),
level,
text: text.length > MAX_LINE_LENGTH ? text.slice(0, MAX_LINE_LENGTH) + '…' : text,
});
context.bytes += encoder.encode(JSON.stringify(snapshot.lines.at(-1))).length;
while (snapshot.lines.length > MAX_LINES || context.bytes > MAX_BYTES) {
context.bytes -= encoder.encode(JSON.stringify(snapshot.lines.shift())).length;
snapshot.omitted++;
}
let run = currentRun.getStore();
if (run)
run.append(level, message, fields);
}

View File

@@ -1,11 +1,11 @@
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createRunLog, logMessage } from '../../src/app/log.js';
import { RunLog, logMessage } from '../../src/app/log.js';
test('bounds retained logs, redacts secrets, and preserves normal console output', async () => {
const consoleLog = mock.method(console, 'info', () => {});
try {
const capture = createRunLog(['secret-value']);
const capture = new RunLog(['secret-value']);
await capture.run(async () => {
for (let i = 0; i < 205; i++) logMessage('info', `line ${i}`);
logMessage('info', 'secret-value Bearer sensitive-token');
@@ -21,8 +21,8 @@ test('bounds retained logs, redacts secrets, and preserves normal console output
test('concurrent run contexts do not capture each other or unrelated messages', async () => {
mock.method(console, 'info', () => {});
try {
const a = createRunLog(),
b = createRunLog();
const a = new RunLog(),
b = new RunLog();
await Promise.all([
a.run(async () => {
await Promise.resolve();

View File

@@ -1,4 +1,4 @@
import { createRunLog, logMessage } from '../../../src/app/log.js';
import { RunLog, logMessage } from '../../../src/app/log.js';
// One owner for the hourly update → social pipeline and authenticated manual runs.
// The alarm targets :00:10; the cron watchdog repairs a missing alarm. Alarms can be late.
import { DurableObject } from 'cloudflare:workers';
@@ -127,7 +127,7 @@ export class Scheduler extends DurableObject {
const secrets = Object.entries({ ...process.env, ...this.env })
.filter(([key]) => /TOKEN|PASSWORD|SESSION|ACCOUNT_ID|SECRET/.test(key))
.map(([, value]) => value);
const capture = createRunLog(secrets);
const capture = new RunLog(secrets);
this.#activeRun = { mode, startedAt: Date.now(), logs: capture.snapshot };
try {
return await capture.run(async () => {