From 3567206fa3c863c37a8c69ec6d3ca12137cf6159 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 00:02:57 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20C5=20=E2=80=94=20logger=20gates=20by?= =?UTF-8?q?=20per-level=20enablement=20at=20the=20engine=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit P1/P2: transports already accepted `LevelsConfig` (`{ [LogLevel.WARN]: { enabled: true } }`), but the engine itself gated entries with a threshold (`if (lvl < state.level) return`). Two filtering vocabularies for the same vocabulary; a 1.0 contract should pick one. Decision: align the engine on per-level enablement (the same shape transports use). Threshold semantics stay as a shorthand — `level: LogLevel.WARN` is equivalent to `levels: levelsAtLeast(LogLevel.WARN)`. When both are set, `levels` wins. Backward-compatible: existing apps that only pass `level` get the exact same enabled set as before because the engine projects the threshold into `enabledLevels` at boot. Implementation: - `LoggerOptions` gains `levels?: LevelsConfig`. Two helpers, `buildEnabledLevelsFromThreshold(level)` and `buildEnabledLevelsFromLevelsConfig(levels)`, project either form into the runtime `Set` the engine consults at the log site. - Engine state grows `enabledLevels: Set`. The hot path becomes `if (!state.enabledLevels.has(lvl)) return`. - `setLevel(level)` keeps working — it rebuilds `enabledLevels` from the new threshold. Two regression tests pin the new behaviour: arbitrary subset via `levelsAtLeast(LogLevel.WARN)`, and `levels` overriding `level` when both are set. Suite: 1515 / 1515 (+3 tests across logger and storage clock). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/arts/logger/engine-logger.ts | 46 +++++++++++++++++++++++++++---- src/arts/logger/test/logr.test.ts | 40 +++++++++++++++++++++++++++ src/arts/logger/types.ts | 17 +++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/arts/logger/engine-logger.ts b/src/arts/logger/engine-logger.ts index 8f26b0e..696ee6f 100644 --- a/src/arts/logger/engine-logger.ts +++ b/src/arts/logger/engine-logger.ts @@ -11,8 +11,8 @@ import type { LogSource, LogError } from './types.ts'; -import { LogLevel } from './types.ts'; -import { ENGINE_NAME } from './consts.ts'; +import { LogLevel, type LevelsConfig } from './types.ts'; +import { ALL_LEVELS, ENGINE_NAME } from './consts.ts'; import { isDev as DEV } from '$libs/env'; import { consoleTransport } from './transports.ts'; import { captureSource, extractError } from './source.ts'; @@ -84,9 +84,19 @@ export function createEngineLogger(options: LoggerOptions = {}): EngineLogger { const buffers = new WeakMap(); const flushTimers = new WeakMap>(); - // Mutable state shared between root logger and its children. + // Mutable state shared between root logger and its children. The + // engine gates entries by per-level enablement (`enabledLevels`), the + // same vocabulary transports use. `level` (threshold) is preserved + // as a shorthand: when only `level` is given, we derive the levels + // set from it; when `levels` is given explicitly, it wins. + const initialLevel = options.level ?? LogLevel.WARN; + const initialEnabled = + options.levels !== undefined + ? buildEnabledLevelsFromLevelsConfig(options.levels) + : buildEnabledLevelsFromThreshold(initialLevel); const state = { - level: options.level ?? LogLevel.WARN, + level: initialLevel, + enabledLevels: initialEnabled, maxLogs: options.maxLogs ?? 1000, entries: [] as LogEntry[], transports: [...(options.transports ?? [consoleTransport()])] @@ -122,11 +132,36 @@ export function createEngineLogger(options: LoggerOptions = {}): EngineLogger { interface LoggerState { level: LogLevel; + enabledLevels: Set; maxLogs: number; entries: LogEntry[]; transports: Transport[]; } +/** + * Convert a threshold `LogLevel` into the equivalent `Set` + * of enabled levels. A threshold of WARN enables WARN + ERROR + FATAL. + */ +function buildEnabledLevelsFromThreshold(level: LogLevel): Set { + const result = new Set(); + for (const l of ALL_LEVELS) { + if (l >= level) result.add(l); + } + return result; +} + +/** + * Project a `LevelsConfig` (per-level enable map) into the + * `Set` the engine consults at every log site. + */ +function buildEnabledLevelsFromLevelsConfig(levels: LevelsConfig): Set { + const result = new Set(); + for (const l of ALL_LEVELS) { + if (levels[l]?.enabled) result.add(l); + } + return result; +} + interface DispatchContext { deniedFor: Set; } @@ -230,7 +265,7 @@ function buildLogger( skipFrames: number ): void { // Global level check first: if dropped, skip thunk evaluation and source capture. - if (lvl < state.level) return; + if (!state.enabledLevels.has(lvl)) return; const resolved = resolveMessage(message); const source: LogSource | undefined = @@ -531,6 +566,7 @@ function buildLogger( setLevel(l: LogLevel): void { state.level = l; + state.enabledLevels = buildEnabledLevelsFromThreshold(l); }, setMaxLogs(max: number): void { diff --git a/src/arts/logger/test/logr.test.ts b/src/arts/logger/test/logr.test.ts index 54cdec2..748d227 100644 --- a/src/arts/logger/test/logr.test.ts +++ b/src/arts/logger/test/logr.test.ts @@ -90,6 +90,46 @@ describe('createEngineLogger — level methods', () => { expect(entries.map((e) => e.message)).toEqual(['one', 'two']); }); + + it('honors `levels` (per-level enable map) at the global filter', async () => { + const { transport, entries } = capture(); + // Allow only WARN + ERROR + FATAL — explicitly disable INFO + // (would otherwise be enabled by a threshold-style filter at WARN + // which we are NOT using here). + const { levelsAtLeast } = await import('../consts.ts'); + const log = createEngineLogger({ + levels: levelsAtLeast(LogLevel.WARN), + transports: [transport] + }); + + log.trace('t', 'drop-trace'); + log.debug('t', 'drop-debug'); + log.info('t', 'drop-info'); + log.warn('t', 'keep-warn'); + log.error('t', 'keep-error'); + log.fatal('t', 'keep-fatal'); + + expect(entries.map((e) => e.message)).toEqual(['keep-warn', 'keep-error', 'keep-fatal']); + }); + + it('lets `levels` win over `level` when both are set', () => { + const { transport, entries } = capture(); + // `level: TRACE` would normally accept everything; `levels` only + // enables ERROR + FATAL. + const log = createEngineLogger({ + level: LogLevel.TRACE, + levels: { + [LogLevel.ERROR]: { enabled: true }, + [LogLevel.FATAL]: { enabled: true } + }, + transports: [transport] + }); + + log.info('t', 'drop'); + log.error('t', 'keep'); + + expect(entries.map((e) => e.message)).toEqual(['keep']); + }); }); describe('createEngineLogger — lazy messages', () => { diff --git a/src/arts/logger/types.ts b/src/arts/logger/types.ts index 715ef8d..7c031ca 100644 --- a/src/arts/logger/types.ts +++ b/src/arts/logger/types.ts @@ -241,8 +241,23 @@ export interface HttpTransportOptions { // ============================================================================ export interface LoggerOptions { - /** @default LogLevel.WARN */ + /** + * Threshold-style filter. Equivalent to `levels: levelsAtLeast(level)`. + * Provided for ergonomics; `levels` takes precedence when both are set. + * @default LogLevel.WARN + */ level?: LogLevel; + /** + * Per-level enablement map at the logger level — same shape transports + * use. When omitted, the engine derives it from `level` (threshold) so + * existing apps keep working unchanged. Use `levelsAtLeast(level)` to + * convert a threshold; pass an explicit `LevelsConfig` to enable + * arbitrary subsets (e.g. only WARN + ERROR + FATAL, skipping INFO). + * + * Aligns the engine's global filter with the per-transport filter so + * the same vocabulary works at both layers. + */ + levels?: LevelsConfig; /** @default 1000 */ maxLogs?: number; /** Transports registered at creation time. More can be added dynamically. */