Libraries

Emitting Events

Emit events from library code the host can attribute and filter: join the host's request, tag your own events, and stay out of the configuration.

Your package runs inside someone else's process. When it logs, the events land in the host's stream, pass through the host's redaction, and reach whatever drains the host configured. Three situations cover what a library emits: running inside a host request, running on its own (a CLI, a job, a script), and a dependency graph with more than one copy of evlog in it. The first is the one most packages hit first.

Join the host's request when you run inside one

Inside a host application, the framework integration already builds one wide event per request and emits it at the end of the lifecycle. A package that creates its own logger during a request forks that into two unrelated events, and the host loses the connection between its handler and your code. Instead, accept the request logger as a parameter and add your context to the event the host already owns:

src/checkout.ts
import type { AuditableLogger } from 'evlog'

export function chargePayment(log: AuditableLogger, amount: number): void {
  log.set({ payment: { amount, provider: 'stripe' } })
  // ...
}

The host resolves the request logger with useLogger() from the framework subpath it uses (evlog/hono, evlog/next, ...) or with the framework-native accessor, then passes it to your package. Every log.set() you call merges into the request's wide event, so the host sees the whole operation in one place: its handler, your call, and the error if one happened.

Design your API so the logger stays optional: chargePayment(log, amount) where the host passes what it has. A package that quietly reaches for an ambient logger cannot run inside a queue worker or a script, and the host has no way to hand you the request logger you skipped.

Request loggers from some framework integrations also carry log.fork() for background work that needs its own event, correlated with the parent request. Integrations attach it when they run a logger storage (Hono, oRPC, Express, Fastify, NestJS, SvelteKit, React Router, Next.js, Elysia); Nitro and Nuxt do not attach it yet. A standalone createLogger() instance never has it.

Emit your own events when there is no request

A CLI command, a sync job, or a script has no host request to join. Use the global log API for progress, and keep your package name in a field so the host can filter on it:

src/client.ts
import { log } from 'evlog'

export class ApiClient {
  async request(path: string): Promise<Response> {
    log.info({ source: 'mylib.client', message: `GET ${path}` })
    // ...
  }
}

It works whether or not the host configured evlog: without configuration it prints, pretty in development and JSON otherwise. The first segment of source is your package name and stays stable across releases. Treat it as a public identifier: hosts filter on it, and Testing shows how to lock it down.

When an operation deserves one wide event instead of individual messages, build it with createLogger and put the package name in the same field:

src/sync.ts
import { createLogger } from 'evlog'

export function syncRecords(records: number): void {
  const log = createLogger({ source: 'mylib', operation: 'sync-records' })
  log.set({ records: { total: records } })
  // ...
  log.emit()
}

source is a convention, not an API: pick one field name and document it in your package's README. If your fields benefit from compile-time checking, type the context with createLogger<SyncContext>(); Typed Fields covers the patterns.

Never configure from library code

Never call initLogger() from library code, and configure nothing at import time. initLogger() writes process-wide state shared by every evlog copy of the same major version, and the last call wins. A library that calls it replaces the host's drain, sampling, and redaction for the whole process, on a path the host's own code never executed.

Declare evlog as a peer dependency pinned to a single major, so your package and the host resolve compatible copies:

package.json
{
  "peerDependencies": {
    "evlog": "^2.0.0"
  }
}

Gotchas

Two behaviors are reference material rather than design decisions, and both bite once.

The tagged form skips the drain in development. log.info({ source, message }), the object form, always becomes a wide event through the host's pipeline: sampled, redacted, and delivered to the drain the host configured. log.info('mylib.client', 'GET /users'), the tagged form, prints through the host's pretty printer when pretty is on (the development default) and reaches neither a drain nor redaction there; in JSON mode it becomes a small wide event carrying tag and message. If the host may be draining, use the object form.

More than one copy of evlog can exist in the graph. Package managers hash optional peers differently across workspaces, so your package and the host can resolve different physical copies. Every 2.x copy registers the same process-wide slot, so an initLogger() from the host's copy configures yours too, and your emits flow through the host's drains. Two different majors in one process is the case evlog cannot fix: they cannot share request scope or configuration, events emitted through the other copy are undrained and unredacted, and evlog prints a warning when the second major registers. Deduplicate the graph to a single major when that warning appears; nothing in your package code changes. For errors, duplicate copies also change how the host catches yours, covered on Structured Errors.