@rawnodes/logger
v2.7.0
Published
High-performance Pino-based logger with AsyncLocalStorage context, O(1) level rules, and timing utilities
Maintainers
Readme
@rawnodes/logger
Flexible Winston-based logger with AsyncLocalStorage context, level rules, and multiple output formats.
Features
- Multiple Formats - JSON, plain (colored), logfmt, simple
- Context Propagation - Automatic context via AsyncLocalStorage
- Level Rules - Configure log levels per module/context in config
- Lazy Meta - Defer metadata creation until log level check passes
- Timing Utilities - Built-in performance measurement
- Request ID - Generate and extract request IDs
- Secret Masking - Automatic masking of sensitive data
- TypeScript First - Full generic type support
Installation
pnpm add @rawnodes/logger
# or
npm install @rawnodes/loggerQuick Start
import { Logger } from '@rawnodes/logger';
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
});
logger.info('Hello world');
logger.info('User logged in', { userId: 123 });Configuration
Basic Config
import { Logger } from '@rawnodes/logger';
const logger = Logger.create({
level: 'info', // default log level
console: { format: 'plain' }, // console output format
file: { // optional file output
format: 'json',
dirname: 'logs',
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: '14d',
maxSize: '20m',
},
});Level Rules
Configure different log levels for specific modules or contexts:
const logger = Logger.create({
level: {
default: 'info',
rules: [
{ match: { context: 'auth' }, level: 'debug' }, // debug for auth module
{ match: { context: 'database' }, level: 'warn' }, // warn for database
{ match: { userId: 123 }, level: 'debug' }, // debug for user 123
{ match: { context: 'api', userId: 456 }, level: 'silly' }, // combined match
],
},
console: { format: 'plain' },
});
const authLogger = logger.for('auth');
authLogger.debug('This will be logged'); // matches rule
const dbLogger = logger.for('database');
dbLogger.info('This will NOT be logged'); // level is warnRules from config are readonly and cannot be removed via API.
Output Formats
| Format | Description | Example |
|--------|-------------|---------|
| json | Structured JSON | {"level":"info","message":"hello","timestamp":"..."} |
| plain | Colored, human-readable | [2025-01-01T12:00:00] info [APP] hello |
| logfmt | Key=value pairs | level=info msg=hello context=APP ts=2025-01-01T12:00:00 |
| simple | Minimal | [2025-01-01T12:00:00] info: hello |
// Different formats for console and file
const logger = Logger.create({
level: 'info',
console: { format: 'plain' }, // colored for development
file: {
format: 'json', // structured for log aggregation
dirname: 'logs',
filename: 'app-%DATE%.log',
},
});Per-Transport Level
Each transport can have its own log level:
const logger = Logger.create({
level: 'silly', // accept all at logger level
console: {
format: 'plain',
level: 'debug', // console: debug and above
},
file: {
format: 'json',
level: 'warn', // file: only warn and error
dirname: 'logs',
filename: 'app-%DATE%.log',
},
});| Scenario | Console | File |
|----------|---------|------|
| Development | debug | — |
| Production | info | warn |
| Troubleshooting | debug | info |
| Critical alerts | info | error |
This is useful for sending only critical errors to alerting systems while keeping verbose logs in console.
Per-Transport Rules
Each transport can have its own filtering rules. Use level: 'off' with rules to create a whitelist pattern:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
file: {
format: 'json',
level: 'off', // off by default
rules: [
{ match: { context: 'payments' }, level: 'error' }, // only errors from payments
{ match: { context: 'auth' }, level: 'warn' }, // warn+ from auth
],
dirname: 'logs',
filename: 'critical-%DATE%.log',
},
});
// Only error logs from 'payments' context go to file
logger.for('payments').error('Payment failed'); // → file
logger.for('payments').info('Processing'); // ✗ not logged to file
logger.for('other').error('Generic error'); // ✗ not logged to file (no matching rule)Rules can also match store context (AsyncLocalStorage):
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
file: {
format: 'json',
level: 'off',
rules: [
{ match: { userId: 123 }, level: 'debug' }, // debug for specific user
{ match: { context: 'api', premium: true }, level: 'debug' }, // debug for premium API users
],
dirname: 'logs',
filename: 'debug-%DATE%.log',
},
});
// Logs for user 123 go to file
store.run({ userId: 123 }, () => {
logger.debug('User action'); // → file
});Use level: 'off' in rules to suppress specific contexts:
const logger = Logger.create({
level: 'debug',
console: {
format: 'plain',
level: 'debug',
rules: [
{ match: { context: 'noisy-module' }, level: 'off' }, // suppress noisy logs
],
},
});
logger.for('noisy-module').debug('Spam'); // ✗ not logged
logger.for('other').debug('Useful info'); // → loggedExternal Transports
Discord
Send logs to Discord via webhooks with rich embeds:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
discord: {
webhookUrl: 'https://discord.com/api/webhooks/xxx/yyy',
level: 'error', // only errors to Discord
username: 'My App Logger', // optional bot name
avatarUrl: 'https://...', // optional avatar
embedColors: { // optional custom colors
error: 0xFF0000,
warn: 0xFFAA00,
},
batchSize: 10, // messages per batch (default: 10)
flushInterval: 2000, // flush interval ms (default: 2000)
},
});Telegram
Send logs to Telegram chats/channels:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
telegram: {
botToken: process.env.TG_BOT_TOKEN!,
chatId: process.env.TG_CHAT_ID!,
level: 'warn', // warn and above
parseMode: 'Markdown', // 'Markdown' | 'MarkdownV2' | 'HTML'
disableNotification: false, // mute non-error by default
threadId: 123, // optional forum topic ID
replyToMessageId: 456, // optional reply to message
batchSize: 20, // default: 20
flushInterval: 1000, // default: 1000
},
});Use level: 'off' with rules for selective logging:
telegram: {
botToken: '...',
chatId: '...',
level: 'off', // off by default
rules: [
{ match: { context: 'payments' }, level: 'error' }, // only payment errors
],
}CloudWatch
Send logs to AWS CloudWatch Logs:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
cloudwatch: {
logGroupName: '/app/my-service',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
createLogGroup: true, // auto-create group (default: false)
createLogStream: true, // auto-create stream (default: true)
batchSize: 100, // default: 100
flushInterval: 1000, // default: 1000
},
});Log Stream Name
The logStreamName field is optional and supports multiple configuration formats:
// Option 1: Static string
cloudwatch: {
logGroupName: '/app/my-service',
logStreamName: 'my-static-stream',
// ...
}
// Option 2: Predefined pattern
cloudwatch: {
logGroupName: '/app/my-service',
logStreamName: { pattern: 'hostname-date' },
// ...
}
// Option 3: Custom template
cloudwatch: {
logGroupName: '/app/my-service',
logStreamName: { template: '{hostname}/{env}/{date}' },
// ...
}
// Option 4: Omit for default (hostname)
cloudwatch: {
logGroupName: '/app/my-service',
// logStreamName defaults to hostname
// ...
}Available patterns:
| Pattern | Example Output |
|---------|----------------|
| hostname | my-server |
| hostname-date | my-server/2025-01-15 |
| hostname-uuid | my-server-a1b2c3d4 |
| date | 2025-01-15 |
| uuid | a1b2c3d4-e5f6-7890-abcd |
Template variables:
| Variable | Description |
|----------|-------------|
| {hostname} | Server hostname (config.hostname → HOSTNAME env → os.hostname()) |
| {date} | Current date (YYYY-MM-DD) |
| {datetime} | Current datetime (YYYY-MM-DD-HH-mm-ss) |
| {uuid} | 8-char UUID (generated once at startup) |
| {pid} | Process ID |
| {env} | NODE_ENV (default: "development") |
Hostname resolution priority:
config.hostname(from logger config)process.env.HOSTNAME(useful in Docker/Kubernetes)os.hostname()
Examples:
// Kubernetes-friendly: pod name as stream
cloudwatch: {
logGroupName: '/app/my-service/production',
logStreamName: { pattern: 'hostname' }, // -> "my-app-pod-abc123"
// ...
}
// Daily rotation with hostname
cloudwatch: {
logGroupName: '/app/my-service',
logStreamName: { pattern: 'hostname-date' }, // -> "my-server/2025-01-15"
// ...
}
// Custom format with environment
cloudwatch: {
logGroupName: '/app/my-service',
logStreamName: { template: '{env}/{hostname}/{date}' }, // -> "production/my-server/2025-01-15"
// ...
}Transport Options
All external transports support:
| Option | Default | Description |
|--------|---------|-------------|
| level | — | Log level filter |
| rules | — | Per-transport filtering rules |
| batchSize | varies | Max messages per batch |
| flushInterval | varies | Flush interval in ms |
| maxRetries | 3 | Retry count on failure |
| retryDelay | 1000 | Base retry delay in ms |
Multiple Transports
You can configure multiple instances of the same transport type using arrays:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
discord: [
{
webhookUrl: 'https://discord.com/api/webhooks/payments/xxx',
level: 'off',
rules: [{ match: { context: 'payments' }, level: 'error' }],
},
{
webhookUrl: 'https://discord.com/api/webhooks/auth/yyy',
level: 'off',
rules: [{ match: { context: 'auth' }, level: 'error' }],
},
],
telegram: [
{ botToken: 'token1', chatId: 'errors-chat', level: 'error' },
{ botToken: 'token2', chatId: 'alerts-chat', level: 'warn' },
],
});
// Payment errors go to payments Discord channel
logger.for('payments').error('Payment failed');
// Auth errors go to auth Discord channel
logger.for('auth').error('Login failed');Graceful Shutdown
External transports (Discord, Telegram, CloudWatch) buffer messages before sending. To ensure no logs are lost on process exit, use graceful shutdown.
Automatic (Recommended)
Enable autoShutdown in config to automatically handle SIGTERM/SIGINT:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
cloudwatch: { /* ... */ },
autoShutdown: true, // Auto-register shutdown handlers
});
// Or with options:
const logger = Logger.create({
level: 'info',
console: { format: 'plain' },
cloudwatch: { /* ... */ },
autoShutdown: {
timeout: 10000, // Max wait time (default: 5000ms)
signals: ['SIGTERM'], // Signals to handle (default: ['SIGTERM', 'SIGINT'])
},
});Manual
For more control, use registerShutdown or call shutdown() directly:
import { Logger, registerShutdown } from '@rawnodes/logger';
const logger = Logger.create(config);
// Option 1: Register handlers manually
registerShutdown(logger, {
timeout: 10000,
onShutdown: async () => {
console.log('Flushing logs...');
},
});
// Option 2: Call shutdown directly (e.g., in your own signal handler)
process.on('SIGTERM', async () => {
await logger.shutdown(); // Flush all buffered messages
process.exit(0);
});In Tests
For tests, call shutdown() in afterAll to flush pending logs:
afterAll(async () => {
await logger.shutdown();
});Singleton Pattern
For app-wide logging:
// src/logger.ts
import { createSingletonLogger, type LoggerContext } from '@rawnodes/logger';
export interface AppContext extends LoggerContext {
userId?: number;
requestId?: string;
}
export const AppLogger = createSingletonLogger<AppContext>();
// src/main.ts
import { AppLogger } from './logger.js';
AppLogger.init({
level: {
default: 'info',
rules: [{ match: { context: 'debug-module' }, level: 'debug' }],
},
console: { format: 'plain' },
});
// Anywhere in your app
const logger = AppLogger.for('UserService');
logger.info('User created', { userId: 123 });Context Propagation
Automatically include context in all logs within an async scope:
// Express middleware
app.use((req, res, next) => {
const context = {
userId: req.user?.id,
requestId: req.headers['x-request-id'] || generateRequestId(),
};
AppLogger.getStore().run(context, () => next());
});
// All logs within this request will include userId and requestId
logger.info('Processing request');
// Output: [2025-01-01T12:00:00] info [APP] Processing request
// userId: 123
// requestId: abc-123Dynamic Level Overrides
Add/remove level overrides at runtime:
// Enable debug for specific user (e.g., for troubleshooting)
logger.setLevelOverride({ userId: 123 }, 'debug');
// Enable debug for specific module
logger.setLevelOverride({ context: 'payments' }, 'debug');
// Remove override
logger.removeLevelOverride({ userId: 123 });
// Clear all dynamic overrides (keeps config rules)
logger.clearLevelOverrides();
// Get all overrides
const overrides = logger.getLevelOverrides();
// [{ match: { context: 'payments' }, level: 'debug', readonly: false }]Lazy Meta
Defer expensive object creation:
// BAD: Object created even if debug is disabled
logger.debug('Data processed', { result: expensiveSerialize(data) });
// GOOD: Function only called when debug is enabled
logger.debug('Data processed', () => ({ result: expensiveSerialize(data) }));Child Loggers
Create scoped loggers:
const logger = AppLogger.for('PaymentService');
logger.info('Processing payment');
// Output: [timestamp] info [PaymentService] Processing payment
const stripeLogger = logger.for('Stripe');
stripeLogger.info('Charging card');
// Output: [timestamp] info [Stripe] Charging cardUtilities
Timing
import { measureAsync, measureSync } from '@rawnodes/logger';
// Async
const { result, timing } = await measureAsync('fetch-users', async () => {
return await userService.findAll();
});
console.log(timing); // { label: 'fetch-users', durationMs: 45.23, durationFormatted: '45.23ms' }
// Sync
const { result, timing } = measureSync('compute', () => {
return heavyComputation();
});Request ID
import { generateRequestId, extractRequestId, getOrGenerateRequestId } from '@rawnodes/logger';
// Generate new
generateRequestId(); // "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
generateRequestId({ short: true }); // "a1b2c3d4"
generateRequestId({ prefix: 'req' }); // "req-a1b2c3d4-e5f6-..."
// Extract from headers (checks x-request-id, x-correlation-id, x-trace-id)
extractRequestId(req.headers); // string | undefined
// Extract or generate
getOrGenerateRequestId(req.headers); // always returns stringSecret Masking
Automatically masks sensitive fields in logs:
import { maskSecrets, createMasker } from '@rawnodes/logger';
maskSecrets({
user: 'admin',
password: 'secret123',
apiKey: 'key_abc123',
});
// { user: 'admin', password: '***', apiKey: '***' }
// Custom masker
const masker = createMasker({
patterns: ['ssn', 'creditCard'],
mask: '[REDACTED]'
});Default masked patterns: password, secret, token, apikey, api_key, api-key, auth, credential, private
Logfmt Utilities
Helper functions for logfmt format:
import { flattenObject, formatLogfmt, formatLogfmtValue } from '@rawnodes/logger';
flattenObject({ user: { id: 123, name: 'John' } });
// { 'user.id': 123, 'user.name': 'John' }
formatLogfmt({ level: 'info', msg: 'hello', userId: 123 });
// "level=info msg=hello userId=123"API Reference
Logger
class Logger<TContext> {
static create(config: LoggerConfig, store?: LoggerStore): Logger;
for(context: string): Logger;
getStore(): LoggerStore<TContext>;
// Logging
error(message: string, error?: Error, meta?: Meta): void;
warn(message: string, meta?: Meta): void;
info(message: string, meta?: Meta): void;
http(message: string, meta?: Meta): void;
verbose(message: string, meta?: Meta): void;
debug(message: string, meta?: Meta): void;
silly(message: string, meta?: Meta): void;
// Level overrides
setLevelOverride(match: LevelOverrideMatch, level: LogLevel): void;
removeLevelOverride(match: LevelOverrideMatch): boolean;
clearLevelOverrides(): void;
getLevelOverrides(): LevelOverride[];
// Winston profiling
profile(id: string, meta?: object): void;
}Types
type LogLevel = 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly' | 'off';
type LogFormat = 'json' | 'plain' | 'logfmt' | 'simple';
type Meta = object | (() => object);
interface LoggerConfig {
level: LogLevel | {
default: LogLevel;
rules?: LevelRule[];
};
console: {
format: LogFormat;
level?: LogLevel; // optional, overrides global level
rules?: LevelRule[]; // optional, per-transport filtering
};
file?: {
format: LogFormat;
level?: LogLevel; // optional, overrides global level
rules?: LevelRule[]; // optional, per-transport filtering
dirname: string;
filename: string;
datePattern?: string;
maxFiles?: string;
maxSize?: string;
zippedArchive?: boolean;
};
}
interface LevelRule {
match: Record<string, unknown> & { context?: string };
level: LogLevel;
}Integration Examples
Express
import express from 'express';
import { AppLogger, generateRequestId } from './logger';
const app = express();
app.use((req, res, next) => {
const context = {
requestId: req.headers['x-request-id'] || generateRequestId({ short: true }),
userId: req.user?.id,
};
AppLogger.getStore().run(context, () => next());
});NestJS
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AppLogger, generateRequestId } from './logger';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const context = {
requestId: req.headers['x-request-id'] || generateRequestId({ short: true }),
};
AppLogger.getStore().run(context, () => next());
}
}Telegraf
import { Telegraf } from 'telegraf';
import { AppLogger } from './logger';
bot.use((ctx, next) => {
const context = {
telegramUserId: ctx.from?.id,
chatId: ctx.chat?.id,
};
return AppLogger.getStore().run(context, () => next());
});License
MIT
