@pronghorn/logger
Logger
Logger is a lightweight, TypeScript-first structured logging middleware built as an external plugin for Pronghorn. It attaches a request-scoped logger with correlation IDs, configurable log levels, and pluggable output transports, replacing Pronghorn's bare-bones built-in logger middleware.
Built as a standalone package (
@pronghorn/logger), the final piece of the@pronghorn/*ecosystem, designed for real observability, not just console lines.
Why Logger
Pronghorn's built-in logger middleware prints one line per request with no levels, no correlation IDs, and no way to route output anywhere but stdout. Logger replaces it with a proper structured logging layer suited for production observability stacks.
- Every request gets a correlation ID (
requestId), reused from an inbound header if present, otherwise generated, and echoed back in the response. - Four log levels (
debug,info,warn,error) with configurable minimum threshold filtering. - JSON-line output by default, the format expected by log aggregators like Datadog and ELK, or a colorized pretty-print mode for local development.
- Pluggable
Transportfunctions, ship logs to console, file, or any custom sink (HTTP endpoint, message queue, etc.) simultaneously. logger.child(meta)derives a scoped logger that merges extra context into every subsequent entry, used internally to attachrequestIdapp-wide.- Zero runtime dependencies,
pronghornis only a peer dependency for types.
Installation
bun add @pronghorn/logger
Requires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only).
Quick Start
import { createApp } from 'pronghorn'
import { requestLogger } from '@pronghorn/logger'
const app = createApp()
app.use(requestLogger({ level: 'info' }))
app.get('/', context => context.json({ message: 'Hello, logged 📜' }))
await app.listen(4000)
Replaces Pronghorn's built-in logger middleware entirely, both are global scope, so swapping is a one-line change: drop logger, add requestLogger(...).
Core Concepts
Request-scoped logging
Once registered, every request gets a context.locals.logger instance already tagged with that request's correlation ID. Use it anywhere inside the handler instead of console.log.
app.post('/orders', context => {
const logger = context.locals.logger as Logger
logger.info('order:created', { orderId: 'ord_123', amount: 49.99 })
return context.json({ created: true })
})
Automatic request/response logging
Every request automatically logs a request:start entry on entry and a request:end entry on exit, including method, path, status, and duration. The log level for request:end escalates automatically: info for 2xx/3xx, warn for 4xx, error for 5xx.
{"level":"info","message":"request:start","timestamp":"2026-07-18T10:28:00.000Z","requestId":"a1b2c3","meta":{"method":"GET","path":"/orders"}}
{"level":"info","message":"request:end","timestamp":"2026-07-18T10:28:00.042Z","requestId":"a1b2c3","meta":{"method":"GET","path":"/orders","status":200,"durationMs":42}}
Correlation IDs across services
The middleware reuses an inbound X-Request-Id header if present (useful behind a gateway or between microservices), otherwise generates a new UUID, and always echoes it back in the response header, letting you trace a single request across multiple hops.
app.use(requestLogger({ headerName: 'x-request-id' }))
Pretty console output for development
Switch to human-readable, color-coded output instead of JSON lines while developing locally.
import { prettyConsoleTransport } from '@pronghorn/logger'
app.use(requestLogger({ transports: [prettyConsoleTransport()] }))
2026-07-18T10:28:00.042Z INFO [a1b2c3] request:end {"method":"GET","path":"/orders","status":200,"durationMs":42}
Multiple transports at once
Pass an array to ship the same entries to multiple destinations simultaneously, e.g. JSON to a log file for aggregation and a pretty format to the terminal while developing.
import { jsonConsoleTransport, fileTransport } from '@pronghorn/logger'
app.use(requestLogger({
transports: [jsonConsoleTransport(), fileTransport('./logs/app.log')]
}))
Custom transports
A Transport is just (entry: LogEntry) => void | Promise<void>, write your own to ship logs anywhere.
import type { Transport } from '@pronghorn/logger'
function httpTransport(endpoint: string): Transport {
return async entry => {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
})
}
}
app.use(requestLogger({ transports: [httpTransport('https://logs.example.com/ingest')] }))
Filtering by level
Only entries at or above the configured level are dispatched to transports, letting you silence debug noise in production while keeping it locally.
app.use(requestLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' }))
Scoped child loggers
Derive a logger with extra baked-in context anywhere in your app, useful for tagging logs by subsystem (e.g. a background job runner) outside the request lifecycle.
import { createLogger } from '@pronghorn/logger'
const rootLogger = createLogger({ level: 'info' })
const jobLogger = rootLogger.child({ subsystem: 'email-queue' })
jobLogger.info('job:started', { jobId: 'job_42' })
Middleware Options
| Option | Type | Default | Description |
|---|---|---|---|
level |
'debug' | 'info' | 'warn' | 'error' |
'info' |
Minimum level dispatched to transports |
transports |
Transport[] |
[jsonConsoleTransport()] |
Output destinations, applied in order |
pretty |
boolean |
false |
Shortcut for prettyConsoleTransport() when no transports are given |
headerName |
string |
'x-request-id' |
Header used to read/write the correlation ID |
Built-in Transports
| Export | Description |
|---|---|
jsonConsoleTransport() |
Writes each entry as a single JSON line to stdout/stderr |
prettyConsoleTransport() |
Writes each entry as a colorized, human-readable line |
fileTransport(filePath) |
Appends each entry as a JSON line to a file on disk |
fileTransport appends only, external log rotation (e.g. logrotate) is expected for long-running production log files.
API Reference
requestLogger(options?: RequestLoggerOptions): Middleware - global middleware factory, register via app.use(requestLogger(options)).
createLogger(options?: LoggerOptions, baseMeta?): Logger - creates a standalone logger instance outside the request lifecycle.
| Locals property | Type | Description |
|---|---|---|
context.locals.logger |
Logger |
Request-scoped logger, pre-tagged with requestId |
context.locals.requestId |
string |
The current request's correlation ID |
Logger
| Method | Signature | Description |
|---|---|---|
debug |
(message, meta?) => void |
Logs at debug level |
info |
(message, meta?) => void |
Logs at info level |
warn |
(message, meta?) => void |
Logs at warn level |
error |
(message, meta?) => void |
Logs at error level |
child |
(meta) => Logger |
Returns a derived logger merging meta into every subsequent entry |
Architecture
Logger is split into three modules, each with a single responsibility.
| Module | Responsibility |
|---|---|
logger.ts |
Core Logger factory: level filtering, entry formatting, transport dispatch, child() derivation |
transports.ts |
Built-in output sinks: JSON console, pretty console, and append-only file |
middleware.ts |
Wires a per-request Logger into context.locals, generates/propagates the correlation ID, and logs start/end entries with automatic level escalation |
Log entries are dispatched to every configured transport independently and asynchronously (void transport(entry)), so a slow file write never blocks the response, and one failing transport doesn't prevent others from receiving the entry.
Observability Notes
- Use
jsonConsoleTransport()in production so container log collectors (Docker, Kubernetes, systemd) can parse structured fields directly. - Always propagate the
x-request-idheader downstream when calling other services, so a single user request can be traced across your entire system. - Keep
level: 'debug'local-only, debug-level logs are typically too noisy and sometimes too sensitive for production aggregation. - If using
fileTransport, pair it with an external rotation tool, this package intentionally does not manage file rotation or retention itself.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.