Bloque H1+H2 — logger onInternalError + clock/idFactory injection

H1 — `LoggerOptions.onInternalError?: (info) => void` lets enterprise
hosts capture transport failures somewhere other than `console.error`
(Sentry's captureException, an audit pipeline, etc.). When defined, the
engine routes the failure-path notification to the hook instead of
`console.error`. The synthetic failure entry that flows to remaining
transports is independent of the hook — it always dispatches.

H2 — `LoggerOptions.clock?: { now }` and `LoggerOptions.idFactory?:
() => string` make timestamps and entry ids deterministic for tests and
runtimes with strict time discipline. The Logger is created BEFORE
`App.Timers`, so this is opt-in injection (not App-wired). Default
`Date.now` is late-bound through a closure so existing
`vi.spyOn(Date, 'now')` test patterns keep working.

Tests cover both injections plus the fallback path (no
`onInternalError` → `console.error` is still called).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 21ca220fef
commit 5152b23c6b

@ -18,10 +18,12 @@ import { consoleTransport } from './transports.ts';
import { captureSource, extractError } from './source.ts';
/**
* Unique entry id. Uses `crypto.randomUUID()` when available (modern browsers,
* Node 16+); falls back to a compact `timestamp-random` hex in older hosts.
* Default unique entry id. Uses `crypto.randomUUID()` when available
* (modern browsers, Node 16+); falls back to a compact `timestamp-random`
* hex in older hosts. Hosts that need deterministic ids in tests can
* override via `LoggerOptions.idFactory`.
*/
function generateId(): string {
function defaultGenerateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
@ -78,6 +80,12 @@ function generateId(): string {
*/
export function createEngineLogger(options: LoggerOptions = {}): EngineLogger {
const captureSourceEnabled: boolean = options.captureSource ?? DEV;
const onInternalError = options.onInternalError;
// Late-bind `Date.now` so test suites that `vi.spyOn(Date, 'now')`
// after logger construction still see their stub. Callers that
// inject `options.clock` get their function captured directly.
const clockNow = options.clock?.now ?? ((): number => Date.now());
const generateId = options.idFactory ?? defaultGenerateId;
const timers = new Map<string, number>();
// Per-transport buffers and interval timer ids — indexed by reference.
@ -93,9 +101,20 @@ export function createEngineLogger(options: LoggerOptions = {}): EngineLogger {
transports: [...(options.transports ?? [consoleTransport()])]
};
const built = buildLogger(captureSourceEnabled, timers, buffers, flushTimers, state, {
...options.globalContext
});
const built = buildLogger(
captureSourceEnabled,
timers,
buffers,
flushTimers,
state,
{
...options.globalContext
},
undefined,
onInternalError,
clockNow,
generateId
);
// Browser: flush everything before the page unloads so buffered entries
// get a chance to reach their destinations. The handler reference is kept
@ -190,7 +209,10 @@ function buildLogger(
flushTimers: WeakMap<Transport, ReturnType<typeof setTimeout>>,
state: LoggerState,
globalContext: Record<string, unknown>,
failureThrottle: WeakMap<Transport, FailureThrottleState> = new WeakMap()
failureThrottle: WeakMap<Transport, FailureThrottleState> = new WeakMap(),
onInternalError?: (info: { readonly transport: string; readonly error: unknown }) => void,
clockNow: () => number = Date.now,
generateId: () => string = defaultGenerateId
): EngineLogger {
function mergeContext(input?: LogInput): Record<string, unknown> | undefined {
const inputCtx = input?.context;
@ -239,7 +261,7 @@ function buildLogger(
const entry: LogEntry = {
id: generateId(),
timestamp: new Date(),
timestamp: new Date(clockNow()),
level: lvl,
category,
message: resolved,
@ -416,7 +438,16 @@ function buildLogger(
const failedName = transportName(failed);
context.deniedFor.add(failedName);
console.error(`[${ENGINE_NAME}] Transport "${failedName}" error:`, err);
// `onInternalError` lets enterprise hosts capture engine-internal
// failures somewhere other than the runtime console (Sentry's
// captureException, an audit log, etc.). The synthetic failure
// entry below is independent of this hook — it always flows to
// the remaining transports.
if (onInternalError !== undefined) {
onInternalError({ transport: failedName, error: err });
} else {
console.error(`[${ENGINE_NAME}] Transport "${failedName}" error:`, err);
}
// Per-transport failure throttle. Inside an active window we suppress
// the synthetic failure entry; the actual `write()` keeps being
@ -424,7 +455,7 @@ function buildLogger(
const throttleMs = failed.failureThrottleMs ?? 0;
let suppressedCount = 0;
if (throttleMs > 0) {
const now = Date.now();
const now = clockNow();
const current = failureThrottle.get(failed);
if (current && current.until > now) {
current.suppressed++;
@ -445,7 +476,7 @@ function buildLogger(
const failureEntry: LogEntry = {
id: generateId(),
timestamp: new Date(),
timestamp: new Date(clockNow()),
level: LogLevel.ERROR,
category: ENGINE_NAME,
message,
@ -577,7 +608,10 @@ function buildLogger(
flushTimers,
state,
{ ...globalContext, ...ctx },
failureThrottle
failureThrottle,
onInternalError,
clockNow,
generateId
);
},

@ -552,6 +552,84 @@ describe('createEngineLogger — transport failure and deniedFor', () => {
expect(throwCount).toBe(1);
});
it('honors injected `clock` for entry timestamps and throttle math', () => {
const { transport, entries } = capture();
let now = 1_000_000;
const log = createEngineLogger({
level: LogLevel.TRACE,
clock: { now: () => now },
transports: [transport]
});
log.info('t', 'first');
now = 2_000_000;
log.info('t', 'second');
expect(entries[0].timestamp.getTime()).toBe(1_000_000);
expect(entries[1].timestamp.getTime()).toBe(2_000_000);
});
it('honors injected `idFactory` for stable entry ids', () => {
const { transport, entries } = capture();
let counter = 0;
const log = createEngineLogger({
level: LogLevel.TRACE,
idFactory: () => `id-${++counter}`,
transports: [transport]
});
log.info('t', 'a');
log.info('t', 'b');
log.info('t', 'c');
expect(entries.map((e) => e.id)).toEqual(['id-1', 'id-2', 'id-3']);
});
it('routes engine-internal errors through `onInternalError` instead of console.error', () => {
const captured: Array<{ transport: string; error: unknown }> = [];
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const log = createEngineLogger({
level: LogLevel.TRACE,
transports: [],
onInternalError: (info) => {
captured.push({ transport: info.transport, error: info.error });
}
});
log.addTransport({
name: 'bad',
write() {
throw new Error('boom');
}
});
log.error('t', 'original');
expect(captured).toHaveLength(1);
expect(captured[0].transport).toBe('bad');
expect((captured[0].error as Error).message).toBe('boom');
// Hook present → console.error is NOT called for the internal failure.
expect(errSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
it('falls back to console.error when no `onInternalError` is supplied', () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'bad',
write() {
throw new Error('boom');
}
});
log.error('t', 'original');
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('cascade guard: a second-level failure is swallowed', () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({

@ -229,6 +229,34 @@ export interface LoggerOptions {
* @default DEV
*/
captureSource?: boolean;
/**
* Hook invoked when a transport throws or rejects. Receives the failed
* transport's `name` and the error. The synthetic failure entry that
* the engine routes to the remaining transports is independent of
* this hook — `onInternalError` is for the rare case when the host
* needs to capture engine-internal failures somewhere other than
* `console`.
*
* When defined, `console.error` is NOT called. When undefined, the
* engine falls back to `console.error` (the historical behavior) so
* existing apps see no change.
*/
onInternalError?: (info: { readonly transport: string; readonly error: unknown }) => void;
/**
* Optional clock used for `failureThrottleMs` window math and for the
* `Date` stamp on every `LogEntry`. Defaults to `Date.now`. The Logger
* is created BEFORE `App.Timers`, so this option exists for tests and
* runtimes that need a deterministic time source — not for App-level
* wiring.
*/
clock?: { now: () => number };
/**
* Optional factory for entry ids. Defaults to `crypto.randomUUID()`
* with a hex `${time}-${rand}` fallback. Useful for tests that need
* stable ids in snapshots, or for hosts that want correlation ids
* derived from external context (W3C trace ids, request ids, etc.).
*/
idFactory?: () => string;
}
// ============================================================================

Loading…
Cancel
Save

Powered by TurnKey Linux.