From 706b7ee0e81652f9a8165acdc3dd54c8c73c1b06 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 17:22:40 -0700 Subject: [PATCH] Make run log capture an explicit class --- src/app/log.js | 88 ++++++++++++++++++------------- test/admin/log.test.mjs | 8 +-- workers/updater/src/Scheduler.mjs | 4 +- 3 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/app/log.js b/src/app/log.js index 8fb2dce..b760897 100644 --- a/src/app/log.js +++ b/src/app/log.js @@ -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); } diff --git a/test/admin/log.test.mjs b/test/admin/log.test.mjs index 6f155bc..a08193e 100644 --- a/test/admin/log.test.mjs +++ b/test/admin/log.test.mjs @@ -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(); diff --git a/workers/updater/src/Scheduler.mjs b/workers/updater/src/Scheduler.mjs index a244ef6..944ee0c 100644 --- a/workers/updater/src/Scheduler.mjs +++ b/workers/updater/src/Scheduler.mjs @@ -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 () => {