Frameworks

NestJS

Source Code
`EvlogModule.forRoot()` registers the middleware, the exception filter and `useLogger()` across a NestJS app, with async config where you need it.

The evlog/nestjs module provides EvlogModule.forRoot() which registers a global middleware, creating a request-scoped logger accessible via useLogger() or req.log, emitting a wide event when the response completes.

Set up evlog in my NestJS app

Quick Start

1. Install

pnpm add evlog @nestjs/common @nestjs/core @nestjs/platform-express

2. Create evlog.config.ts

evlog.config.ts
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'my-api',
})

Every option lives in this file: sampling, drain, enrich, route filters. npx evlog init writes it, and the wiring below, for you. See Configuration.

3. Register the module

src/app.module.ts
import { Module } from '@nestjs/common'
import { toMiddlewareOptions } from 'evlog'
import { EvlogModule } from 'evlog/nestjs'
import config from '../evlog.config'

@Module({
  imports: [
    EvlogModule.forRoot(toMiddlewareOptions(config)),
  ],
})
export class AppModule {}

4. Bootstrap with evlog

src/main.ts
import 'reflect-metadata'
import { NestFactory } from '@nestjs/core'
import { initLogger, toLoggerConfig } from 'evlog'
import { AppModule } from './app.module'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

const app = await NestFactory.create(AppModule)
await app.listen(3000)

EvlogModule.forRoot() registers as a global module, so the middleware is automatically applied to all routes.

Fastify Platform

The module works the same on @nestjs/platform-fastify. Install it in place of @nestjs/platform-express and pass the adapter to NestFactory.create():

src/main.ts
import 'reflect-metadata'
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter } from '@nestjs/platform-fastify'
import type { NestFastifyApplication } from '@nestjs/platform-fastify'
import { initLogger } from 'evlog'
import { AppModule } from './app.module'

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

const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())
await app.listen(3000)

Nest runs middleware on the raw Node request, so on Fastify the module also registers a preHandler hook that sets the evlog logger on FastifyRequest.log, in place of Fastify's own request logger. Guards, interceptors, pipes, and controllers all get the evlog logger from request.log. Fastify onRequest hooks run before it and still see Fastify's logger.

Do not register the evlog/fastify plugin on the same app: each one emits its own wide event, so every request is logged twice.

Wide Events

Build up context progressively through your controllers and services. One request = one wide event:

src/users.controller.ts
import { Controller, Get, Param } from '@nestjs/common'
import { useLogger } from 'evlog/nestjs'

@Controller('users')
export class UsersController {
  @Get(':id')
  async findOne(@Param('id') id: string) {
    const log = useLogger()

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

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

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

    return { 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-...

useLogger()

Use useLogger() to access the request-scoped logger from anywhere in the call stack without injecting the request object through your service layer:

src/users.service.ts
import { useLogger } from 'evlog/nestjs'

export class UsersService {
  async findUser(id: string) {
    const log = useLogger()
    log.set({ user: { id } })

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

    return user
  }
}
src/users.controller.ts
@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findUser(id)
  }
}

Both req.log and useLogger() return the same logger instance. useLogger() uses AsyncLocalStorage to propagate the logger across async boundaries.

Background work (log.fork)

Use req.log.fork(label, fn) (or the logger from useLogger() in the same request) for child wide events. See After emit in Wide events.

src/orders.controller.ts
import { useLogger } from 'evlog/nestjs'

@Post()
create(@Req() req: Express.Request) {
  req.log.fork!('enqueue', async () => {
    const log = useLogger()
    log.set({ queued: true })
  })
  return { ok: true }
}

Error Handling

Use createError for structured errors with why, fix, and link fields. Create a NestJS exception filter to log and format errors:

src/evlog-exception.filter.ts
import { Catch } from '@nestjs/common'
import type { ExceptionFilter, ArgumentsHost } from '@nestjs/common'
import { parseError } from 'evlog'
import { useLogger } from 'evlog/nestjs'

@Catch()
export class EvlogExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse()
    const error = exception instanceof Error ? exception : new Error(String(exception))

    try {
      useLogger().error(error)
    } catch {
      // Outside an evlog request scope — log is unavailable
    }

    const parsed = parseError(error)
    response.status(parsed.status).json({
      message: parsed.message,
      why: parsed.why,
      fix: parsed.fix,
      link: parsed.link,
    })
  }
}

Apply it to your controllers:

src/checkout.controller.ts
import { Controller, Get, UseFilters } from '@nestjs/common'
import { createError } from 'evlog'
import { EvlogExceptionFilter } from './evlog-exception.filter'

@Controller()
@UseFilters(new EvlogExceptionFilter())
export class CheckoutController {
  @Get('checkout')
  checkout() {
    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',
    })
  }
}

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
  └─ requestId: 880a50ac-...

Configuration

Every option goes in evlog.config.ts. See the Configuration reference for the full list.

Drain & Enrichers

Set drain and enrich in evlog.config.ts so they reach the app through toMiddlewareOptions(config). You can also pass them inline in EvlogModule.forRoot(); an inline key replaces the one from the file instead of merging with it:

src/app.module.ts
import { Module } from '@nestjs/common'
import { EvlogModule } from 'evlog/nestjs'
import { createAxiomDrain } from 'evlog/axiom'
import { createUserAgentEnricher } from 'evlog/enrichers'

const userAgent = createUserAgentEnricher()

@Module({
  imports: [
    EvlogModule.forRoot({
      drain: createAxiomDrain(),
      enrich: (ctx) => {
        userAgent(ctx)
        ctx.event.region = process.env.FLY_REGION
      },
    }),
  ],
})
export class AppModule {}

Async Configuration

Use forRootAsync() when options depend on other providers (e.g. ConfigService):

src/app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { EvlogModule } from 'evlog/nestjs'
import { createAxiomDrain } from 'evlog/axiom'

@Module({
  imports: [
    ConfigModule.forRoot(),
    EvlogModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        drain: createAxiomDrain({ apiKey: config.get('AXIOM_API_KEY') }),
      }),
    }),
  ],
})
export class AppModule {}

Pipeline (Batching & Retry)

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

src/app.module.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())

EvlogModule.forRoot({ 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/app.module.ts
EvlogModule.forRoot({
  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/app.module.ts
EvlogModule.forRoot({
  include: ['/api/**'],
  exclude: ['/_internal/**', '/health'],
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
  },
})

Run Locally

Terminal
git clone https://github.com/evloghq/evlog.git
cd evlog
pnpm install
pnpm example nestjs

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

Source Code

Browse the complete NestJS example source on GitHub.

Next Steps

Deepen your NestJS 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