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.
| Binding | Scope | Notes |
|---|---|---|
APP_INTERCEPTOR provider | Global | DI-friendly; covers every route. Preferred for cross-cutting logging. |
app.useGlobalInterceptors(new ...) | Global | Instantiated outside DI, so no injected providers. |
@UseInterceptors() on a controller | Per-controller | Times only that controller’s routes. |
@UseInterceptors() on a method | Per-handler | Times a single endpoint. |
Tip: Prefer the
APP_INTERCEPTORprovider form overuseGlobalInterceptors. 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
tapso both success and error paths are timed and logged consistently. - Derive the handler name from
context.getClass()andcontext.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_INTERCEPTORso coverage cannot drift as new controllers are added. - For high-traffic services, log at
debuglevel or sample requests to avoid flooding logs; reserveerrorfor failures. - Guard against non-HTTP contexts (WebSocket, RPC) by checking
context.getType()before callingswitchToHttp()if the interceptor is shared across transports.