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.
import { service } from '@tekir/core'
import type { Logger } from '@tekir/logger'
export const logger = service<Logger>('logger')Register LoggerProvider in your kernel:
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.
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 LoggerConfigA production setup with multiple channels:
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 LoggerConfigFull 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
falseto suppress all output. Default:true. - name: added to every log entry. Default:
'app'. - timestamp: include a
timefield (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-OrgIDheader for multi-tenant Loki. - allowInsecureHost: allow loopback/private hosts and
httptargets. Defaultfalse; 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 LoggerConfigLog 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 60Structured 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))