Skip to content

Commit 5d47e12

Browse files
fix(logs): show emission time for replayed log lines, not render time
Log lines were timestamped at RENDER time (new Date() in the client), so replayed buffer lines — the init replay and the /live backlog seed — all showed page-open time instead of when each line was actually produced. Root cause: emission time was captured NOWHERE in the log path. The audit log file (pipeline logLine) and the in-memory logBuffer both stored bare strings, and the client invented a timestamp at paint. Thread a real emission timestamp end-to-end: - server.js broadcast('log'): stamp data.ts = Date.now() once, at emission. logBuffer changes from string[] to {ts,msg}[]; the ts rides the live SSE event, the buffer, and (via pipeline) the on-disk log. - server.js publicLogBuffer(): filter on .msg, return {ts,msg}[]. - server.js parseLogLine() + hydrateLogBufferFromFile(): recover ts from the on-disk '[<ISO>] <msg>' format; legacy bare lines → ts:null. - server.js sanitizeForPublic(): forward ts on public 'log' events. - audit/pipeline.js logLine(): prefix each persisted line with [<ISO>] so emission time survives a server restart / reloaded saved run. - admin.html: _logEntryFields() normalizes string|{ts,msg}; _buildLogEntry(msg,cat,tsMs) renders the emission time (null → BLANK, never a misleading now); appendLog uses Date.now() only for direct live string calls; appendLogBatch + SSE 'log' use the stored ts. - live.html: appendLog shape-sniffs {ts,msg} buffer/SSE entries (object with msg but no tag/cat/status), unwraps to the string-classify path, and renders the stored emission ts (null → blank); structured live lifecycle objects still stamp now. SSE 'log' case forwards ts. Lines whose emission time is genuinely unknown (older on-disk runs written before this change) render a BLANK time rather than a fake one. Test: admin-log-batch.test.js extended (15) — replayed {ts,msg} renders its emission time, bare string → blank, live string → current time, live {ts,msg} SSE → server ts. Adversarial review: SHIP (8/8 risks verified safe — buffer-shape readers, public API consumers, live shape-sniff, disk-format parse, no double-prefix, XSS intact, ms-epoch formatting).
1 parent 55f7711 commit 5d47e12

5 files changed

Lines changed: 119 additions & 21 deletions

File tree

admin.html

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,8 +1465,9 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14651465
if (msg.result) { upsertLocal(msg.result); addSingleRow(msg.result); }
14661466
applyState();
14671467
} else if (msg.type === 'log') {
1468-
// Live lines carry a server-sent category; prefer it over re-deriving.
1469-
appendLog(msg.msg, msg.cat);
1468+
// Live lines carry a server-sent category + emission ts; prefer both
1469+
// over re-deriving/now.
1470+
appendLog({ ts: msg.ts, msg: msg.msg }, msg.cat);
14701471
}
14711472
};
14721473
eventSource.onerror = () => {
@@ -2103,21 +2104,35 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21032104
// trim away — see appendLogBatch for why that mattered.
21042105
const LOG_DOM_MAX = 500;
21052106

2107+
// Normalise a log entry to { msg, ts }. Buffer/replay entries arrive as
2108+
// { ts, msg } (server-stamped emission time); older payloads / direct calls
2109+
// may be bare strings (ts unknown → null).
2110+
function _logEntryFields(entry) {
2111+
if (entry && typeof entry === 'object') {
2112+
return { msg: String(entry.msg == null ? '' : entry.msg), ts: typeof entry.ts === 'number' ? entry.ts : null };
2113+
}
2114+
return { msg: String(entry == null ? '' : entry), ts: null };
2115+
}
2116+
21062117
// Build ONE log-entry div — no DOM insertion, no scroll, no reflow. Shared
21072118
// by the live single-line appendLog and the batched init replay so
21082119
// categorization / escaping / linkification stay byte-identical between the
21092120
// two paths.
21102121
// `cat` is the server-sent category for live SSE lines; replayed init buffer
2111-
// lines arrive as plain strings (no cat), so re-derive via logCategory().
2112-
function _buildLogEntry(msg, cat) {
2122+
// lines arrive with no cat, so re-derive via logCategory().
2123+
// `tsMs` is the EMISSION time in ms — shows WHEN the line was produced, not
2124+
// when it rendered. null → blank time (genuinely unknown emission time, e.g.
2125+
// a pre-timestamp line from an older on-disk run); callers pass Date.now()
2126+
// for lines happening live.
2127+
function _buildLogEntry(msg, cat, tsMs) {
21132128
const div = document.createElement('div');
21142129
let cls = '';
21152130
if (/|warn|pause|insufficient/i.test(msg)) cls = 'log-warn';
21162131
else if (/|complete||success/i.test(msg)) cls = 'log-ok';
21172132
else if (/error|fail|ERR/i.test(msg)) cls = 'log-err';
21182133
div.className = 'log-entry ' + cls;
21192134
div.dataset.cat = cat || logCategory(msg);
2120-
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
2135+
const ts = tsMs != null ? new Date(tsMs).toLocaleTimeString('en-US', { hour12: false }) : '';
21212136
// Escape all five HTML-significant chars (incl. " and ') BEFORE linkifying.
21222137
// Without quote-escaping, an attacker-controlled URL containing a double
21232138
// quote (e.g. from an untrusted node operator's remote_addrs) would break
@@ -2129,10 +2144,14 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21292144
return div;
21302145
}
21312146

2132-
// Live single-line append: one row, one reflow — fine at live cadence.
2133-
function appendLog(msg, cat) {
2147+
// Live single-line append: one row, one reflow — fine at live cadence. A
2148+
// direct string call is a line happening NOW; an object entry carries its
2149+
// own emission ts.
2150+
function appendLog(entry, cat) {
21342151
const body = document.getElementById('logBody');
2135-
body.appendChild(_buildLogEntry(msg, cat));
2152+
const { msg, ts } = _logEntryFields(entry);
2153+
const tsMs = (entry && typeof entry === 'object') ? ts : Date.now();
2154+
body.appendChild(_buildLogEntry(msg, cat, tsMs));
21362155
body.scrollTop = body.scrollHeight;
21372156
if (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);
21382157
}
@@ -2145,12 +2164,16 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21452164
// appendLog enforces anyway, so the visible result is identical), (b) build
21462165
// them into a single DocumentFragment, and (c) insert + scroll exactly once.
21472166
// One reflow instead of thousands.
2148-
function appendLogBatch(msgs) {
2149-
if (!Array.isArray(msgs) || msgs.length === 0) return;
2167+
function appendLogBatch(entries) {
2168+
if (!Array.isArray(entries) || entries.length === 0) return;
21502169
const body = document.getElementById('logBody');
2151-
const tail = msgs.length > LOG_DOM_MAX ? msgs.slice(-LOG_DOM_MAX) : msgs;
2170+
const tail = entries.length > LOG_DOM_MAX ? entries.slice(-LOG_DOM_MAX) : entries;
21522171
const frag = document.createDocumentFragment();
2153-
for (const m of tail) frag.appendChild(_buildLogEntry(m));
2172+
// Replay uses each line's STORED emission ts (null → blank) — never now.
2173+
for (const e of tail) {
2174+
const { msg, ts } = _logEntryFields(e);
2175+
frag.appendChild(_buildLogEntry(msg, undefined, ts));
2176+
}
21542177
body.appendChild(frag);
21552178
body.scrollTop = body.scrollHeight;
21562179
while (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);

audit/pipeline.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,12 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
603603
auditLogPath = path.join(PROJECT_ROOT, 'results', `audit-${logTs}.log`);
604604
state.auditLogPath = auditLogPath;
605605
}
606-
const logLine = (msg) => { try { appendFileSync(auditLogPath, msg + '\n', 'utf8'); } catch {} };
606+
// Prefix each persisted line with an ISO emission timestamp (`[<ISO>] <msg>`)
607+
// so the server can recover WHEN a line was produced when it rehydrates the
608+
// log buffer from disk after a restart — see server.js parseLogLine. Written
609+
// at logLine-call time, which is ≈ emission (the broadcast tee fires it
610+
// synchronously right after the line is emitted).
611+
const logLine = (msg) => { try { appendFileSync(auditLogPath, `[${new Date().toISOString()}] ${msg}\n`, 'utf8'); } catch {} };
607612
if (resume) {
608613
logLine(`${'='.repeat(32)}`);
609614
logLine(`RESUMED: ${new Date().toISOString()}`);

live.html

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,19 @@ <h1 class="live-page-title">
13871387
const body = document.getElementById('logBody');
13881388
if (!body) return;
13891389

1390+
// Buffer/replay + raw 'log' SSE lines arrive as { ts, msg } (server-stamped
1391+
// emission time). They behave like plain string lines (classify by message
1392+
// shape) but carry their own time. Distinguish them from the rich
1393+
// structured lifecycle objects — which always carry tag/cat/status — by
1394+
// shape, unwrap to the string path, and remember the emission ts.
1395+
let _emitTs = null; // emission time in ms, or null = unknown → blank
1396+
let _haveTs = false; // entry specified its own time (vs. happening now)
1397+
if (opts && typeof opts === 'object' && ('msg' in opts) && !('tag' in opts) && !('cat' in opts) && !('status' in opts)) {
1398+
_emitTs = (typeof opts.ts === 'number') ? opts.ts : null;
1399+
_haveTs = true;
1400+
opts = String(opts.msg == null ? '' : opts.msg);
1401+
}
1402+
13901403
let o;
13911404
if (typeof opts === 'string') {
13921405
// Derive cat/tag from the message shape so historical/backfilled
@@ -1434,7 +1447,11 @@ <h1 class="live-page-title">
14341447
div.className = 'log-entry ' + cls;
14351448
div.dataset.cat = o.cat || 'sys';
14361449

1437-
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
1450+
// A normalised { ts, msg } entry uses its STORED emission time (null →
1451+
// blank, never a misleading now); live structured lifecycle objects are
1452+
// happening now.
1453+
const tsMs = _haveTs ? _emitTs : Date.now();
1454+
const ts = tsMs != null ? new Date(tsMs).toLocaleTimeString('en-US', { hour12: false }) : '';
14381455
const parts = [`<span class="log-time">${ts}</span>`];
14391456

14401457
// Tag pill
@@ -2340,7 +2357,8 @@ <h1 class="live-page-title">
23402357
break;
23412358
}
23422359
case 'log': {
2343-
if (d.msg) appendLog(d.msg);
2360+
// Carry the server's emission ts so the line shows WHEN it happened.
2361+
if (d.msg) appendLog({ ts: d.ts, msg: d.msg });
23442362
break;
23452363
}
23462364
case 'state': {

server.js

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ function classifyLogCategory(msg) {
310310
// diagnostics are hidden from spectators (the admin dashboard still shows all).
311311
// Filters the rolling string buffer for the public log endpoints.
312312
function publicLogBuffer() {
313-
return logBuffer.filter(m => classifyLogCategory(m) === 'node');
313+
return logBuffer.filter(e => classifyLogCategory(e.msg) === 'node');
314314
}
315315

316316
// EVENTS persist to a file (separate from per-run runs/test-NNN/audit.log), with
@@ -337,10 +337,15 @@ function appendEventLog(msg) {
337337
function broadcast(type, data = {}) {
338338
if (type === 'log' && data.msg) {
339339
// Tag the live SSE 'log' event with a category so admin/live can filter
340-
// without re-deriving it. logBuffer stays an array of strings (the client
341-
// re-classifies replayed init lines). Set BEFORE emitter.emit below.
340+
// without re-deriving it. Set BEFORE emitter.emit below.
342341
data.cat = data.cat || classifyLogCategory(data.msg);
343-
logBuffer.push(data.msg);
342+
// Stamp emission time ONCE, here, so every surface shows WHEN a line was
343+
// produced — not when the client rendered/replayed it. The ts rides on the
344+
// live SSE event (data.ts), the in-memory buffer ({ts,msg}), and — via the
345+
// pipeline's ISO-prefixed logLine — the on-disk audit log, so it survives a
346+
// restart too. Buffer entries are {ts,msg} objects; consumers read .msg.
347+
if (data.ts == null) data.ts = Date.now();
348+
logBuffer.push({ ts: data.ts, msg: data.msg });
344349
if (logBuffer.length > LOG_BUFFER_MAX) logBuffer.shift();
345350
if (data.cat === 'events') appendEventLog(data.msg);
346351
}
@@ -560,13 +565,26 @@ try { state.activeSDK = _rfs(SDK_PREF_FILE, 'utf8').trim() || 'js'; } catch { st
560565
// boot (after snapshot restore) and on /api/resume so the SSE init replay
561566
// always carries the in-flight run's full prior log history — not the last
562567
// few lines, and not a different file's contents.
568+
// Parse one on-disk audit line back into a {ts,msg} buffer entry. Lines are
569+
// written as `[<ISO>] <msg>` by audit/pipeline.js logLine. Recover the emission
570+
// timestamp when the prefix is present; lines from older runs (no prefix) yield
571+
// ts:null so the client renders a BLANK time rather than a misleading one.
572+
function parseLogLine(line) {
573+
const m = /^\[(\d{4}-\d\d-\d\dT[\d:.]+Z)\]\s([\s\S]*)$/.exec(line);
574+
if (m) {
575+
const t = Date.parse(m[1]);
576+
return { ts: Number.isNaN(t) ? null : t, msg: m[2] };
577+
}
578+
return { ts: null, msg: line };
579+
}
580+
563581
function hydrateLogBufferFromFile(filePath) {
564582
try {
565583
const txt = _rfs(filePath, 'utf8');
566584
const lines = txt.split('\n').filter(l => l.trim());
567585
const tail = lines.slice(-LOG_BUFFER_MAX);
568586
logBuffer.length = 0;
569-
logBuffer.push(...tail);
587+
logBuffer.push(...tail.map(parseLogLine));
570588
return tail.length;
571589
} catch { return 0; }
572590
}
@@ -1959,6 +1977,9 @@ function sanitizeForPublic(evt) {
19591977
if (evt.testedAt != null) safe.testedAt = evt.testedAt;
19601978
if (evt.msg != null) safe.msg = String(evt.msg).slice(0, 400);
19611979
if (evt.cat != null) safe.cat = evt.cat;
1980+
// Emission timestamp — lets /live show WHEN a line was produced, not when it
1981+
// was rendered (matters for the seeded backlog + reconnect replay).
1982+
if (evt.ts != null) safe.ts = evt.ts;
19621983
if (evt.baselineMbps != null) safe.baselineMbps = evt.baselineMbps;
19631984
if (evt.skipped === true) safe.skipped = true;
19641985
if (evt.inPlan === true) safe.inPlan = true;

test/admin-log-batch.test.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function extractFn(src, name) {
4343
return src.slice(m.index, j);
4444
}
4545

46-
const FNS = ['escHtml', 'logCategory', '_logEntryMatches', '_buildLogEntry', 'appendLog', 'appendLogBatch'];
46+
const FNS = ['escHtml', 'logCategory', '_logEntryMatches', '_logEntryFields', '_buildLogEntry', 'appendLog', 'appendLogBatch'];
4747
const extracted = FNS.map(n => extractFn(html, n)).join('\n\n');
4848

4949
// ─── Fake DOM ──────────────────────────────────────────────────────────────
@@ -144,6 +144,37 @@ for (let i = 0; i < 600; i++) {
144144
ok(logBody.children.length === LOG_DOM_MAX,
145145
`single-line appends trim to ${LOG_DOM_MAX} (got ${logBody.children.length})`);
146146

147+
// ─── 5. Emission-time: replay uses stored ts, never render-time ──────────────
148+
console.log('[5] replay shows EMISSION time, live shows current time');
149+
logBody._children = [];
150+
const T = Date.parse('2026-06-15T08:30:45.000Z');
151+
const expectedTs = new Date(T).toLocaleTimeString('en-US', { hour12: false });
152+
vm.runInContext('appendLogBatch(globalThis.__tsEntries)',
153+
Object.assign(sandbox, { __tsEntries: [{ ts: T, msg: 'restored line' }] }));
154+
const tsRow = logBody.children[0].innerHTML;
155+
ok(tsRow.includes(`<span class="log-time">${expectedTs}</span>`),
156+
`replayed { ts, msg } renders its EMISSION time (${expectedTs})`);
157+
158+
// A bare string (unknown emission time) renders a BLANK time, not a fake now.
159+
logBody._children = [];
160+
vm.runInContext('appendLogBatch(["legacy bare line"])', sandbox);
161+
ok(logBody.children[0].innerHTML.includes('<span class="log-time"></span>'),
162+
'replayed bare string (no ts) renders a BLANK time, not render-time');
163+
164+
// A direct live string append is happening NOW → non-empty current time.
165+
logBody._children = [];
166+
vm.runInContext('appendLog("live line happening now")', sandbox);
167+
const liveTime = /<span class="log-time">([^<]+)<\/span>/.exec(logBody.children[0].innerHTML);
168+
ok(liveTime && liveTime[1].trim().length > 0,
169+
'live string append stamps the current time (non-blank)');
170+
171+
// A live object entry { ts, msg } (e.g. a 'log' SSE line) uses its server ts.
172+
logBody._children = [];
173+
vm.runInContext('appendLog(globalThis.__liveObj)',
174+
Object.assign(sandbox, { __liveObj: { ts: T, msg: 'sse live line' } }));
175+
ok(logBody.children[0].innerHTML.includes(`<span class="log-time">${expectedTs}</span>`),
176+
'live { ts, msg } SSE line uses the server emission ts');
177+
147178
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
148179
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
149180
console.log('='.repeat(60));

0 commit comments

Comments
 (0)