Skip to content

Commit 6c9a491

Browse files
committed
feat(logger): central leveled logger + capture into the Diagnostic Bundle
Wave 1 debug cockpit. Adds scripts/logger.mjs: leveled logging (error<warn<info<debug <trace, plus silent) gating console noise, with an always-on ring buffer (cap 500) so recent messages export even when the console wasn't open. The Diagnostic Bundle now includes the captured buffer; the dashboard's new capability/bundle handlers route through log.* as the first adopters. Level CONTROL (UI/setting) is intentionally deferred until enough call sites adopt the logger — shipping a no-op control would be the kind of fake feature we're avoiding. 6 logger tests + bundle log-buffer test; full suite 557/557. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf
1 parent 5b3de3a commit 6c9a491

5 files changed

Lines changed: 205 additions & 4 deletions

File tree

scripts/logger.mjs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Chronicle Sync — leveled logger with an in-memory ring buffer.
3+
*
4+
* Two jobs:
5+
* 1. Gate console noise by a current level (error < warn < info < debug < trace).
6+
* 2. ALWAYS capture recent messages into a ring buffer, so the dashboard can
7+
* export them (Diagnostic Bundle) even when the console wasn't open.
8+
*
9+
* Adoption is incremental: modules migrate `console.*` → `log.*` over time. The
10+
* level control is wired into the UI only once enough call sites route through
11+
* here for it to actually govern output (so we never ship a no-op control).
12+
*
13+
* PURE except for `console` and the clock — unit-tested in tools/test-logger.mjs.
14+
*/
15+
16+
/** Numeric severities; lower = more severe. `silent` suppresses all console output. */
17+
export const LOG_LEVELS = Object.freeze({ silent: -1, error: 0, warn: 1, info: 2, debug: 3, trace: 4 });
18+
19+
const RING_MAX = 500;
20+
let _level = LOG_LEVELS.info;
21+
const _ring = [];
22+
23+
/**
24+
* Set the active console level by name ("error".."trace"/"silent") or number.
25+
* Unknown names are ignored (keeps the prior level).
26+
* @param {string|number} level
27+
*/
28+
export function setLogLevel(level) {
29+
if (typeof level === 'number' && Number.isFinite(level)) {
30+
_level = level;
31+
return;
32+
}
33+
if (typeof level === 'string' && Object.prototype.hasOwnProperty.call(LOG_LEVELS, level)) {
34+
_level = LOG_LEVELS[level];
35+
}
36+
}
37+
38+
/** @returns {string} the active level name. */
39+
export function getLogLevelName() {
40+
return Object.keys(LOG_LEVELS).find((k) => LOG_LEVELS[k] === _level) ?? 'info';
41+
}
42+
43+
function _stringify(v) {
44+
if (typeof v === 'string') return v;
45+
if (v instanceof Error) return v.message || String(v);
46+
try { return JSON.stringify(v); } catch (err) { return String(v); }
47+
}
48+
49+
function _record(levelName, args) {
50+
_ring.push({ t: Date.now(), level: levelName, msg: args.map(_stringify).join(' ') });
51+
if (_ring.length > RING_MAX) _ring.shift();
52+
}
53+
54+
function _emit(levelName, args) {
55+
_record(levelName, args);
56+
const sev = LOG_LEVELS[levelName];
57+
if (sev < 0 || sev > _level) return; // below the active threshold → captured but not printed
58+
const fn = levelName === 'error' ? console.error
59+
: levelName === 'warn' ? console.warn
60+
: levelName === 'info' ? (console.info || console.log)
61+
: (console.debug || console.log);
62+
try { fn.call(console, `Chronicle [${levelName}]:`, ...args); } catch (err) { /* console unavailable */ }
63+
}
64+
65+
/** Leveled log methods. Each records to the ring AND prints if the level allows. */
66+
export const log = {
67+
error: (...a) => _emit('error', a),
68+
warn: (...a) => _emit('warn', a),
69+
info: (...a) => _emit('info', a),
70+
debug: (...a) => _emit('debug', a),
71+
trace: (...a) => _emit('trace', a),
72+
};
73+
74+
/** @returns {Array<{t:number, level:string, msg:string}>} a copy of the ring buffer. */
75+
export function getLogBuffer() {
76+
return _ring.slice();
77+
}
78+
79+
/** Clear the captured ring buffer. */
80+
export function clearLogBuffer() {
81+
_ring.length = 0;
82+
}
83+
84+
/** Render the ring buffer as plain text (for the Diagnostic Bundle / export). */
85+
export function exportLogText() {
86+
return _ring
87+
.map((e) => `[${new Date(e.t).toISOString()}] ${e.level.toUpperCase()} ${e.msg}`)
88+
.join('\n');
89+
}

scripts/sync-dashboard.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
CAP_STATUS,
2424
} from './capability-inspector.mjs';
2525
import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs';
26+
import { log, getLogBuffer } from './logger.mjs';
2627
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
2728

2829
/**
@@ -232,7 +233,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
232233
try {
233234
capabilityData = await this._buildCapabilityData();
234235
} catch (err) {
235-
console.error('Chronicle Dashboard: Failed to build sync capability', err);
236+
log.error('Dashboard: Failed to build sync capability', err);
236237
loadErrors.push({ tab: 'status', message: err.message || 'Failed to build sync capability' });
237238
}
238239

@@ -1039,7 +1040,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
10391040
const resp = await this.api?.get(`/systems/${matchedSystem}/character-fields`);
10401041
if (resp && Array.isArray(resp.fields)) fieldDefs = resp;
10411042
} catch (err) {
1042-
console.warn('Chronicle Dashboard: capability — failed to load character-fields', err);
1043+
log.warn('Dashboard: capability — failed to load character-fields', err);
10431044
}
10441045

10451046
const snapshot = captureActorSnapshot(sample);
@@ -1541,6 +1542,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15411542
capability: cap && cap.ok ? { source: cap.source, summary: cap.summary } : null,
15421543
activityLog: (this._syncManager?.getActivityLog?.() ?? []).slice(0, 100),
15431544
errorLog: (this.api?.getErrorLog?.() ?? []).slice(0, 100),
1545+
logBuffer: getLogBuffer().slice(-200),
15441546
};
15451547
}
15461548

@@ -1558,7 +1560,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15581560
}
15591561
ui.notifications.info('Chronicle: Diagnostic bundle copied to clipboard.');
15601562
} catch (err) {
1561-
console.error('Chronicle Dashboard: diagnostic bundle copy failed', err);
1563+
log.error('Dashboard: diagnostic bundle copy failed', err);
15621564
if (resultEl) {
15631565
resultEl.textContent = 'Copy failed — check console.';
15641566
resultEl.className = 'debug-copy-result test-error';
@@ -1595,7 +1597,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15951597
}
15961598
ui.notifications.info('Chronicle: Sync capability report copied to clipboard.');
15971599
} catch (err) {
1598-
console.error('Chronicle Dashboard: capability copy failed', err);
1600+
log.error('Dashboard: capability copy failed', err);
15991601
if (resultEl) {
16001602
resultEl.textContent = 'Copy failed — check console.';
16011603
resultEl.className = 'debug-copy-result test-error';

scripts/sync-diagnostic-bundle.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ export function buildDiagnosticBundle(input) {
121121
}
122122
L.push('');
123123

124+
// --- Captured log buffer (leveled logger ring) ---
125+
if (Array.isArray(d.logBuffer) && d.logBuffer.length) {
126+
L.push(`## Log buffer (${d.logBuffer.length})`);
127+
for (const e of d.logBuffer) {
128+
const ts = e && e.t ? new Date(e.t).toISOString() : '';
129+
L.push(`- \`${ts}\` ${String((e && e.level) || '').toUpperCase()} ${_s(e && e.msg)}`);
130+
}
131+
L.push('');
132+
}
133+
124134
return L.join('\n');
125135
}
126136

tools/test-logger.mjs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Unit tests for the leveled logger (`scripts/logger.mjs`).
4+
* Run: `node --test tools/test-logger.mjs`
5+
*/
6+
7+
import test from 'node:test';
8+
import assert from 'node:assert/strict';
9+
10+
const { log, setLogLevel, getLogLevelName, getLogBuffer, clearLogBuffer, exportLogText, LOG_LEVELS } =
11+
await import('../scripts/logger.mjs');
12+
13+
function withConsoleSpy(fn) {
14+
const calls = { error: 0, warn: 0, info: 0, debug: 0, log: 0 };
15+
const orig = { error: console.error, warn: console.warn, info: console.info, debug: console.debug, log: console.log };
16+
console.error = () => { calls.error++; };
17+
console.warn = () => { calls.warn++; };
18+
console.info = () => { calls.info++; };
19+
console.debug = () => { calls.debug++; };
20+
console.log = () => { calls.log++; };
21+
try { fn(calls); } finally { Object.assign(console, orig); }
22+
}
23+
24+
test('level set/get by name and number; unknown name ignored', () => {
25+
setLogLevel('warn');
26+
assert.equal(getLogLevelName(), 'warn');
27+
setLogLevel(LOG_LEVELS.debug);
28+
assert.equal(getLogLevelName(), 'debug');
29+
setLogLevel('bogus');
30+
assert.equal(getLogLevelName(), 'debug'); // unchanged
31+
});
32+
33+
test('ring buffer ALWAYS captures, regardless of console level', () => {
34+
clearLogBuffer();
35+
setLogLevel('error'); // only errors print…
36+
withConsoleSpy(() => {
37+
log.debug('a debug line');
38+
log.error('an error line');
39+
});
40+
const buf = getLogBuffer();
41+
assert.equal(buf.length, 2); // both captured
42+
assert.deepEqual(buf.map((e) => e.level), ['debug', 'error']);
43+
assert.match(buf[0].msg, /a debug line/);
44+
});
45+
46+
test('console output is gated by the active level', () => {
47+
clearLogBuffer();
48+
setLogLevel('warn');
49+
withConsoleSpy((calls) => {
50+
log.error('e');
51+
log.warn('w');
52+
log.info('i');
53+
log.debug('d');
54+
assert.equal(calls.error, 1);
55+
assert.equal(calls.warn, 1);
56+
assert.equal(calls.info, 0); // below threshold → not printed
57+
assert.equal(calls.debug, 0);
58+
});
59+
});
60+
61+
test('silent suppresses all console output but still captures', () => {
62+
clearLogBuffer();
63+
setLogLevel('silent');
64+
withConsoleSpy((calls) => {
65+
log.error('still recorded');
66+
assert.equal(calls.error, 0);
67+
});
68+
assert.equal(getLogBuffer().length, 1);
69+
});
70+
71+
test('objects/errors are stringified; exportLogText renders lines', () => {
72+
clearLogBuffer();
73+
setLogLevel('trace');
74+
withConsoleSpy(() => {
75+
log.info('obj', { a: 1 });
76+
log.error(new Error('boom'));
77+
});
78+
const txt = exportLogText();
79+
assert.match(txt, /INFO obj \{"a":1\}/);
80+
assert.match(txt, /ERROR boom/);
81+
});
82+
83+
test('ring buffer is capped (does not grow unbounded)', () => {
84+
clearLogBuffer();
85+
setLogLevel('silent');
86+
for (let i = 0; i < 600; i++) log.info('x' + i);
87+
assert.ok(getLogBuffer().length <= 500);
88+
});

tools/test-sync-diagnostic-bundle.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,15 @@ test('missing values degrade to placeholders, not crashes', () => {
5959
assert.match(out, /Module: \*\*\*\*/);
6060
assert.match(out, /State: \*\*\*\*/);
6161
});
62+
63+
test('leveled log buffer renders when present', () => {
64+
const out = buildDiagnosticBundle({
65+
logBuffer: [
66+
{ t: Date.parse('2026-06-26T12:00:00Z'), level: 'error', msg: 'PUT failed' },
67+
{ t: Date.parse('2026-06-26T12:00:01Z'), level: 'debug', msg: 'pull start' },
68+
],
69+
});
70+
assert.match(out, /## Log buffer \(2\)/);
71+
assert.match(out, /ERROR PUT failed/);
72+
assert.match(out, /2026-06-26T12:00:01\.000Z` DEBUG pull start/);
73+
});

0 commit comments

Comments
 (0)