Skip to content
NestJS ns interceptors 4 min read

Logging & Timing Interceptor

One of the most common uses for a NestJS interceptor is observability: recording which handler ran, how long it took, and whether it succeeded. Because interceptors wrap a handler before it executes and after its response stream completes, they are the ideal place to take a Date.now() reading on the way in and another on the way out. This page builds a reusable LoggingInterceptor that logs each request and reports its latency using RxJS operators.

How an interceptor sees the timeline

An interceptor’s intercept method receives an ExecutionContext and a CallHandler. Calling next.handle() returns an Observable of the route handler’s eventual response. The key insight is that everything before next.handle() runs prior to the handler, while operators piped onto the returned stream run after the handler emits. That gives us two natural hook points around a single Date.now() delta.

The cleanest operator for side effects that must not alter the response is tap. It lets us observe the next, error, and complete notifications without touching the value flowing through the pipe.

Building the interceptor

The interceptor pulls the HTTP request out of the context so we can log a meaningful method and URL, records a start timestamp, then taps the response stream to compute the elapsed time once the handler resolves.

// src/common/interceptors/logging.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  Logger,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Request } from 'express';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<Request>();
    const { method, url } = request;
    const handler = `${context.getClass().name}.${context.getHandler().name}`;
    const now = Date.now();

    this.logger.log(`-> ${method} ${url} (${handler})`);

    return next.handle().pipe(
      tap({
        next: () => {
          const ms = Date.now() - now;
          this.logger.log(`<- ${method} ${url} ${ms}ms`);
        },
        error: (err: Error) => {
          const ms = Date.now() - now;
          this.logger.error(`xx ${method} ${url} ${ms}ms - ${err.message}`);
        },
      }),
    );
  }
}

Using the tap object form (with next, error, and complete callbacks) means a thrown exception still gets timed and logged, rather than silently bypassing the success branch.

Applying it globally

Register the interceptor once in your root module so every route is measured. Binding through a provider token lets Nest resolve its dependencies (such as the Logger) from the DI container.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { UsersModule } from './users/users.module';

@Module({
  imports: [UsersModule],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
})
export class AppModule {}

A trivial controller is enough to see it in action:

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

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id, name: 'Ada Lovelace' };
  }
}

Hitting the endpoint produces paired enter/exit log lines with the measured duration:

curl http://localhost:3000/users/42

Output:

[Nest] 8123  - 06/14/2026, 9:41:02 AM   LOG [LoggingInterceptor] -> GET /users/42 (UsersController.findOne)
[Nest] 8123  - 06/14/2026, 9:41:02 AM   LOG [LoggingInterceptor] <- GET /users/42 6ms

Where to bind it

Interceptors can be attached at several scopes. The choice affects which requests are timed and how dependencies are injected.

BindingScopeNotes
APP_INTERCEPTOR providerGlobalDI-friendly; covers every route. Preferred for cross-cutting logging.
app.useGlobalInterceptors(new ...)GlobalInstantiated outside DI, so no injected providers.
@UseInterceptors() on a controllerPer-controllerTimes only that controller’s routes.
@UseInterceptors() on a methodPer-handlerTimes a single endpoint.

Tip: Prefer the APP_INTERCEPTOR provider form over useGlobalInterceptors. Only the provider form participates in dependency injection, so the interceptor can request services like a metrics client or a request-scoped context.

Gotcha: Date.now() measures wall-clock time inside the Nest pipeline only. It excludes time spent in middleware and guards that run before interceptors, so it is not a substitute for full end-to-end APM tracing.

Best practices

  • Use the object form of tap so both success and error paths are timed and logged consistently.
  • Derive the handler name from context.getClass() and context.getHandler() to make logs self-describing without hardcoding route strings.
  • Keep the interceptor side-effect-only inside tap — never mutate or replace the response value here; use a transformation interceptor for that.
  • Bind globally via APP_INTERCEPTOR so coverage cannot drift as new controllers are added.
  • For high-traffic services, log at debug level or sample requests to avoid flooding logs; reserve error for failures.
  • Guard against non-HTTP contexts (WebSocket, RPC) by checking context.getType() before calling switchToHttp() if the interceptor is shared across transports.
Last updated June 14, 2026
Was this helpful?