Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
108 changes: 108 additions & 0 deletions lib/hellog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
HellogPrettyDefaultPlugin,
HellogStdoutDefaultPlugin,
} from './plugins.js';
import { SerializedError } from './errors.js';

class StoredLogsTransportPlugin extends HellogPlugin {
readonly logs: HellogMessage[] = [];
Expand Down Expand Up @@ -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);
});
});
100 changes: 83 additions & 17 deletions lib/hellog.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,7 +13,7 @@ import {
interface HellogOptions {
level?: HellogLevel;
plugins?: HellogPlugin[];
meta?: Record<string, string> | (() => Record<string, string>);
meta?: Record<string, unknown> | (() => Record<string, unknown>);
}

/**
Expand Down Expand Up @@ -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<string, bigint>();

constructor(options?: HellogOptions) {
this.options = options;
Expand All @@ -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];
}

/**
Expand All @@ -85,23 +98,76 @@ 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<string, unknown> | (() => Record<string, unknown>)): 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<string, unknown> | (() => Record<string, unknown>) | undefined,
): Record<string, unknown> {
if (!meta) return {};
if (typeof meta === 'function') return meta();
return meta;
}

private _log(data: unknown[], level: HellogLevel, extraMeta?: Record<string, unknown>): void {
if (HellogLevelOrder[level] < HellogLevelOrder[this.maxLevel]) return;

let metaObject: Record<string, string>;
const meta = this.options?.meta;
if (meta && typeof meta === 'function') {
metaObject = meta();
} else {
metaObject = meta ?? {};
}
const metaObject: Record<string, unknown> = {
...Hellog._resolveMeta(this.options?.meta),
...extraMeta,
};

const err = data.find((d): d is Error => d instanceof Error);
const serialized = serializeError(err);

let messages: HellogMessage[] = [
{
content: format(...data),
timestamp: new Date(),
level,
meta: metaObject,
...(serialized !== undefined ? { error: serialized } : {}),
},
];

Expand Down
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './errors.js';
export * from './hellog.js';
export * from './levels.js';
export * from './messages.js';
Expand Down
4 changes: 3 additions & 1 deletion lib/messages.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
meta: Record<string, unknown>;
error?: SerializedError;
}
66 changes: 65 additions & 1 deletion lib/plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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<string, unknown>;
assert.deepStrictEqual(parsed['error'], err);
});
});
Loading
Loading