Skip to content

feature: logger hook functions #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 6, 2025
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
77 changes: 69 additions & 8 deletions src/context-logger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ describe('ContextLogger', () => {

beforeEach(() => {
jest.clearAllMocks();

jest.spyOn(ContextStore, 'getContext').mockReturnValue(CONTEXT);

mockLogger = {
log: spyLog,
debug: spyDebug,
Expand All @@ -29,7 +29,7 @@ describe('ContextLogger', () => {
// Reset the internal logger for each test to ensure clean state
(ContextLogger as any).internalLogger = null;
ContextLogger.init(mockLogger);

contextLogger = new ContextLogger(MODULE_NAME);
});

Expand Down Expand Up @@ -59,7 +59,7 @@ describe('ContextLogger', () => {

expect(mockLogger.log).toHaveBeenCalledWith(CONTEXT, message, MODULE_NAME);
});

it('should call log method when info is called', () => {
const message = 'Test message';
const bindings = { someBinding: 'value' };
Expand Down Expand Up @@ -245,7 +245,7 @@ describe('ContextLogger', () => {
// Reset the internal logger for each test to ensure clean state
(ContextLogger as any).internalLogger = null;
});

it('should group only bindings when bindingsKey provided', () => {
const logger = new ContextLogger(MODULE_NAME);
ContextLogger.init(mockLogger, { groupFields: { bindingsKey: 'params' } });
Expand Down Expand Up @@ -318,7 +318,7 @@ describe('ContextLogger', () => {
// Reset the internal logger for each test to ensure clean state
(ContextLogger as any).internalLogger = null;
});

it('should group both bindings and context under specified keys', () => {
const logger = new ContextLogger(MODULE_NAME);
ContextLogger.init(mockLogger, {
Expand Down Expand Up @@ -369,7 +369,7 @@ describe('ContextLogger', () => {
// Reset the internal logger for each test to ensure clean state
(ContextLogger as any).internalLogger = null;
});

it('should adapt context when adapter is provided', () => {
const logger = new ContextLogger(MODULE_NAME);
ContextLogger.init(mockLogger, {
Expand Down Expand Up @@ -413,7 +413,7 @@ describe('ContextLogger', () => {
// Reset the internal logger for each test to ensure clean state
(ContextLogger as any).internalLogger = null;
});

it('should ignore bootstrap logs when ignoreBootstrapLogs is true', () => {
const logger = new ContextLogger(MODULE_NAME);
ContextLogger.init(mockLogger, { ignoreBootstrapLogs: true });
Expand Down Expand Up @@ -452,4 +452,65 @@ describe('ContextLogger', () => {
);
});
});

describe('hook functions', () => {
it('should call hook function when provided', () => {
const logger = new ContextLogger(MODULE_NAME);
const logMessage = 'Test message';
const bindings = { someBinding: 'value' };
const hookSpy = jest.fn();

ContextLogger.init(mockLogger, {
hooks: {
log: [hookSpy],
},
});

logger.log(logMessage, bindings);

expect(hookSpy).toHaveBeenCalledWith(logMessage, { ...bindings, ...CONTEXT });
});

it('should call multiple hook function when provided', () => {
const logger = new ContextLogger(MODULE_NAME);
const logMessage = 'Test message';
const bindings = { someBinding: 'value' };
const hookSpy1 = jest.fn();
const hookSpy2 = jest.fn();
const hookSpy3 = jest.fn();

ContextLogger.init(mockLogger, {
hooks: {
log: [hookSpy1, hookSpy2, hookSpy3],
},
});

logger.log(logMessage, bindings);

expect(hookSpy1).toHaveBeenCalledWith(logMessage, { ...bindings, ...CONTEXT });
expect(hookSpy2).toHaveBeenCalledWith(logMessage, { ...bindings, ...CONTEXT });
expect(hookSpy3).toHaveBeenCalledWith(logMessage, { ...bindings, ...CONTEXT });
});

it('should call \'all\' hook function when provided', () => {
const logger = new ContextLogger(MODULE_NAME);
const logMessage = 'Test message';
const bindings = { someBinding: 'value' };
const hookSpy = jest.fn();
const allHookSpy = jest.fn();

ContextLogger.init(mockLogger, {
hooks: {
log: [hookSpy],
all: [allHookSpy],
},
});

logger.log(logMessage, bindings);
logger.debug(logMessage, bindings);

expect(hookSpy).toHaveBeenCalledTimes(1);
expect(allHookSpy).toHaveBeenCalledTimes(2);
});
});
});
27 changes: 18 additions & 9 deletions src/context-logger.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { Logger as NestLogger } from '@nestjs/common';
import { LogLevel, Logger as NestLogger } from '@nestjs/common';
import { Logger as NestJSPinoLogger } from 'nestjs-pino';
import { ContextStore } from './store/context-store';
import { omitBy, isNil, isEmpty } from 'lodash';
import { ContextLoggerFactoryOptions } from './interfaces/context-logger.interface';

type Bindings = Record<string, any>;

export interface LogEntry {
[key: string]: any;
err?: Error | string;
}
import { Bindings, LogEntry } from './types';

export class ContextLogger {
private static internalLogger: NestJSPinoLogger;
Expand Down Expand Up @@ -85,7 +79,7 @@ export class ContextLogger {
this.callInternalLogger('error', message, adaptedBindings, error);
}

private callInternalLogger(level: string, message: string, bindings: Bindings, error?: Error | string) {
private callInternalLogger(level: LogLevel, message: string, bindings: Bindings, error?: Error | string) {
// If it's a bootstrap log and ignoreBootstrapLogs is true, do nothing
if (typeof bindings === 'string' && ContextLogger.options?.ignoreBootstrapLogs) {
return;
Expand All @@ -99,6 +93,7 @@ export class ContextLogger {
logObject = this.createLogEntry(bindings, error);
}
const logger = ContextLogger.internalLogger ?? this.fallbackLogger;
this.callHooks(level, message, logObject);
return logger[level](logObject, ...[message, this.moduleName]);
}

Expand Down Expand Up @@ -133,4 +128,18 @@ export class ContextLogger {

return { [key]: obj };
}

private callHooks(level: LogLevel, message: string, bindings: Bindings): void {
const hooks = ContextLogger.options?.hooks;

if (!hooks) {
return;
}

[...(hooks[level] || []), ...(hooks['all'] || [])].forEach((hook) => {
hook(message, bindings);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand the purpose behind this, you want to be able to shove side effects right before the log is sent? But the hooks are set one time during app bootstrap, why not use the context interceptor to enrich the context and such new fields enrichment will be done automatically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn’t meant for enriching the logs themselves, but rather for triggering small side effects right before or after a log is sent (could be changed) - like tracking metrics or triggering alerts.

It’s meant as a lightweight runtime hook without needing to run the same block of code before/after each log, wrap logger functions or modify individual log calls.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as in "also send a sentry when an error is logged?"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, for example..

});

return;
}
}
34 changes: 30 additions & 4 deletions src/interfaces/context-logger.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ExecutionContext, ModuleMetadata } from '@nestjs/common';
import { Params } from 'nestjs-pino';
import { Bindings, LoggerHookKeys } from '../types';

export interface ContextLoggerFactoryOptions extends Params {
/**
Expand All @@ -8,14 +9,14 @@ export interface ContextLoggerFactoryOptions extends Params {
* Specify keys to group specific fields:
* - bindingsKey: Groups runtime bindings under this key
* - contextKey: Groups context data under this key
*
*
* @example
* // Group both bindings and context
* groupFields: { bindingsKey: 'params', contextKey: 'metadata' }
*
*
* // Group only bindings, spread context at root
* groupFields: { bindingsKey: 'params' }
*
*
* // Group only context, spread bindings at root
* groupFields: { contextKey: 'metadata' }
*/
Expand Down Expand Up @@ -43,7 +44,7 @@ export interface ContextLoggerFactoryOptions extends Params {
/**
* Optional function to transform the context before it is included in the log entry.
* Useful for filtering, renaming, or restructuring context data.
*
*
* @param context - The current context object
* @returns The transformed context object
*/
Expand All @@ -59,6 +60,31 @@ export interface ContextLoggerFactoryOptions extends Params {
enrichContext?: (
context: ExecutionContext
) => Record<string, any> | Promise<Record<string, any>>;

/**
* Optional hooks to execute when a log is created.
* These callbacks allow you to extend logging behavior, such as reporting to external systems,
* incrementing metrics, or performing side effects based on log level.
*
* ⚠️ Note: All callbacks are executed **synchronously and sequentially**.
* This means each hook will block the next one, potentially introducing latency
* to the logging process. Use with caution, especially when performing async-like tasks.
*
* @example
* hooks: {
* all: [
* (message: string, bindings: Bindings) => {
* // do something for all logs
* },
* ],
* error: [
* (message: string, bindings: Bindings) => {
* // do something specific for error logs
* },
* ],
* }
*/
hooks?: Partial<Record<LoggerHookKeys, Array<(message: string, bindings: Bindings) => void>>>;
}

export interface ContextLoggerAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LogLevel } from '@nestjs/common';

export type Bindings = Record<string, any>;

export interface LogEntry {
[key: string]: any;
err?: Error | string;
}

export type LoggerHookKeys = LogLevel | 'all';