Skip to content

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 low

Log Levels

LevelSeverityDescription
debug0Diagnostic detail
info1Normal operations
warn2Potential issues
error3Failures

Transports only receive entries at or above their minLevel.

Setup

typescript
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:

typescript
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:

typescript
@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

typescript
logger.debug(message, context?)   // Diagnostic detail
logger.info(message, context?)    // Normal operations
logger.warn(message, context?)    // Potential issues
logger.error(message, context?)   // Failures

context 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.

typescript
LoggerModule.forRoot({
  redactKeys: ['password', 'secret', 'token', 'ssn'], // extend or replace
  redactedValue: '[REDACTED]',
});
OptionDefaultDescription
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:

typescript
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.

typescript
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).

typescript
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.

typescript
import { LoggerFileModule } from '@modularityjs/logger-file';

Three registration methods:

typescript
// 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:

typescript
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:

typescript
interface LogEntry {
  readonly level: LogLevel;
  readonly message: string;
  readonly context?: Record<string, unknown>;
  readonly channel: string;
  readonly scope?: string;
  readonly timestamp: Date;
}