logger
When Ada reports that her order never shipped, the logs are where you find out why. In development you want lines you can read at a glance; in production you want one JSON object per line, so your log service can filter by orderId or requestId. @kitr/logger writes both from the same calls.
It runs on the server and in the browser and is for lines a developer reads. For alerts with the user and the steps that led to an error, use monitoring; to count what users do, use analytics.
The examples follow Acme's app at app.acme.com, where Ada, Grace and Linus read the logs when something goes wrong.
Install
kitr add loggerFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Installs
@kitr/logger. - Adds
'@kitr/logger'topackagesand() => import('@kitr/logger')toprovidersinkitrrc.ts. - Writes
config/logger.ts(shown in Configure) and regeneratesconfig/index.ts. - Adds a
## kitr: loggersection toAGENTS.mdtelling coding agents to log throughctx.loggerorapp.container.use('logger'), neverconsole.login server code.
The server package requires the logger, so kitr add server adds it first and adds LOG_LEVEL=info to .env.example. Every kit with a server platform already has it.
kitr remove logger reverses these steps and deletes config/logger.ts.
Configure
kitr add logger writes this file:
// config/logger.ts
import { defineConfig } from '@kitr/logger'
export default defineConfig({
name: 'app',
level: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') ?? 'info',
// 'pretty' in dev, 'json' in production by default.
})
| Option | Default | Description |
|---|---|---|
level |
'info' |
The lowest level written: trace, debug, info, warn, error, fatal or silent. |
name |
none | Shown in brackets before each message in pretty output, and as name in JSON. |
format |
'pretty' unless NODE_ENV is production, then 'json' |
How each line is written. |
bindings |
{} |
Fields added to every record, such as { region: 'eu-west-1' }. |
write(line, record) |
the console | Where lines go. The default writes error and fatal to console.error (stderr) and the rest to console.log (stdout). |
| Variable | Used for |
|---|---|
LOG_LEVEL |
The lowest level to print, such as info. |
The default format reads process.env.NODE_ENV, not the app's mode. In the browser the logger calls the console directly; see Log in the browser.
Write your first log line
In a controller, the logger is ctx.logger. It is a child of the app's logger that adds the request id to every line:
// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
export default class OrdersController {
async ship({ logger, params }: HttpContext<{ id: string }>) {
logger.info('order shipped', { orderId: params.id })
return { orderId: params.id, status: 'shipped' }
}
}
Call POST /api/orders/1042/ship in development and the server prints:
INFO 14:03:22.418 [app] order shipped {"requestId":"9b2f6c1e-4f3a-4c1d-8e7a-2b5d0c9e1f44","orderId":"1042"}
In production, where NODE_ENV=production, the same call writes one JSON object:
{"level":"info","time":"2026-11-07T14:03:22.418Z","name":"app","requestId":"9b2f6c1e-4f3a-4c1d-8e7a-2b5d0c9e1f44","orderId":"1042","msg":"order shipped"}
Keep the message fixed and put the variable parts in fields: 'order shipped' with { orderId }, not `order ${id} shipped`. Your log service can then count and filter the lines.
Write at a level
Each level has a method. Lines below the configured level are dropped before formatting, so a debug call in a hot path is nearly free in production.
| Level | Method | Use it for |
|---|---|---|
trace |
logger.trace(msg, fields?) |
Step-by-step detail you only turn on while debugging one thing. |
debug |
logger.debug(msg, fields?) |
Detail for development: the server's request lines, cache hits. |
info |
logger.info(msg, fields?) |
Things that happened: an order shipped, a job finished. |
warn |
logger.warn(msg, fields?) |
Something odd that did not fail: a slow query, a retry. |
error |
logger.error(msgOrError, fields?) |
A failure someone should look at. |
fatal |
logger.fatal(msgOrError, fields?) |
The process cannot continue. |
silent is a level for configuration only: it drops everything.
// server/services/project-service.ts
import type { KitrApp } from '@kitr/kitr/app'
export async function syncProject(app: KitrApp, projectId: string) {
const logger = app.container.use('logger')
logger.debug('loading project', { projectId })
logger.warn('slow query', { projectId, ms: 1840 })
logger.info('project synced', { projectId, tasks: 42 })
}
Log an error
Pass the Error itself to error() or fatal(). Its message becomes msg, and its name, message, stack and code are kept under err:
// server/services/invoice-service.ts
import type { KitrApp } from '@kitr/kitr/app'
export async function generateInvoice(app: KitrApp, invoiceNumber: string) {
const logger = app.container.use('logger')
try {
throw Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' })
} catch (error) {
logger.error(error as Error, { invoiceNumber })
throw error
}
}
In JSON that becomes:
{"level":"error","time":"2026-11-07T14:05:10.002Z","name":"app","invoiceNumber":"INV-2026-007","msg":"connect ECONNREFUSED 10.0.0.5:5432","err":{"name":"Error","message":"connect ECONNREFUSED 10.0.0.5:5432","stack":"Error: connect ECONNREFUSED ...","code":"ECONNREFUSED"}}
The server already logs every 5xx response at error level, so log only errors you catch and handle yourself.
Add fields to every line
child(bindings, name?) returns a logger that adds bindings to every record, so an id travels with a piece of work:
// server/services/order-service.ts
import type { KitrApp } from '@kitr/kitr/app'
export async function shipOrder(app: KitrApp, orderId: string) {
const logger = app.container.use('logger').child({ orderId }, 'orders')
logger.info('picking items')
logger.info('label printed', { carrier: 'DHL' })
logger.info('order shipped')
return { orderId, status: 'shipped' as const }
}
INFO 14:03:22.418 [orders] picking items {"orderId":"1042"}
INFO 14:03:22.419 [orders] label printed {"orderId":"1042","carrier":"DHL"}
INFO 14:03:22.420 [orders] order shipped {"orderId":"1042"}
A child keeps its parent's bindings; fields passed in a call win over bindings with the same key. For fields every line should carry, such as the region, set bindings in config/logger.ts:
// config/logger.ts
import { defineConfig } from '@kitr/logger'
export default defineConfig({
name: 'app',
level: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') ?? 'info',
bindings: { region: process.env.FLY_REGION ?? 'local', release: process.env.RELEASE ?? 'dev' },
})
Log during a request
Every request gets its own child logger with requestId, taken from the x-request-id header or a new UUID and echoed in the response header. Grep for it to see every line one request wrote.
| Where | How |
|---|---|
| A controller | ctx.logger |
| Middleware, an action or a service during a request | useRequest().logger, with useRequest from @kitr/server |
| Code that may run inside or outside a request | tryUseRequest()?.logger ?? app.container.use('logger') |
// server/services/team-service.ts
import { useRequest } from '@kitr/server'
export function inviteToTeam(teamId: string, email: string) {
const { logger } = useRequest()
logger.info('team invite sent', { teamId, invitee: email })
}
useRequest() throws outside a request, such as in a job or at startup.
When a request finishes, the server writes one debug line with the method, path, status, client IP and duration:
DEBUG 14:03:22.431 [app] POST /api/orders/1042/ship {"requestId":"9b2f6c1e-...","status":200,"ip":"127.0.0.1","durationMs":13}
Set LOG_LEVEL=debug to see them.
Log outside a request
The provider binds the logger as logger in the container. Jobs, scripts and providers read it from there:
// server/jobs/prune-sessions.ts
import { defineJob } from '@kitr/jobs'
export const pruneSessions = defineJob('prune_sessions', {
schedule: '0 3 * * *',
async run(_payload, { app, jobId }) {
const logger = app.container.use('logger').child({ jobId }, 'jobs')
logger.info('pruning expired sessions')
},
})
The server keeps the same logger as server.logger, set once server.boot() has run.
Log in the browser
In a component, read the logger with useService from @kitr/react:
// app/components/projects/project-actions.tsx
import { useService } from '@kitr/react'
export function ArchiveProjectButton({ projectId }: { projectId: string }) {
const logger = useService('logger')
return (
<button type="button" onClick={() => logger.info('project archived', { projectId })}>
Archive
</button>
)
}
Browser lines go to the matching console method with the fields as an expandable object, for example console.info('[app] project archived', { projectId }). Set format or write for formatted lines instead. Nothing is sent to your server; to collect client-side failures, use monitoring.
Time an operation
logger.time(msg, fn, fields?) runs an async function, logs msg at info with durationMs when it settles, and returns its result:
// server/services/invoice-service.ts
import type { KitrApp } from '@kitr/kitr/app'
export async function renderInvoicePdf(app: KitrApp, invoiceNumber: string) {
const logger = app.container.use('logger').child({ invoiceNumber }, 'billing')
const bytes = await logger.time('rendered invoice PDF', async () => new Uint8Array(48_213), {
pages: 2,
})
return bytes.length
// => 48213
}
INFO 14:07:41.006 [billing] rendered invoice PDF {"invoiceNumber":"INV-2026-007","pages":2,"durationMs":212}
The line is written even when the function throws, and the error is rethrown. The line doesn't say it failed, so log the error separately.
Change the level at run time
Set level to turn on debug output in a running process:
// server/controllers/admin-controller.ts
import type { HttpContext } from '@kitr/api'
export default class AdminController {
async setLogLevel({ app, body }: HttpContext<{}, {}, { level: 'debug' | 'info' }>) {
const logger = app.container.use('logger')
logger.level = body.level
return { level: logger.level }
}
}
A child copies its parent's level when created, so a change affects only children created afterwards, such as the next request's logger. Protect a route like this with auth.
Choose the output format
pretty is for terminals: a coloured level, the UTC time, [name], the message, fields as dim JSON and any stack. json is one record per line, with keys in this order: level, time, name, bindings, fields, msg, err.
Force one format everywhere, for example JSON in a staging environment that runs with NODE_ENV=development:
// config/logger.ts
import { defineConfig } from '@kitr/logger'
export default defineConfig({
name: 'app',
level: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') ?? 'info',
format: process.env.LOG_FORMAT === 'json' ? 'json' : undefined,
})
Most hosting platforms collect stdout and stderr and parse JSON lines into searchable fields, so json in production needs nothing else.
Send logs to a file or a service
write(line, record) replaces the console. It receives the formatted line and the record for every line at or above the level:
// config/logger.ts
import { appendFileSync } from 'node:fs'
import { defineConfig } from '@kitr/logger'
export default defineConfig({
name: 'app',
format: 'json',
write(line, record) {
appendFileSync('storage/logs/app.log', `${line}\n`)
if (record.level === 'error' || record.level === 'fatal') console.error(line)
},
})
To ship to an HTTP endpoint, buffer records and send them in batches:
// server/log_shipper.ts
import type { LogRecord } from '@kitr/logger'
const buffer: LogRecord[] = []
export function shipLog(_line: string, record: LogRecord) {
buffer.push(record)
if (buffer.length >= 100) void flushLogs()
}
export async function flushLogs() {
const batch = buffer.splice(0)
if (!batch.length) return
await fetch('https://logs.acme.com/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(batch),
}).catch(() => {
// Never let logging take the app down.
})
}
setInterval(flushLogs, 5_000).unref()
// config/logger.ts
import { defineConfig } from '@kitr/logger'
import { shipLog } from '#server/log_shipper'
export default defineConfig({ name: 'app', format: 'json', write: shipLog })
Create a logger without the provider
createLogger(options) takes the same options as config/logger.ts. Use it in scripts or anywhere no app exists:
// scripts/import_projects.ts
import { createLogger } from '@kitr/logger'
const logger = createLogger({ name: 'import', level: 'debug' })
logger.info('importing projects', { file: 'projects.csv' })
silentLogger writes nothing; pass it where an API wants a logger and you want no output.
See logs in devtools
With devtools installed, every record is copied to the Log panel under the logger channel, so server and client lines sit in one place.
Testing
createLogger({ write }) lets a test collect records. For code that reads the logger from the container, boot an app with the provider and a collecting write:
// test/ship-order.test.ts
import { type LogRecord, loggerProvider } from '@kitr/logger'
import { createTestApp } from '@kitr/testing'
import { describe, expect, it } from 'vitest'
import { shipOrder } from '#server/services/order-service'
describe('shipOrder', () => {
it('logs each step with the order id', async () => {
const records: LogRecord[] = []
const app = await createTestApp({
providers: [loggerProvider],
config: {
logger: { level: 'debug', format: 'json', write: (_line, record) => records.push(record) },
},
})
await shipOrder(app, '1042')
expect(records.map((r) => r.msg)).toEqual(['picking items', 'label printed', 'order shipped'])
expect(records[1]).toMatchObject({
level: 'info',
name: 'orders',
orderId: '1042',
carrier: 'DHL',
})
})
})
Each record is a plain object with level, msg, name, your fields and bindings, err and durationMs, so assert with toMatchObject. To check a line's exact text, assert on the first argument of write. To keep a noisy test quiet, use level: 'silent' or silentLogger:
// test/quiet-server.test.ts
import { silentLogger } from '@kitr/logger'
import { createServer } from '@kitr/server'
import { expect, it } from 'vitest'
it('answers the health check without printing', async () => {
const server = createServer({ logger: silentLogger })
const res = await server.fetch(new Request('http://localhost/healthz'))
expect(await res.json()).toEqual({ ok: true })
})