Logging

Structured JSON logging with pluggable channels: Console, File, Datadog, Grafana Loki, and Pino.

Introduction

@tekir/logger provides a Logger class with pluggable transport channels. Every log entry is passed to all configured channels simultaneously: write to the console, a file, and Datadog at the same time. Channels are configured in config/logger.ts and instantiated automatically by LoggerProvider.

services.ts
import { service } from '@tekir/core'
import type { Logger } from '@tekir/logger'

export const logger = service<Logger>('logger')

Register LoggerProvider in your kernel:

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { LoggerProvider } from '@tekir/logger'

export default function({ app }: TekirApp) {
  app.registerAll([LoggerProvider])
}

Configuration

Create config/logger.ts. The channels object defines where log entries are sent. Each channel has a driver field that determines the transport. If no channels are configured the logger defaults to console output.

config/logger.ts
import env from '#env'
import type { LoggerConfig } from '@tekir/logger'

export default {
  level: env.NODE_ENV === 'production' ? 'info' : 'debug',
  name: 'my-app',
  timestamp: true,
  redact: ['password', 'token', 'secret'],
  channels: {
    console: {
      driver: 'console',
      pretty: env.NODE_ENV !== 'production'
    }
  }
} satisfies LoggerConfig

A production setup with multiple channels:

config/logger.ts
import env from '#env'
import type { LoggerConfig } from '@tekir/logger'

export default {
  level: 'info',
  name: env.APP_NAME,
  timestamp: true,
  redact: ['password', 'token', 'secret', 'authorization'],
  channels: {
    console: {
      driver: 'console',
      pretty: false
    },
    file: {
      driver: 'file',
      path: 'storage/logs/app.log',
      maxSize: 10 * 1024 * 1024,
      maxFiles: 5
    },
    datadog: {
      driver: 'datadog',
      apiKey: env.DD_API_KEY,
      site: 'us1',
      service: env.APP_NAME,
      tags: `env:${env.NODE_ENV}`
    }
  }
} satisfies LoggerConfig

Full LoggerConfig reference:

import type { LoggerConfig } from '@tekir/logger'

const config: LoggerConfig = {
  level: 'info',       // 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
  enabled: true,       // set false to silence all output (default: true)
  name: 'my-app',      // added to every log entry as 'name' (default: 'app')
  timestamp: true,     // include 'time' (Unix ms) in every entry (default: true)
  redact: ['password'], // keys to redact in log objects
  channels: { ... }   // transport channels, see below
}
  • level: minimum severity to emit. Default: 'info'.
  • enabled: set to false to suppress all output. Default: true.
  • name: added to every log entry. Default: 'app'.
  • timestamp: include a time field (Unix ms). Default: true.
  • redact: keys to replace with '[REDACTED]'.
  • channels: a map of channel name to driver config. Each entry is sent to every channel.

Channels

Channels are the transport layer. Each channel has a driver that determines where log entries go. You can configure as many channels as you need; the logger writes to all of them on every log call.

Console

Writes to stdout. In pretty mode it outputs colorized human-readable lines; otherwise it emits newline-delimited JSON. This is the default channel when no channels are configured.

channels: {
  console: {
    driver: 'console',
    pretty: true    // colorized output (default: non-production)
  }
}

File

Writes JSON-lines to a file with automatic rotation. When the file exceeds maxSize it is rotated: app.log becomes app.1.log, app.1.log becomes app.2.log, and so on. Files beyond maxFiles are deleted automatically. The directory is created if it does not exist.

channels: {
  file: {
    driver: 'file',
    path: 'storage/logs/app.log',  // log file path (required)
    maxSize: 10 * 1024 * 1024,     // rotate after 10 MB (default)
    maxFiles: 5,                    // keep 5 rotated files (default)
    prefix: '',                     // prepend to each line
    suffix: ''                      // append to each line
  }
}

// Rotation works automatically:
// app.log → app.1.log → app.2.log → ... → app.5.log (deleted)
// Each rotated file is a complete JSON-lines file.
  • path: log file path (required).
  • maxSize: rotate after this many bytes. Default: 10 MB.
  • maxFiles: number of rotated files to keep. Default: 5.
  • prefix / suffix: prepend or append a string to each log line.

Datadog

Sends log entries to the Datadog Logs HTTP API. Requires bun add @tekir/logger-datadog. Supports all Datadog sites, batching, and automatic flush intervals.

// bun add @tekir/logger-datadog
channels: {
  datadog: {
    driver: 'datadog',
    apiKey: env.DD_API_KEY,          // required
    site: 'us1',                     // 'us1' | 'us3' | 'us5' | 'eu' | 'ap1' | 'gov'
    service: 'my-api',              // Datadog service name
    hostname: 'prod-01',            // optional hostname tag
    tags: 'env:production,version:1.0', // comma-separated tags
    source: 'bun',                  // ddsource field
    batchSize: 50,                  // flush after N entries (default: 50)
    flushInterval: 5000             // auto-flush interval in ms (default: 5000)
  }
}
  • site: Datadog region. Default: 'us1'.
  • batchSize: flush after N entries. Default: 50.
  • flushInterval: auto-flush interval in ms. Default: 5000.

Grafana Loki

Pushes entries to the Grafana Loki HTTP API. Requires bun add @tekir/logger-loki. Supports Basic auth (Grafana Cloud), multi-tenant X-Scope-OrgID, custom labels, and batching.

The url is validated before any request is sent. It must be http or https, and loopback, private, and cloud-metadata hosts (such as localhost, 127.0.0.1, private ranges, and 169.254.169.254) are rejected to prevent logs from being sent to an unintended internal target. For local development, set allowInsecureHost: true to permit these hosts. When auth is set, an http target is also rejected unless allowInsecureHost is enabled, so credentials are not sent in clear text.

// bun add @tekir/logger-loki
channels: {
  loki: {
    driver: 'loki',
    url: env.LOKI_URL,                // https URL; internal/private hosts blocked
    labels: { app: 'my-api', env: 'production' },
    auth: {                           // Basic auth (Grafana Cloud)
      username: env.LOKI_USERNAME,
      password: env.LOKI_PASSWORD
    },
    tenantId: 'my-org',              // X-Scope-OrgID for multi-tenant
    allowInsecureHost: false,        // set true to allow localhost/private hosts (dev)
    batchSize: 50,
    flushInterval: 5000
  }
}
  • labels: key/value pairs attached to the Loki stream.
  • auth: Basic auth credentials for Grafana Cloud.
  • tenantId: X-Scope-OrgID header for multi-tenant Loki.
  • allowInsecureHost: allow loopback/private hosts and http targets. Default false; enable only for local development.

Pino

Bridges log entries to a Pino instance. Requires bun add @tekir/logger-pino pino. This gives you access to Pino's full transport ecosystem (pino-pretty, pino-elasticsearch, pino-datadog, etc.) while keeping the tekir logger API in your application code.

// bun add @tekir/logger-pino pino
// For the pino channel, pass the pino instance directly:
import pino from 'pino'

export default {
  level: 'info',
  channels: {
    pino: {
      driver: 'pino',
      pino: pino({
        level: 'trace',
        transport: { target: 'pino-pretty' }
      })
    }
  }
} satisfies LoggerConfig

Log Levels

Six levels ordered by severity. The configured level acts as a minimum threshold; anything below it is silently dropped.

import { logger } from '#services'

// Six levels in ascending order of severity.
// Only messages at or above the configured 'level' are emitted.
logger.trace('very verbose detail')  // level 10
logger.debug('debug information')    // level 20
logger.info('application started')   // level 30  ← default minimum
logger.warn('cache miss rate high')  // level 40
logger.error('request handler threw') // level 50
logger.fatal('process will exit')    // level 60

Structured Logging

Every log method accepts either a string or an object as the first argument. When an object is passed its keys are merged into the log entry:

logger.info('Server started')
// → {"level":"info","msg":"Server started","time":1712345678901,"name":"my-app"}
// Pass an object as the first argument for structured context.
logger.info({ requestId: 'abc-123', method: 'GET', path: '/users' }, 'Incoming request')
// → {"level":"info","requestId":"abc-123","method":"GET","path":"/users","msg":"Incoming request","time":...}

// Works with all levels.
logger.error({ err: err.message, stack: err.stack }, 'Unhandled exception')

logger.warn({ userId: 42, quota: 95 }, 'User approaching storage quota')

Redaction

Any key in the redact array is replaced with '[REDACTED]'. Redaction is deep: a matching key is masked at any nesting level, including inside arrays and the inherited child-logger context, so a value like user.password or headers.authorization is redacted even when it is nested. The original object you pass is never mutated.

// Keys listed in 'redact' are replaced with '[REDACTED]' at log time.
// Redaction is deep: nested keys are masked too.
logger.info({
  email: '[email protected]',
  password: 'super-secret',
  user: { id: 42, token: 'jwt-abc-123' }
}, 'User login attempt')

// Output:
// {"level":"info","email":"[email protected]","password":"[REDACTED]","user":{"id":42,"token":"[REDACTED]"},"msg":"User login attempt","time":...}

Log messages are also sanitized before they are written: carriage returns, line feeds, and other control characters (including ANSI escape sequences) are stripped from the message and string fields. This prevents log forging and terminal-escape injection from untrusted input.

Child Loggers

logger.child(context) returns a new Logger that inherits all settings and channels from the parent. The given context is merged into every log entry.

import { logger } from '#services'

// child() creates a new Logger that inherits all parent settings
// and merges additional context into every log entry.
const requestLogger = logger.child({
  requestId: 'abc-123',
  traceId: 'trace-xyz'
})

requestLogger.info('Processing request')
// → {"level":"info","requestId":"abc-123","traceId":"trace-xyz","msg":"Processing request","time":...}

// Nesting is supported: child inherits the parent's context.
const dbLogger = requestLogger.child({ component: 'database' })
dbLogger.debug('Query executed')
// → {"level":"debug","requestId":"abc-123","traceId":"trace-xyz","component":"database","msg":"Query executed","time":...}

A common pattern is to create a per-request child logger in middleware:

// core/middleware/request_logger.ts
import { logger } from '#services'
import type { HttpContext } from '@tekir/core'

export default async function requestLogger(
  { store, request }: HttpContext,
  next: () => Promise<void>
) {
  const requestId = crypto.randomUUID()
  const log = logger.child({ requestId, method: request.method, path: request.path })
  store.logger = log

  log.info('Request received')
  const start = Date.now()

  try {
    await next()
    log.info({ ms: Date.now() - start }, 'Request completed')
  } catch (err: any) {
    log.error({ err: err.message }, 'Request failed')
    throw err
  }
}

Pretty Output

Pretty mode outputs colorized, human-readable lines. It is enabled by default when NODE_ENV is not 'production'. Override it in the console channel config.

// In pretty mode output is colorized:
//   2024-04-05T12:00:00.000Z INFO  Server started
//   2024-04-05T12:00:01.000Z WARN  Cache miss rate high {"component":"cache"}

// Pretty mode is enabled by default when NODE_ENV is not 'production'.
// Override via the console channel config:
channels: {
  console: { driver: 'console', pretty: true }
}

isLevelEnabled

logger.isLevelEnabled(level) returns true if the level would be emitted. Use it to guard expensive serialization.

// Check whether a level would be emitted before doing expensive work.
if (logger.isLevelEnabled('debug')) {
  const payload = JSON.stringify(expensiveObject())
  logger.debug({ payload }, 'Debug snapshot')
}

Custom Transport

Implement the LogTransport interface to create your own channel. Add it at runtime with logger.addTransport():

import type { LogTransport, LogEntry } from '@tekir/logger'

class SlackTransport implements LogTransport {
  constructor(private webhookUrl: string) {}

  async write(entry: LogEntry): Promise<void> {
    if (entry.level !== 'error' && entry.level !== 'fatal') return

    await fetch(this.webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `[${entry.level.toUpperCase()}] ${entry.msg}`
      })
    })
  }
}

// Add it at runtime
import { logger } from '#services'
logger.addTransport(new SlackTransport(env.SLACK_WEBHOOK_URL))