npm package: @rawnodes/logger
Description: High-performance Pino-based logger with AsyncLocalStorage context, O(1) level rules, and timing utilities
Author: RawNodes
Latest version: 2.7.1

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

```bash
pnpm add @rawnodes/logger
# or
npm install @rawnodes/logger
```

## Quick Start

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

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

```typescript
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 warn
```

Rules 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` |

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

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

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

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

```typescript
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');  // → logged
```

## External Transports

### Discord

Send logs to Discord via webhooks with rich embeds:

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

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

```typescript
telegram: {
  botToken: '...',
  chatId: '...',
  level: 'off',                        // off by default
  rules: [
    { match: { context: 'payments' }, level: 'error' },  // only payment errors
  ],
}
```

### CloudWatch

Send logs to AWS CloudWatch Logs:

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

```typescript
// 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:**
1. `config.hostname` (from logger config)
2. `process.env.HOSTNAME` (useful in Docker/Kubernetes)
3. `os.hostname()`

**Examples:**

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

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

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

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

```typescript
afterAll(async () => {
  await logger.shutdown();
});
```

## Singleton Pattern

For app-wide logging:

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

```typescript
// 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-123
```

## Dynamic Level Overrides

Add/remove level overrides at runtime:

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

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

```typescript
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 card
```

## Utilities

### Timing

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

```typescript
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 string
```

### Secret Masking

Automatically masks sensitive fields in logs:

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

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

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

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

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

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

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