Logger
Architecture
The logger uses a transport-pool pattern. LoggerService dispatches log entries to all registered transports. Each transport filters by channel and log level, so different transports can handle different types of logs independently.
LoggerService.info('request processed', { statusCode: 200 })
│
├── ConsoleTransport (channels: ['default'], minLevel: 'info') ✓ matches
├── FileTransport (channels: ['audit'], minLevel: 'debug') ✗ wrong channel
└── FileTransport (channels: '*', minLevel: 'error') ✗ level too lowLog Levels
| Level | Severity | Description |
|---|---|---|
debug | 0 | Diagnostic detail |
info | 1 | Normal operations |
warn | 2 | Potential issues |
error | 3 | Failures |
Transports only receive entries at or above their minLevel.
Setup
import { LoggerModule } from '@modularityjs/logger';
import { LoggerConsoleModule } from '@modularityjs/logger-console';
import { LoggerFileModule } from '@modularityjs/logger-file';
const modules = [
LoggerModule,
LoggerConsoleModule,
// Add file transports as needed:
LoggerFileModule.forRoot({
path: './logs/app.log',
channels: ['default'],
minLevel: 'info',
}),
];Usage
Scoped Loggers
Use LoggerFactory.create(scope) for a logger with a scope label on the default channel:
import { Inject, Injectable } from '@modularityjs/di';
import { LoggerFactory, LoggerService } from '@modularityjs/logger';
@Injectable()
class OrderService {
private readonly logger: LoggerService;
constructor(@Inject(LoggerFactory) factory: LoggerFactory) {
this.logger = factory.create('order-service');
}
placeOrder(orderId: string) {
this.logger.info('Order placed', { orderId });
// Output: 2026-04-11T12:00:00.000Z [INFO] [order-service] Order placed {"orderId":"ORD-1"}
}
}Named Channels
Use LoggerFactory.createChannel(channel) to route logs to specific transports:
@Injectable()
class PaymentService {
private readonly logger: LoggerService;
constructor(@Inject(LoggerFactory) factory: LoggerFactory) {
this.logger = factory.createChannel('payment', 'payment-service');
}
charge(amount: number) {
this.logger.info('Charge processed', { amount });
// Only transports with channels: ['payment'] or channels: '*' receive this
}
}Logging Methods
logger.debug(message, context?) // Diagnostic detail
logger.info(message, context?) // Normal operations
logger.warn(message, context?) // Potential issues
logger.error(message, context?) // Failurescontext is an optional Record<string, unknown> included as structured data.
Redaction
Sensitive context keys are redacted before any transport sees the entry — a leaked credential in a log file is a breach regardless of which transport wrote it. Matching is by normalized key (lowercased, _/- stripped): a key is redacted when its normalized form contains any entry of redactKeys, so password, passwordHash, api_key, and Set-Cookie are all caught by the defaults. Redaction walks nested plain objects and arrays; only context is redacted, never message.
LoggerModule.forRoot({
redactKeys: ['password', 'secret', 'token', 'ssn'], // extend or replace
redactedValue: '[REDACTED]',
});| Option | Default | Description |
|---|---|---|
redactKeys | ['apikey', 'authorization', 'cookie', 'credential', 'passphrase', 'password', 'privatekey', 'secret', 'setcookie', 'token'] | Normalized-substring matches; [] disables |
redactedValue | '[REDACTED]' | Replacement value |
Ambient Context
LogContextProvidersPool lets modules contribute fields that are merged into every log entry at dispatch time — request id, tenant, trace id — without threading them through call sites:
import { Injectable } from '@modularityjs/di';
import { LogContextProvidersPool, LoggerModule } from '@modularityjs/logger';
import type { LogContextProvider } from '@modularityjs/logger';
import { Module } from '@modularityjs/modularity';
@Injectable()
class TenantLogContextProvider implements LogContextProvider {
provide(): Record<string, unknown> | undefined {
const tenantId = readAmbientTenantId(); // e.g. an AsyncLocalStorage read
return tenantId ? { tenantId } : undefined;
}
}
@Module({
name: 'tenant-log-context',
imports: [LoggerModule],
providers: [TenantLogContextProvider],
pools: [
{
pool: LogContextProvidersPool,
key: 'tenant',
useClass: TenantLogContextProvider,
},
],
})
class TenantLogContextModule {}provide() runs on every log call — keep it synchronous and cheap (an AsyncLocalStorage read, not I/O). Explicit entry fields win on key collision; redaction runs after the merge; a provider that throws is swallowed rather than taking logging down.
Request-Id Correlation (@modularityjs/http-request-id-logger)
The ready-made bridge between http-request-id and the logger: wire HttpRequestIdLoggerModule and every log line emitted while handling a request carries { requestId } from the ambient request context — the same id returned in the X-Request-Id response header, so a user-reported id greps straight to the request's full log trail. Outside a request (boot, CLI, queue consumers) the provider contributes nothing.
import { HttpRequestIdLoggerModule } from '@modularityjs/http-request-id-logger';
import { HttpRequestIdModule } from '@modularityjs/http-request-id';
const modules = [
// ...HttpModule, HttpFastifyModule, LoggerModule, LoggerConsoleModule...
HttpRequestIdModule,
HttpRequestIdLoggerModule,
];Transports
Console (@modularityjs/logger-console)
Writes to stdout (debug/info/warn) and stderr (error).
import { LoggerConsoleModule } from '@modularityjs/logger-console';
const modules = [
LoggerModule,
LoggerConsoleModule, // channels: ['default'], minLevel: 'debug'
];Default configuration: logs the default channel at debug level and above.
File (@modularityjs/logger-file)
Appends to files on disk. The factory validates its config at wiring time (non-empty path, known minLevel, positive-integer maxBufferLines — the cap on buffered-but-unwritten lines, default 100 000), and the module probes each log path at boot (mkdir + append), so a misconfigured path fails boot instead of silently discarding every log line. Runtime write failures still warn without crashing.
import { LoggerFileModule } from '@modularityjs/logger-file';Three registration methods:
// Log specific channels to a file
LoggerFileModule.forRoot({
path: './logs/app.log',
channels: ['default', 'http'],
minLevel: 'info',
});
// Log a single channel to its own file
LoggerFileModule.forChannel('audit', {
path: './logs/audit.log',
minLevel: 'debug',
});
// Log all channels to one file (wildcard)
LoggerFileModule.forAll({
path: './logs/all.log',
minLevel: 'warn',
});OpenTelemetry (@modularityjs/logger-telemetry)
Registers OtelLogTransport, which attaches each log entry as an addEvent on the active OTEL span (no-op when no span is active). Wildcard channel, debug minLevel — runs in parallel with console/file transports.
Custom Transports
Extend LogTransport and register via the LogTransportPool:
import { Injectable } from '@modularityjs/di';
import { LogTransport, LogTransportPool } from '@modularityjs/logger';
import type { LogEntry, LogLevel } from '@modularityjs/logger';
import { Module } from '@modularityjs/modularity';
@Injectable()
class SlackTransport extends LogTransport {
readonly channels = '*' as const;
readonly minLevel: LogLevel = 'error';
log(entry: LogEntry) {
// Send to Slack webhook...
}
}
@Module({
name: 'logger-slack',
imports: [LoggerModule],
providers: [SlackTransport],
pools: [{ pool: LogTransportPool, key: 'slack', useClass: SlackTransport }],
})
class LoggerSlackModule {}Log Entry Structure
Every log entry has the following shape:
interface LogEntry {
readonly level: LogLevel;
readonly message: string;
readonly context?: Record<string, unknown>;
readonly channel: string;
readonly scope?: string;
readonly timestamp: Date;
}