Skip to content
78 changes: 78 additions & 0 deletions src/__tests__/plugin.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* - Logger.use() and Logger.unuse()
*/

import { LogixiaLogger } from '../core/logitron-logger';
import type { LogixiaPlugin } from '../plugin';
import { globalPluginRegistry, PluginRegistry, usePlugin } from '../plugin';
import type { LogEntry } from '../types';
Expand Down Expand Up @@ -260,6 +261,38 @@ describe('PluginRegistry', () => {
expect(result!.payload?.step).toBe(1);
expect(result!.payload?.step2).toBe(2);
});

it('isolates a throwing onLog plugin and keeps processing (no crash)', async () => {
registry.register({
name: 'thrower',
onLog() {
throw new Error('plugin blew up');
},
});
registry.register({
name: 'after',
onLog(e) {
return { ...e, payload: { ...e.payload, reached: true } };
},
});
const entry = makeEntry();
// A buggy plugin must not crash logging; the chain continues with the
// un-transformed entry, and later plugins still run.
const result = await registry.runOnLog(entry);
expect(result).not.toBeNull();
expect(result!.payload?.reached).toBe(true);
});

it('isolates a rejecting async onLog plugin', async () => {
registry.register({
name: 'async-thrower',
async onLog() {
await Promise.resolve();
throw new Error('async plugin blew up');
},
});
await expect(registry.runOnLog(makeEntry())).resolves.not.toBeNull();
});
});

// ── runOnError ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -425,3 +458,48 @@ describe('Full plugin lifecycle', () => {
expect(events).toEqual(['init', 'log', 'error', 'shutdown']);
});
});

// ── Logger ↔ plugin integration ───────────────────────────────────────────────

describe('Logger plugin integration', () => {
const BASE = {
appName: 'PluginIT',
environment: 'test' as const,
format: { timestamp: false, colorize: false, json: false },
traceId: false,
levelOptions: { level: 'info' as const },
};

it('invokes a plugin onError hook when a transport write fails', async () => {
const logger = new LogixiaLogger({ ...BASE, transports: { console: { format: 'json' } } });
const seen: Error[] = [];
logger.use({
name: 'err-capture',
onError(e) {
seen.push(e);
},
});

// Force the transport write to fail.
const tm = (logger as unknown as { transportManager: { write: () => Promise<void> } })
.transportManager;
const boom = new Error('transport exploded');
tm.write = () => Promise.reject(boom);

await logger.info('will fail to write');

expect(seen).toHaveLength(1);
expect(seen[0]).toBe(boom);
});

it('a throwing onLog plugin does not crash a logger.info call', async () => {
const logger = new LogixiaLogger({ ...BASE });
logger.use({
name: 'bad',
onLog() {
throw new Error('plugin crash');
},
});
await expect(logger.info('still works')).resolves.toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions src/core/__tests__/logixia-logger.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,22 @@ describe('Child logger', () => {
expect(parsed.context).toBe('svc');
});

it('child logger merges its context data into every log payload', async () => {
const out = spyOutput();
const parent = new LogixiaLogger({
...BASE_CONFIG,
format: { json: true },
levelOptions: { level: 'info' },
});
const child = parent.child('svc', { serviceVersion: '2.0', region: 'us-east' });
await child.info('child msg', { adHoc: true });
out.restore();
const parsed = JSON.parse(out.joined());
expect(parsed.payload.serviceVersion).toBe('2.0');
expect(parsed.payload.region).toBe('us-east');
expect(parsed.payload.adHoc).toBe(true);
});

it('child logger does not affect parent context', () => {
const parent = new LogixiaLogger({ ...BASE_CONFIG });
parent.child('child-ctx');
Expand Down Expand Up @@ -587,6 +603,46 @@ describe('setLevel', () => {
});
});

// ── Redaction integration (autoDetect-only config must apply) ─────────────────

describe('redaction applied through the logger', () => {
it('applies autoDetect redaction even with no explicit paths/patterns (BUG 1)', async () => {
const out = spyOutput();
const logger = new LogixiaLogger({
...BASE_CONFIG,
environment: 'production',
format: { json: true, colorize: false, timestamp: false },
redact: { autoDetect: 'aggressive' },
});
await logger.info('t', {
password: 'hunter2',
email: 'x@y.com',
jwt: 'eyJhbGciOiJ.aaa.bbb',
});
out.restore();
const joined = out.joined();
expect(joined).not.toContain('hunter2');
expect(joined).not.toContain('x@y.com');
expect(joined).not.toContain('eyJhbGciOiJ.aaa.bbb');
expect(joined).toContain('[REDACTED]');
});

it('redacts a top-level password key via conservative autoDetect (BUG 1 + BUG 2)', async () => {
const out = spyOutput();
const logger = new LogixiaLogger({
...BASE_CONFIG,
format: { json: true, colorize: false, timestamp: false },
redact: { autoDetect: 'conservative' },
});
await logger.info('t', { password: 'hunter2', keep: 'visible' });
out.restore();
const joined = out.joined();
expect(joined).not.toContain('hunter2');
expect(joined).toContain('visible');
expect(joined).toContain('[REDACTED]');
});
});

// ── Error object logging ──────────────────────────────────────────────────────

describe('error() with Error object', () => {
Expand Down
66 changes: 41 additions & 25 deletions src/core/logitron-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,15 @@ export class LogixiaLogger<
const resolvedLevel = resolveInitialLevel(this.config);
this.config.levelOptions = { ...this.config.levelOptions, level: resolvedLevel };

if (!this.config.fields) {
this.config.fields = {
timestamp: '[yyyy-mm-dd HH:MM:ss.MS]',
level: '[log_level]',
appName: '[app_name]',
traceId: '[trace_id]',
message: '[message]',
payload: '[payload]',
timeTaken: '[time_taken_MS]',
};
}
this.config.fields ??= {
timestamp: '[yyyy-mm-dd HH:MM:ss.MS]',
level: '[log_level]',
appName: '[app_name]',
traceId: '[trace_id]',
message: '[message]',
payload: '[payload]',
timeTaken: '[time_taken_MS]',
};

this.context = context ?? '';

Expand Down Expand Up @@ -272,7 +270,7 @@ export class LogixiaLogger<
if (!shutdownCfg) return;

const normalized: GracefulShutdownConfig =
shutdownCfg === true ? { enabled: true } : (shutdownCfg as GracefulShutdownConfig);
shutdownCfg === true ? { enabled: true } : shutdownCfg;

if (!normalized.enabled) return;

Expand Down Expand Up @@ -306,9 +304,7 @@ export class LogixiaLogger<
*/
private _buildPerfCaches(): void {
// 1. Level value map β€” pre-merge built-ins + custom levels once
const customLevelEntries = Object.entries(
(this.config.levelOptions?.levels ?? {}) as Record<string, number>
);
const customLevelEntries = Object.entries(this.config.levelOptions?.levels ?? {});
this._levelValues = new Map<string, number>([
[LogLevel.ERROR, 0],
[LogLevel.WARN, 1],
Expand Down Expand Up @@ -356,10 +352,10 @@ export class LogixiaLogger<
for (const f of fieldNames) {
if (this.fieldState.has(f)) {
this._fieldCache.set(f, this.fieldState.get(f)!);
} else if (this.config.fields?.[f as keyof typeof this.config.fields] !== undefined) {
this._fieldCache.set(f, this.config.fields[f as keyof typeof this.config.fields] !== false);
} else {
} else if (this.config.fields?.[f as keyof typeof this.config.fields] === undefined) {
this._fieldCache.set(f, true);
} else {
this._fieldCache.set(f, this.config.fields[f as keyof typeof this.config.fields] !== false);
}
}

Expand Down Expand Up @@ -397,19 +393,23 @@ export class LogixiaLogger<

// 5. Pre-computed "[appName] " string (gray when colorize is on)
const appNameRaw = `[${this.config.appName ?? 'App'}]`;
if (this._fieldCache.get('appName') !== false) {
if (this._fieldCache.get('appName') === false) {
this._formattedAppName = '';
} else {
this._formattedAppName = colorize
? `${this._colorMap.get('gray')!}${appNameRaw}${this._colorMap.get('reset')!} `
: `${appNameRaw} `;
} else {
this._formattedAppName = '';
}

// 6. Redact flag β€” if no redact config, skip applyRedaction entirely
// 6. Redact flag β€” if no redact config, skip applyRedaction entirely.
// autoDetect injects built-in PII paths/patterns at redaction time, so
// it must flip the flag on even with no explicit paths/patterns set β€”
// otherwise autoDetect-only configs silently leak PII.
this._hasRedact = !!(
this.config.redact &&
((this.config.redact.paths?.length ?? 0) > 0 ||
(this.config.redact.patterns?.length ?? 0) > 0)
(this.config.redact.patterns?.length ?? 0) > 0 ||
!!this.config.redact.autoDetect)
);
}

Expand Down Expand Up @@ -491,7 +491,7 @@ export class LogixiaLogger<

setLevel(level: LogLevelString): void {
this.config.levelOptions = this.config.levelOptions ?? {};
this.config.levelOptions.level = level as string;
this.config.levelOptions.level = level;
// Refresh the cached numeric threshold so shouldLog() stays accurate
this._minLevelValue = this._levelValues.get(level) ?? this._minLevelValue;
// Rebuild level string cache in case colours are keyed per-level
Expand Down Expand Up @@ -595,7 +595,14 @@ export class LogixiaLogger<

child(context: string, data?: Record<string, unknown>): ILogger {
const childLogger = new LogixiaLogger(this.config, context);
if (data) childLogger.contextData = { ...this.contextData, ...data };
// Inherit the parent's bound fields plus any child-specific data. The
// hot-path merge in log() is gated on _hasContextData, so it must be set
// here or the merged fields are silently dropped from every log.
const mergedContextData = { ...this.contextData, ...data };
if (Object.keys(mergedContextData).length > 0) {
childLogger.contextData = mergedContextData;
childLogger._hasContextData = true;
}
return childLogger;
}

Expand Down Expand Up @@ -834,6 +841,15 @@ export class LogixiaLogger<
return;
} catch (error) {
internalError('Transport write failed', error);
// Notify plugin onError hooks (e.g. Sentry/alerting). runOnError
// swallows hook errors internally, so this can never re-throw.
if (this._pluginRegistry.size > 0) {
await this._pluginRegistry.runOnError(
error instanceof Error ? error : new Error(String(error)),
entry
);
}
// Fall through to the stdout/stderr fallback so the log is not lost.
}
}

Expand Down
17 changes: 14 additions & 3 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface LogixiaPlugin {
*
* - Return the entry (modified or unchanged) to continue processing.
* - Return `null` to **drop** the entry β€” no transport will receive it.
*
* Errors thrown inside `onLog` are swallowed so a buggy plugin cannot crash
* logging; the chain continues with the previous (un-transformed) entry.
*/
onLog?(entry: LogEntry): LogEntry | null | Promise<LogEntry | null>;

Expand Down Expand Up @@ -117,11 +120,19 @@ export class PluginRegistry {
* (possibly transformed) entry.
*/
async runOnLog(entry: LogEntry): Promise<LogEntry | null> {
let current: LogEntry | null = entry;
let current: LogEntry = entry;
for (const plugin of this._plugins) {
if (!plugin.onLog) continue;
current = await plugin.onLog(current);
if (current === null) return null;
let next: LogEntry | null;
try {
next = await plugin.onLog(current);
} catch {
// Isolate buggy plugins: a throwing onLog must not crash logging.
// Skip this plugin and continue the chain with the un-transformed entry.
continue;
}
if (next === null) return null;
current = next;
}
return current;
}
Expand Down
Loading
Loading