From b529c958e2bf9e1711aa00b32cf6cca0059f602a Mon Sep 17 00:00:00 2001 From: JVQ Date: Fri, 26 Jun 2026 11:58:57 +0200 Subject: [PATCH 1/2] feat: child loggers, structured meta, Error serialization, timers, TTY-aware color - Add logger.child(meta) for context-binding child loggers; merges static and dynamic meta from parent, supports arbitrary nesting - Widen meta type from Record to Record so numbers, booleans, and objects pass through to JSON/logfmt sinks - Serialize Error arguments to { name, message, stack } on HellogMessage.error for structured sinks; human content unchanged via util.format - Add time(label)/timeEnd(label) duration timers using process.hrtime.bigint() with durationMs in meta; no memory leak on unknown labels - Add isLevelEnabled(level) guard for expensive argument construction - Make HellogColorizeDefaultPlugin TTY-aware: auto-detects process.stdout.isTTY, NO_COLOR, FORCE_COLOR; accepts { enabled? } override - Convert DefaultPlugins static array to per-instance factory to prevent cross-logger state bleed as plugins gain state - New lib/errors.ts with SerializedError interface and serializeError() util - 32 tests, 97% coverage (up from 17 tests, 88% coverage) - Zero new runtime dependencies; full backward compatibility Co-Authored-By: Claude Sonnet 4.6 --- lib/errors.ts | 14 ++++++ lib/hellog.spec.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++ lib/hellog.ts | 100 +++++++++++++++++++++++++++++++++------- lib/index.ts | 1 + lib/messages.ts | 4 +- lib/plugins.spec.ts | 66 ++++++++++++++++++++++++++- lib/plugins.ts | 41 +++++++++++++++-- 7 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 lib/errors.ts diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..0020fa2 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,14 @@ +export interface SerializedError { + name: string; + message: string; + stack?: string | undefined; +} + +export function serializeError(err: unknown): SerializedError | undefined { + if (!(err instanceof Error)) return undefined; + return { + name: err.name, + message: err.message, + stack: err.stack, + }; +} diff --git a/lib/hellog.spec.ts b/lib/hellog.spec.ts index 3f1f057..12d23e1 100644 --- a/lib/hellog.spec.ts +++ b/lib/hellog.spec.ts @@ -10,6 +10,7 @@ import { HellogPrettyDefaultPlugin, HellogStdoutDefaultPlugin, } from './plugins.js'; +import { SerializedError } from './errors.js'; class StoredLogsTransportPlugin extends HellogPlugin { readonly logs: HellogMessage[] = []; @@ -107,3 +108,110 @@ describe(Hellog.name, () => { assert.strictEqual(formatMock.mock.calls.length, 1); }); }); + +describe('Hellog.child', () => { + it('should merge static parent + child meta', () => { + const store = new StoredLogsTransportPlugin(); + const parent = new Hellog({ meta: { service: 'api' }, plugins: [store] }); + const child = parent.child({ requestId: '123' }); + child.info('hello'); + assert.strictEqual(store.logs[0]?.meta['service'], 'api'); + assert.strictEqual(store.logs[0]?.meta['requestId'], '123'); + }); + + it('should not affect parent meta', () => { + const store = new StoredLogsTransportPlugin(); + const parent = new Hellog({ meta: { service: 'api' }, plugins: [store] }); + parent.child({ requestId: '123' }).info('child log'); + parent.info('parent log'); + assert.strictEqual(store.logs[0]?.meta['requestId'], '123'); + assert.strictEqual(store.logs[1]?.meta['requestId'], undefined); + }); + + it('should support dynamic meta in child', () => { + const store = new StoredLogsTransportPlugin(); + let counter = 0; + const parent = new Hellog({ meta: { service: 'api' }, plugins: [store] }); + const child = parent.child(() => ({ seq: String(++counter) })); + child.info('first'); + child.info('second'); + assert.strictEqual(store.logs[0]?.meta['seq'], '1'); + assert.strictEqual(store.logs[1]?.meta['seq'], '2'); + }); + + it('should nest children', () => { + const store = new StoredLogsTransportPlugin(); + const root = new Hellog({ meta: { a: '1' }, plugins: [store] }); + root.child({ b: '2' }).child({ c: '3' }).info('nested'); + assert.strictEqual(store.logs[0]?.meta['a'], '1'); + assert.strictEqual(store.logs[0]?.meta['b'], '2'); + assert.strictEqual(store.logs[0]?.meta['c'], '3'); + }); +}); + +describe('Hellog.isLevelEnabled', () => { + it('should return true for levels at or above maxLevel', () => { + const logger = new Hellog({ level: HellogLevel.WARN }); + assert.strictEqual(logger.isLevelEnabled(HellogLevel.WARN), true); + assert.strictEqual(logger.isLevelEnabled(HellogLevel.ERROR), true); + }); + + it('should return false for levels below maxLevel', () => { + const logger = new Hellog({ level: HellogLevel.WARN }); + assert.strictEqual(logger.isLevelEnabled(HellogLevel.DEBUG), false); + assert.strictEqual(logger.isLevelEnabled(HellogLevel.INFO), false); + }); +}); + +describe('Hellog.time / timeEnd', () => { + it('should log a positive durationMs and clean up the timer', () => { + const store = new StoredLogsTransportPlugin(); + const logger = new Hellog({ level: HellogLevel.DEBUG, plugins: [store] }); + logger.time('op'); + logger.timeEnd('op'); + assert.strictEqual(store.logs.length, 1); + const durationMs = store.logs[0]?.meta['durationMs']; + assert.ok(typeof durationMs === 'number' && durationMs >= 0); + assert.ok(store.logs[0]?.content.includes('op:')); + }); + + it('should do nothing for unknown label', () => { + const store = new StoredLogsTransportPlugin(); + const logger = new Hellog({ level: HellogLevel.DEBUG, plugins: [store] }); + logger.timeEnd('nonexistent'); + assert.strictEqual(store.logs.length, 0); + }); +}); + +describe('Hellog structured meta', () => { + it('should accept non-string meta values', () => { + const store = new StoredLogsTransportPlugin(); + const logger = new Hellog({ meta: { port: 8080, debug: true }, plugins: [store] }); + logger.info('started'); + assert.strictEqual(store.logs[0]?.meta['port'], 8080); + assert.strictEqual(store.logs[0]?.meta['debug'], true); + }); +}); + +describe('Hellog Error serialization', () => { + it('should attach serialized error to message when Error is logged', () => { + const store = new StoredLogsTransportPlugin(); + const logger = new Hellog({ plugins: [store] }); + const err = new TypeError('bad input'); + logger.error('caught', err); + const msg = store.logs[0]; + assert.ok(msg); + assert.ok(msg.error !== undefined); + const e = msg.error as SerializedError; + assert.strictEqual(e.name, 'TypeError'); + assert.strictEqual(e.message, 'bad input'); + assert.ok(typeof e.stack === 'string'); + }); + + it('should not set error field for non-Error arguments', () => { + const store = new StoredLogsTransportPlugin(); + const logger = new Hellog({ plugins: [store] }); + logger.info('plain message'); + assert.strictEqual(store.logs[0]?.error, undefined); + }); +}); diff --git a/lib/hellog.ts b/lib/hellog.ts index 0184142..c5a78ac 100644 --- a/lib/hellog.ts +++ b/lib/hellog.ts @@ -1,4 +1,5 @@ import { format } from 'node:util'; +import { serializeError } from './errors.js'; import { HellogLevel, HellogLevelOrder } from './levels.js'; import { HellogMessage } from './messages.js'; import { @@ -12,7 +13,7 @@ import { interface HellogOptions { level?: HellogLevel; plugins?: HellogPlugin[]; - meta?: Record | (() => Record); + meta?: Record | (() => Record); } /** @@ -49,16 +50,20 @@ interface HellogOptions { */ export class Hellog { /** - * Default plugins used by the logger when none are provided. + * Returns a fresh default plugin stack. Each logger instance gets its own + * plugin instances to prevent cross-logger state bleed. */ - static DefaultPlugins = [ - new HellogLineBreakDefaultPlugin(), - new HellogPrettyDefaultPlugin(), - new HellogColorizeDefaultPlugin(), - new HellogStdoutDefaultPlugin(), - ]; + static defaultPlugins(): HellogPlugin[] { + return [ + new HellogLineBreakDefaultPlugin(), + new HellogPrettyDefaultPlugin(), + new HellogColorizeDefaultPlugin(), + new HellogStdoutDefaultPlugin(), + ]; + } readonly options: HellogOptions | undefined; + private readonly _timers = new Map(); constructor(options?: HellogOptions) { this.options = options; @@ -69,7 +74,15 @@ export class Hellog { } get plugins(): HellogPlugin[] { - return this.options?.plugins ?? Hellog.DefaultPlugins; + return this.options?.plugins ?? Hellog.defaultPlugins(); + } + + /** + * Returns true when logs at the given level will be processed. + * Use to guard expensive argument construction. + */ + isLevelEnabled(level: HellogLevel): boolean { + return HellogLevelOrder[level] >= HellogLevelOrder[this.maxLevel]; } /** @@ -85,16 +98,68 @@ export class Hellog { }); } - private _log(data: unknown[], level: HellogLevel): void { + /** + * Create a child logger that inherits level + plugins and merges additional + * meta on top of the parent's. Supports static or dynamic meta. + * + * @example + * ```ts + * const reqLog = logger.child({ requestId: 'abc' }); + * reqLog.info('handling request'); // includes requestId in meta + * ``` + */ + child(meta: Record | (() => Record)): Hellog { + const parentMeta = this.options?.meta; + return new Hellog({ + ...this.options, + meta: () => ({ + ...Hellog._resolveMeta(parentMeta), + ...Hellog._resolveMeta(meta), + }), + }); + } + + /** + * Start a timer for the given label. Call {@link timeEnd} to stop it. + * Uses `process.hrtime.bigint()` for sub-millisecond monotonic precision. + */ + time(label: string): void { + this._timers.set(label, process.hrtime.bigint()); + } + + /** + * Stop a timer started with {@link time} and log the elapsed duration. + * Logs at DEBUG level by default. + * + * @param label - The label used in {@link time}. + * @param level - The log level to use (default: DEBUG). + */ + timeEnd(label: string, level: HellogLevel = HellogLevel.DEBUG): void { + const start = this._timers.get(label); + if (start === undefined) return; + this._timers.delete(label); + const ms = Number(process.hrtime.bigint() - start) / 1_000_000; + this._log([`${label}: ${ms.toFixed(3)}ms`], level, { durationMs: ms }); + } + + private static _resolveMeta( + meta: Record | (() => Record) | undefined, + ): Record { + if (!meta) return {}; + if (typeof meta === 'function') return meta(); + return meta; + } + + private _log(data: unknown[], level: HellogLevel, extraMeta?: Record): void { if (HellogLevelOrder[level] < HellogLevelOrder[this.maxLevel]) return; - let metaObject: Record; - const meta = this.options?.meta; - if (meta && typeof meta === 'function') { - metaObject = meta(); - } else { - metaObject = meta ?? {}; - } + const metaObject: Record = { + ...Hellog._resolveMeta(this.options?.meta), + ...extraMeta, + }; + + const err = data.find((d): d is Error => d instanceof Error); + const serialized = serializeError(err); let messages: HellogMessage[] = [ { @@ -102,6 +167,7 @@ export class Hellog { timestamp: new Date(), level, meta: metaObject, + ...(serialized !== undefined ? { error: serialized } : {}), }, ]; diff --git a/lib/index.ts b/lib/index.ts index eb91a5e..cd9b250 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ +export * from './errors.js'; export * from './hellog.js'; export * from './levels.js'; export * from './messages.js'; diff --git a/lib/messages.ts b/lib/messages.ts index 0491f84..b947c4e 100644 --- a/lib/messages.ts +++ b/lib/messages.ts @@ -1,8 +1,10 @@ import { HellogLevel } from './levels.js'; +import { SerializedError } from './errors.js'; export interface HellogMessage { content: string; timestamp: Date; level: HellogLevel; - meta: Record; + meta: Record; + error?: SerializedError; } diff --git a/lib/plugins.spec.ts b/lib/plugins.spec.ts index b77b502..2a62c1d 100644 --- a/lib/plugins.spec.ts +++ b/lib/plugins.spec.ts @@ -8,6 +8,7 @@ import { HellogLogFormatDefaultPlugin, HellogPrettyDefaultPlugin, } from './plugins.js'; +import { SerializedError } from './errors.js'; describe(HellogLogFormatDefaultPlugin.name, () => { it("should format a message's content into a log-formatted string", () => { @@ -149,7 +150,7 @@ describe(HellogColorizeDefaultPlugin.name, () => { }); it("should apply the correct color code depending on the message's level", () => { - const plugin = new HellogColorizeDefaultPlugin(); + const plugin = new HellogColorizeDefaultPlugin({ enabled: true }); const baseSource = { meta: { foo: 'bar' }, timestamp: new Date('2021-01-01T00:00:00Z'), @@ -247,3 +248,66 @@ describe(HellogLineBreakDefaultPlugin.name, () => { assert.strictEqual(message2.content, 'This is a new line.'); }); }); + +describe('HellogColorizeDefaultPlugin TTY detection', () => { + it('should suppress ANSI when enabled=false', () => { + const plugin = new HellogColorizeDefaultPlugin({ enabled: false }); + const source = { + level: HellogLevel.ERROR, + meta: {}, + timestamp: new Date(), + content: 'oops', + }; + const [result] = plugin.format([source]); + assert.ok(result); + assert.strictEqual(result.content, 'oops'); + }); + + it('should emit ANSI when enabled=true regardless of TTY', () => { + const plugin = new HellogColorizeDefaultPlugin({ enabled: true }); + const source = { + level: HellogLevel.ERROR, + meta: {}, + timestamp: new Date(), + content: 'oops', + }; + const [result] = plugin.format([source]); + assert.ok(result); + assert.strictEqual(result.content, '\x1b[31moops\x1b[0m'); + }); +}); + +describe('HellogLogFormatDefaultPlugin structured meta', () => { + it('should stringify non-string meta values', () => { + const plugin = new HellogLogFormatDefaultPlugin(); + const source = { + level: HellogLevel.INFO, + meta: { count: 42, active: true, tags: ['a', 'b'] }, + timestamp: new Date('2021-01-01T00:00:00Z'), + content: 'msg', + }; + const [result] = plugin.format([source]); + assert.ok(result); + assert.ok(result.content.includes('count="42"')); + assert.ok(result.content.includes('active="true"')); + assert.ok(result.content.includes('tags="["a","b"]"')); + }); +}); + +describe('HellogJsonDefaultPlugin error field', () => { + it('should include error object when present on message', () => { + const plugin = new HellogJsonDefaultPlugin(); + const err: SerializedError = { name: 'Error', message: 'boom', stack: 'Error: boom\n at ...' }; + const source = { + level: HellogLevel.ERROR, + meta: {}, + timestamp: new Date('2021-01-01T00:00:00Z'), + content: 'Error: boom', + error: err, + }; + const [result] = plugin.format([source]); + assert.ok(result); + const parsed = JSON.parse(result.content) as Record; + assert.deepStrictEqual(parsed['error'], err); + }); +}); diff --git a/lib/plugins.ts b/lib/plugins.ts index 6e6d7bc..ec0936e 100644 --- a/lib/plugins.ts +++ b/lib/plugins.ts @@ -1,5 +1,6 @@ import { HellogLevel } from './levels.js'; import { HellogMessage } from './messages.js'; +import { SerializedError } from './errors.js'; /* node:coverage disable */ export abstract class HellogPlugin { @@ -98,7 +99,15 @@ export class HellogLogFormatDefaultPlugin extends HellogPlugin { [levelKey]: message.level, [messageKey]: message.content, }; - for (const key in message.meta) data[key] = message.meta[key]!; + for (const key in message.meta) { + const v = message.meta[key]; + data[key] = + v === null || v === undefined + ? '' + : typeof v === 'object' + ? JSON.stringify(v) + : String(v); + } let content = ''; for (const key in data) content += `${key}="${data[key]!}" `; @@ -116,10 +125,11 @@ export class HellogJsonDefaultPlugin extends HellogPlugin { const formatted: HellogMessage[] = []; for (const message of messages) { - const { meta, ...rest } = message; - const content: Record = rest; + const { meta, error, ...rest } = message; + const content: Record = { ...rest }; for (const key in meta) content[key] = meta[key]; + if (error) content['error'] = error as SerializedError; formatted.push({ ...message, content: JSON.stringify(content) }); } @@ -127,8 +137,33 @@ export class HellogJsonDefaultPlugin extends HellogPlugin { } } +interface HellogColorizeDefaultPluginOptions { + /** + * Override auto-detection. `true` forces ANSI on, `false` forces off. + * Default: auto (enabled when stdout is a TTY and NO_COLOR is unset). + */ + enabled?: boolean; +} + +function _colorEnabled(options: HellogColorizeDefaultPluginOptions | undefined): boolean { + if (options?.enabled !== undefined) return options.enabled; + if (process.env['FORCE_COLOR'] === '1') return true; + if (process.env['FORCE_COLOR'] === '0') return false; + if (process.env['NO_COLOR'] !== undefined) return false; + return process.stdout.isTTY === true; +} + export class HellogColorizeDefaultPlugin extends HellogPlugin { + private readonly options: HellogColorizeDefaultPluginOptions | undefined; + + constructor(options?: HellogColorizeDefaultPluginOptions) { + super(); + this.options = options; + } + override format(messages: HellogMessage[]): HellogMessage[] { + if (!_colorEnabled(this.options)) return messages; + const formatted: HellogMessage[] = []; for (const message of messages) { From 9518991cdee90535f5ffbb038fc3e8164181dcf0 Mon Sep 17 00:00:00 2001 From: Maxence Lecanu Date: Sun, 28 Jun 2026 00:55:24 +0200 Subject: [PATCH 2/2] refactor(plugins): make colorize enablement a getter Address review feedback: fold the standalone _colorEnabled function into a private getter on HellogColorizeDefaultPlugin. Co-Authored-By: Claude Opus 4.8 --- lib/plugins.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/plugins.ts b/lib/plugins.ts index ec0936e..017c1ea 100644 --- a/lib/plugins.ts +++ b/lib/plugins.ts @@ -145,14 +145,6 @@ interface HellogColorizeDefaultPluginOptions { enabled?: boolean; } -function _colorEnabled(options: HellogColorizeDefaultPluginOptions | undefined): boolean { - if (options?.enabled !== undefined) return options.enabled; - if (process.env['FORCE_COLOR'] === '1') return true; - if (process.env['FORCE_COLOR'] === '0') return false; - if (process.env['NO_COLOR'] !== undefined) return false; - return process.stdout.isTTY === true; -} - export class HellogColorizeDefaultPlugin extends HellogPlugin { private readonly options: HellogColorizeDefaultPluginOptions | undefined; @@ -161,8 +153,16 @@ export class HellogColorizeDefaultPlugin extends HellogPlugin { this.options = options; } + private get colorEnabled(): boolean { + if (this.options?.enabled !== undefined) return this.options.enabled; + if (process.env['FORCE_COLOR'] === '1') return true; + if (process.env['FORCE_COLOR'] === '0') return false; + if (process.env['NO_COLOR'] !== undefined) return false; + return process.stdout.isTTY === true; + } + override format(messages: HellogMessage[]): HellogMessage[] { - if (!_colorEnabled(this.options)) return messages; + if (!this.colorEnabled) return messages; const formatted: HellogMessage[] = [];