Use Cases

Structured Logging in Node.js

Build a Node.js HTTP handler with structured request logs, explicit outcomes, error context, and a JSON event you can query.

A checkout fails. You have the request ID from the client. Your logs should let you find the payment amount, the outcome, and the reason for the decline together.

Build a small Node.js HTTP server that records those details in one JSON wide event per request. You will run a successful checkout and a declined payment, then match each response to its log. The example uses Node’s built-in HTTP server so you can see the full logging lifecycle.

Run a complete HTTP handler

Install evlog, then save the server below. It uses the Node.js HTTP server and no framework. Node.js 24 can run this TypeScript file directly:

Terminal
npm install evlog
node server.ts

The /checkout route simulates a successful payment. Add ?decline=1 to exercise the error path. The payment IDs are fixture data, so no payment provider or credentials are needed.

server.ts
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { createError, createLogger, initLogger } from 'evlog'

initLogger({ env: { service: 'checkout' }, pretty: false })

createServer((req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  const requestId = randomUUID()
  const log = createLogger({ requestId, method: req.method, path: url.pathname })
  res.setHeader('x-request-id', requestId)
  res.setHeader('content-type', 'application/json')

  try {
    if (req.method !== 'POST' || url.pathname !== '/checkout') {
      res.statusCode = 404
      log.set({ outcome: 'not-found' })
      res.end(JSON.stringify({ error: 'Not found', requestId }))
      return
    }

    log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
    if (url.searchParams.get('decline') === '1') {
      throw createError({
        message: 'Payment declined',
        status: 402,
        why: 'The simulated card has insufficient funds',
        fix: 'Try another payment method',
      })
    }

    log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
    res.statusCode = 200
    res.end(JSON.stringify({ orderId: 'order-123', requestId }))
  } catch (error) {
    log.error(error instanceof Error ? error : new Error(String(error)))
    log.set({ outcome: 'declined' })
    res.statusCode = 402
    res.end(JSON.stringify({ error: 'Payment declined', requestId }))
  } finally {
    log.emit({ status: res.statusCode })
  }
}).listen(3000)

Send both requests from another terminal:

Terminal
curl -i -X POST http://localhost:3000/checkout
curl -i -X POST 'http://localhost:3000/checkout?decline=1'

Read the result

The response's x-request-id header matches the event's requestId. The successful event includes the following fields, plus environment metadata and timing fields:

Successful event, selected fields
{
  "level": "info",
  "service": "checkout",
  "method": "POST",
  "path": "/checkout",
  "orderId": "order-123",
  "payment": { "amount": 2999, "currency": "EUR", "chargeId": "ch_123" },
  "outcome": "paid",
  "status": 200
}

The declined request emits an error event with status: 402, outcome: 'declined', and the error's message, reason, and suggested fix. Both paths include durationMs, measured from logger creation to emission. This example measures handler work, not confirmation that the client received the response.

Move from the example to your application

Choose the fields that will help you debug this operation, such as order IDs, payment outcomes, and timings. Keep credentials and unnecessary personal data out of the context, and use Redaction for sensitive fields you retain.

The finally block emits on normal returns and exceptions through this handler. It cannot guarantee delivery if the process is terminated.

pretty: false forces JSON in this example. Without that override, evlog selects pretty output in development and JSON in production.

For Express, Fastify, NestJS, or Hono, use a framework integration to manage request logging. For remote delivery, choose a drain adapter and its batching/retry configuration. Short-lived processes must await pending delivery as described in Standalone TypeScript.

To keep field names and values consistent across handlers, continue with Typed Fields. The Logging Lifecycle explains how framework integrations collect context and emit events for you.