Frameworks

Hono

Source Code
Automatic wide events, structured errors, drain adapters, enrichers, and tail sampling in Hono applications.

The evlog/hono middleware auto-creates a request-scoped logger accessible via c.get('log') — or useLogger() anywhere deeper in the call stack — and emits a wide event when the response completes.

Set up evlog in my Hono app

Quick Start

1. Install

pnpm add evlog hono @hono/node-server

2. Initialize and register the middleware

src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { initLogger } from 'evlog'
import { evlog, type EvlogVariables } from 'evlog/hono'

initLogger({
  env: { service: 'my-api' },
})

const app = new Hono<EvlogVariables>()

app.use(evlog())

app.get('/health', (c) => {
  c.get('log').set({ route: 'health' })
  return c.json({ ok: true })
})

serve({ fetch: app.fetch, port: 3000 })
Using Vite? The evlog/vite plugin replaces the initLogger() call with compile-time auto-initialization, strips log.debug() from production builds, and injects source locations.

The EvlogVariables type gives you typed access to c.get('log') across all route handlers.

Wide Events

Build up context progressively through your handler. One request = one wide event:

src/index.ts
app.get('/users/:id', async (c) => {
  const log = c.get('log')
  const userId = c.req.param('id')

  log.set({ user: { id: userId } })

  const user = await db.findUser(userId)
  log.set({ user: { name: user.name, plan: user.plan } })

  const orders = await db.findOrders(userId)
  log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } })

  return c.json({ user, orders })
})

All fields are merged into a single wide event emitted when the request completes:

Terminal output
14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms
  ├─ orders: count=2 totalRevenue=6298
  ├─ user: id=usr_123 name=Alice plan=pro
  └─ requestId: 4a8ff3a8-...

Accessing the logger deeper in the stack

c.get('log') is the idiomatic accessor inside a route handler. Once you are a few layers down — a service, a repository — threading c everywhere gets noisy. useLogger() resolves the same request logger without it:

src/services/payment.ts
import { useLogger } from 'evlog/hono'

export async function chargeCard(amount: number) {
  const log = useLogger()
  log.set({ payment: { amount } })
}

Both return the same logger — pick whichever reads better at the call site.

useLogger() is backed by AsyncLocalStorage. On Cloudflare Workers this requires the nodejs_compat (or nodejs_als) compatibility flag in wrangler.toml. c.get('log') works with or without it, so deployments that cannot enable the flag keep using it.

Background work

log.fork() runs work under a child logger that emits its own wide event, correlated to the request via _parentRequestId:

src/index.ts
app.post('/api/orders', (c) => {
  c.get('log').fork('send-receipt', async () => {
    await sendReceipt()
    useLogger().set({ email: { sent: true } })
  })
  return c.json({ ok: true })
})

If you schedule async work after the response without forking, post-emit [evlog] warnings help you notice stale set() calls. See Wide events — After emit.

Error Handling

Use createError for structured errors with why, fix, and link fields:

src/index.ts
import { createError, parseError } from 'evlog'

app.get('/checkout', (c) => {
  const log = c.get('log')
  log.set({ cart: { items: 3, total: 9999 } })

  throw createError({
    message: 'Payment failed',
    status: 402,
    why: 'Card declined by issuer',
    fix: 'Try a different payment method',
    link: 'https://docs.example.com/payments/declined',
  })
})

Handle errors globally with app.onError to return structured JSON responses:

src/index.ts
import type { ContentfulStatusCode } from 'hono/utils/http-status'

app.onError((error, c) => {
  c.get('log').error(error)
  const parsed = parseError(error)

  return c.json(
    {
      message: parsed.message,
      why: parsed.why,
      fix: parsed.fix,
      link: parsed.link,
    },
    parsed.status as ContentfulStatusCode,
  )
})

parseError() types status as a number, while Hono’s c.json() second argument expects ContentfulStatusCode. The cast matches what you already return at runtime and satisfies TypeScript.

The error is captured and logged with both the custom context and structured error fields:

Terminal output
14:58:20 ERROR [my-api] GET /checkout 402 in 3ms
  ├─ error: name=EvlogError message=Payment failed status=402
  ├─ cart: items=3 total=9999
  └─ requestId: 880a50ac-...

Configuration

See the Configuration reference for all available options (initLogger, middleware options, sampling, silent mode, etc.).

Drain & Enrichers

Configure drain adapters and enrichers directly in the middleware options:

src/index.ts
import { createAxiomDrain } from 'evlog/axiom'
import { createUserAgentEnricher } from 'evlog/enrichers'

const userAgent = createUserAgentEnricher()

app.use(evlog({
  drain: createAxiomDrain(),
  enrich: (ctx) => {
    userAgent(ctx)
    ctx.event.region = process.env.FLY_REGION
  },
}))

Pipeline (Batching & Retry)

For production, wrap your adapter with createDrainPipeline to batch events and retry on failure:

src/index.ts
import type { DrainContext } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 50, intervalMs: 5000 },
  retry: { maxAttempts: 3 },
})
const drain = pipeline(createAxiomDrain())

app.use(evlog({ drain }))
Call drain.flush() on server shutdown to ensure all buffered events are sent. See the Pipeline docs for all options.

Tail Sampling

Use keep to force-retain specific events regardless of head sampling:

src/index.ts
app.use(evlog({
  drain: createAxiomDrain(),
  keep: (ctx) => {
    if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
  },
}))

Route Filtering

Control which routes are logged with include and exclude patterns:

src/index.ts
app.use(evlog({
  include: ['/api/**'],
  exclude: ['/_internal/**', '/health'],
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
  },
}))

Client-Side Logging

Use evlog/http to send structured logs from any frontend to your Hono server. This works with any client framework (React, Vue, Svelte, vanilla JS).

Browser setup

client.ts
import { initLogger, log } from 'evlog'
import { createHttpLogDrain } from 'evlog/http'

const drain = createHttpLogDrain({
  drain: { endpoint: '/v1/ingest' },
})
initLogger({ drain })

log.info({ action: 'page_view', path: location.pathname })

Ingest endpoint

Add a POST route to receive batched DrainContext[] from the browser:

src/index.ts
import type { DrainContext } from 'evlog'

app.post('/v1/ingest', async (c) => {
  const batch = await c.req.json<DrainContext[]>()
  for (const ctx of batch) {
    console.log('[BROWSER]', JSON.stringify(ctx.event))
  }
  return c.body(null, 204)
})
See the full HTTP drain adapter docs for batching, retry, sendBeacon fallback, and authentication options.

Run Locally

Terminal
git clone https://github.com/hugorcd/evlog.git
cd evlog
pnpm install
pnpm run example:hono

Open http://localhost:3000 to explore the interactive test UI.

Source Code

Browse the complete Hono example source on GitHub.

Next Steps

Deepen your Hono integration:

  • Wide Events: Design comprehensive events with context layering
  • Adapters: Send logs to Axiom, Sentry, PostHog, and more
  • Sampling: Control log volume with head and tail sampling
  • Structured Errors: Throw errors with why, fix, and link fields