Guide

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: a coloured level, the time, the message. In production you want one JSON object per line, so your log service can search and filter by orderId or requestId. @kitr/logger writes both from the same calls, with levels, child loggers that carry fields, timing, and a request id on every line the server writes.

The examples on this page follow one scenario: Acme's app at app.acme.com ships orders, generates invoices and syncs projects, and the team (Ada, Grace and Linus) reads the logs when something goes wrong.

@kitr/logger has no dependencies and runs on the server and in the browser. It is for lines a developer reads. To be alerted about errors with the user and the steps that led to them, use monitoring; to count what users do, use analytics.

Install

kitr add logger

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

What kitr add does:

  • Installs @kitr/logger.
  • Adds '@kitr/logger' to packages and () => import('@kitr/logger') to providers in kitrrc.ts.
  • Writes config/logger.ts (shown in Configure) and regenerates config/index.ts.
  • Adds a ## kitr: logger section to AGENTS.md telling coding agents to log through ctx.logger or app.container.use('logger'), never console.log in server code.

kitr add logger adds no env variables or scripts and asks no questions. To edit package.json without running the package manager, pass the global flag:

kitr add logger --no-install

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

The server package requires the logger, so kitr add server adds it first, and its setup adds LOG_LEVEL=info to .env.example. Every kit with a server platform (server, fullstack, saas, site, realtime) already has it.

kitr remove logger reverses the provider, packages entry, dependency and AGENTS.md 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 level in the generated config. Added to .env.example by the server package as LOG_LEVEL=info. Also read by the server when it has to create its own logger.

The default format reads process.env.NODE_ENV, not the app's mode. In the browser, with neither format nor write set, the logger skips formatting and 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"}

The message comes first and the fields second. 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 every order shipped line and filter them by orderId.

Write at a level

Each level has a method. Lines below the configured level are dropped before they are formatted, so a debug call in a hot path costs almost nothing 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/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/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"}}

Pretty output prints the stack on the lines after the message. Don't pass the error as a field (logger.error('failed', { error })): an Error's message and stack are not enumerable, so JSON.stringify writes {}. Pass it as the first argument, or copy what you need: { reason: (error as Error).message }.

The server already logs every 5xx response at error level with the method, path and status, so you only log errors you catch and handle yourself.

Add fields to every line

child(bindings, name?) returns a logger that adds bindings to every record. Use it to carry an id through a piece of work instead of repeating it in every call:

// server/services/order-service.ts
import type { KitrApp } from '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 and adds its own; fields passed in a call win over bindings with the same key. The name replaces the parent's name, and without one the child keeps the parent's. For fields every line in the process should carry, such as the region or the release, 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' },
})

level, time and msg are reserved. A field or binding with one of those names is dropped, so a stray { level: 'urgent' } cannot break filtering.

Log during a request

On the server, every request gets its own child logger with requestId. The id comes from the x-request-id header when the caller sends one (a load balancer usually does), or is a new UUID, and it is echoed back 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 [@kitr/server] useRequest() called outside a request. when nothing is in flight, such as in a job or at startup.

When a request finishes, the server writes one line at debug level 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 these lines; at the default info they are dropped.

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, after server.boot(). Before boot it is not set yet, because the provider registers during boot.

Log in the browser

The provider also runs 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 browser console as console.info('[app] project archived', { projectId }): the matching console method for the level (debug, info, warn, error), the message, and the fields as an object you can expand, with no colour codes or JSON strings. Set format or write to get 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/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 then the error is rethrown. The line itself does not say it failed, so log the error separately when you catch it.

Change the level at run time

level is a property you can set. Turn on debug output in a running process without a restart:

// 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 it is created. Changing the root logger affects children created afterwards, such as the next request's logger, but not children that already exist. Protect a route like this one with auth.

Choose the output format

pretty is for terminals: a coloured, padded level, the time in UTC to the millisecond, [name], the message, the remaining fields as dim JSON, and the stack of an error on the following lines. json is for log services: one JSON.stringify(record) per line, with keys in this order: level, time (ISO 8601), name, the bindings, the 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 (Fly, Render, Railway, Kubernetes) collect stdout and stderr, and parse JSON lines into searchable fields. With json in production you need nothing else.

Send logs to a file or a service

write(line, record) replaces the console. It receives the formatted line and the record object, and runs 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)
  },
})

write is called synchronously from every log call, so keep it fast. 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 })

A write that throws throws out of the logger.info() call that triggered it, so catch inside it.

Create a logger without the provider

createLogger(options) returns a logger with the same options as config/logger.ts. Use it in scripts, in a package of your own, 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 is a ready-made logger at level silent that writes nothing. Pass it where an API wants a logger and you want no output, as the server's own tests do with createServer({ logger: silentLogger }).

See logs in devtools

With devtools installed, the provider sends a copy of every record to the devtools Log panel under the logger channel, so server and client lines sit in one place during development. trace and debug appear as debug entries, fatal as errors. The fields show as the entry's data.

While devtools is active and you have not set write, every line goes to console.log, errors included, rather than splitting errors to stderr.

Testing

createLogger({ write }) lets a test collect records and assert on them. To test code that reads the logger from the container, boot an app with the provider and a write that collects:

// 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, so assertions are on fields:

Field Example assertion
level expect(records[0]?.level).toBe('warn')
msg expect(records.map((r) => r.msg)).toContain('order shipped')
name expect(records[0]?.name).toBe('orders')
your fields and bindings expect(records[0]).toMatchObject({ orderId: '1042' })
err expect(records[0]?.err).toMatchObject({ code: 'ECONNREFUSED' })
durationMs expect(typeof records[0]?.durationMs).toBe('number')

To check the exact text a line produces, assert on the first argument of write instead of the record. To keep a noisy test quiet, configure level: 'silent' or pass 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 })
})

Next steps

  • Logger API reference: every export with its signature.
  • Server: request ids, the request logger and error logging.
  • Monitoring: alerts with user and breadcrumbs when something breaks.
  • Devtools: the Log panel that mirrors every line in development.
  • Jobs: what the worker logs when a job retries or fails.
  • Configuration: how config/logger.ts and env variables are loaded.