Logging
@rudderjs/log is the framework's structured logger. It supports multiple channels, RFC 5424 log levels, line and JSON formatters, and a test fake. Once registered, unhandled exceptions automatically route to your default channel — no extra wiring.
Setup
Add a logging config:
// config/log.ts — registered under the `log` key in config/index.ts
import { resolve } from 'node:path'
export default {
default: 'stack',
channels: {
console: { driver: 'console', level: 'debug' },
file: { driver: 'single', path: resolve('storage/logs/app.log'), level: 'info' },
daily: { driver: 'daily', path: resolve('storage/logs/app.log'), level: 'info', days: 14 },
stack: { driver: 'stack', channels: ['console', 'file'] },
null: { driver: 'null' },
},
}Register the provider:
// bootstrap/providers.ts
import { LogProvider } from '@rudderjs/log'
export default [
...(await defaultProviders()),
LogProvider,
]LogProvider is auto-discovered when @rudderjs/log is installed, so the explicit import is only needed when you want to skip auto-discovery. Either way, registering it wires the global exception reporter — unhandled errors flow through your default channel automatically.
Writing logs
The Log facade exposes one method per RFC 5424 level. Each takes a message and an optional context object:
import { Log } from '@rudderjs/log'
Log.emergency('System unusable')
Log.alert('Action required immediately')
Log.critical('Critical condition', { service: 'payments' })
Log.error('Payment failed', { orderId: 123, gateway: 'stripe' })
Log.warning('Deprecated API used', { endpoint: '/v1/users' })
Log.notice('Significant event', { userId: 42 })
Log.info('User logged in', { userId: 42 })
Log.debug('Processing item', { itemId: 7 })
Log.log('info', 'Custom level call', { ... })Context objects are serialized into the log entry. Stick to JSON-safe primitives so file-backed channels render cleanly.
Channels and stacks
Channels are independent log destinations — each has its own driver, minimum level, and formatter. Pick one explicitly with Log.channel(...) or fan out with Log.stack([...]):
Log.channel('file').info('Written to the file channel only')
Log.stack(['console', 'file']).error('Sent to both channels')The stack driver is a static fan-out you configure once. Use Log.stack([...]) for ad-hoc fan-outs that don't deserve their own config entry.
| Level | Severity |
|---|---|
emergency |
0 |
alert |
1 |
critical |
2 |
error |
3 |
warning |
4 |
notice |
5 |
info |
6 |
debug |
7 |
Each channel has a minimum level — messages below it are silently dropped.
Drivers
console— writes to stdout/stderr with ANSI colors. Errors (severity ≤error) go to stderr.single— appends to one log file. Creates parent directories automatically.daily— daily-rotated files with configurable retention. Old files beyonddaysare cleaned up automatically. Files look likeapp-2026-04-06.log.stack— fan-out to a list ofchannels. SetignoreExceptions: trueto swallow errors from sub-channels.null— discard everything. Useful for tests and silenced environments.
Every driver accepts a formatter option: 'line' (default) or 'json'.
[2026-04-06T12:00:00.000Z] console.INFO User logged in {"userId":42}{"timestamp":"2026-04-06T12:00:00.000Z","channel":"console","level":"info","message":"User logged in","context":{"userId":42}}Context propagation
Add context to the default channel for the rest of the request:
Log.withContext({ requestId: 'abc-123' })
Log.info('Processing') // context includes requestId
Log.withoutContext(['requestId'])For context that should appear in every log entry across every channel (environment, app version, deploy id), use shared context:
Log.shareContext({ environment: 'production', deploy: 'v1.4.2' })
Log.flushSharedContext() // clear itContext merges in three layers: shared context → per-channel context → per-call context.
Custom drivers
Register your own driver with extendLog():
import { extendLog } from '@rudderjs/log'
import type { LogAdapter, LogEntry } from '@rudderjs/log'
class SentryAdapter implements LogAdapter {
constructor(private readonly dsn: string) {}
async log(entry: LogEntry): Promise<void> {
await sendToSentry(this.dsn, entry)
}
}
extendLog('sentry', (config) => new SentryAdapter(config['dsn'] as string))channels: {
sentry: { driver: 'sentry', level: 'error', dsn: Env.get('SENTRY_DSN', '') },
}For exception-only routing to a tracker, prefer setExceptionReporter() (see Error Handling).
Listeners
Subscribe to every entry as it's emitted:
Log.listen((entry) => {
if (entry.level === 'error') sendToErrorTracker(entry)
})Listeners fire when the entry is created, before it is dispatched to the channel's adapter. Use them for cross-cutting concerns (alerting, derived metrics, audit trails).
Testing
Swap the default channel with LogFake to capture entries:
import { LogFake, LogRegistry } from '@rudderjs/log'
const fake = new LogFake()
LogRegistry.register('test', fake)
LogRegistry.setDefault('test')
await runCodeThatLogs()
fake.assertLogged('info', 'User logged in')
fake.assertNotLogged('error')
fake.assertLoggedTimes('info', 2)
fake.assertNothingLogged()
fake.clear()Pitfalls
- Stack channels referencing missing channels. Stacks resolve at boot; a bad name throws
Stack references unknown channel "X". - File-driver async surprises.
consolewrites synchronously;singleanddailyare async. If you need a guaranteed flush before exit, await the last call in your shutdown hook. - Context across requests.
Log.withContext(...)writes to the default channel's mutable context. In a long-running process this leaks across requests — for per-request context, use shared context plusLog.flushSharedContext()in a request-finish middleware, or pass context per call.