Exception filters and structured logging
One global @Catch() filter maps every throw to a consistent JSON error envelope with a requestId; a structured JSON logger plus an AsyncLocalStorage correlation id lets you trace one request across many log lines and services.
2:14am, an on-call page: “checkout intermittently 500s.” You open the logs and find a wall of console.log lines with no timestamps, no request boundaries, and no way to tell which line belongs to the one request that failed. The error itself? A handler caught it, logged err.message with a bare console.error, then re-threw, so the same stack got logged three times by three layers — and the response that went back to the client was an HTTP 200 with {"error":"something went wrong"}, because someone called res.json(...) and forgot res.status(...). You cannot grep your way out of this. The problem is not the bug; it is that the service has no error boundary and no correlation. You have to build both before you can even see the bug.
The global filter: one error boundary, one envelope
When anything in the pipeline throws, Nest’s built-in exception layer catches it: HttpException and its subclasses map to their status code, and everything else becomes a 500. A custom @Catch() filter registered globally lets you own that response shape once instead of formatting errors by hand in nineteen handlers. The contract: read the status, emit one consistent JSON envelope, set the status correctly.
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { getRequestId } from './request-context';
@Catch() // no argument -> catches EVERYTHING, not just HttpException
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const requestId = getRequestId();
// Log ONCE, here at the boundary, with full context — including the stack.
this.logger.error(
{ requestId, path: req.url, status, err: exception },
exception instanceof Error ? exception.stack : 'non-error thrown',
);
// Never leak internals on a 500: send a generic message in prod.
// getResponse() returns string | object (an HttpException payload is usually
// { statusCode, message, error }), so normalise it to the message string.
const detail =
exception instanceof HttpException ? exception.getResponse() : null;
const message =
status === HttpStatus.INTERNAL_SERVER_ERROR
? 'Internal server error'
: typeof detail === 'string'
? detail
: ((detail as { message?: unknown }).message ?? 'Error');
res.status(status).json({ // <-- the .status() is load-bearing
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: req.url,
requestId,
});
}
}Two senior details. First, res.status(status).json(...) — call res.json(...) without res.status(...) and Express defaults the HTTP status to 200, so a failing request ships a success status with an error body, and every client that branches on the status code treats the failure as a success. Second, on a 500 you return a generic "Internal server error" to the client but log the full stack with context server-side: leaking a stack trace or a raw err.message (which may contain a SQL fragment, a file path, or an internal hostname) is an information-disclosure bug. Map known domain errors to safe HttpException subclasses (NotFoundException, ConflictException); let everything else fall through to a deliberately vague 500.
Register it globally with the APP_FILTER token so it can use dependency injection:
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './all-exceptions.filter';
@Module({
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}Structured logging: JSON over console.log
Why does the format of your log lines matter at 2am when everything is on fire? Because a machine has to read them — and machines cannot parse free-form strings. console.log('user', userId, 'failed checkout') produces a string a human can read and a machine cannot. Replace the built-in logger with a structured JSON logger — nestjs-pino (a thin Nest wrapper over pino) is the common choice — set in bootstrap with bufferLogs: true so even the logs emitted during startup go through your logger once it is ready:
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger)); // swap Nest's default logger for pino
await app.listen(3000);
}
bootstrap();Now every log line is a JSON object — {"level":"error","time":...,"requestId":"...","msg":"..."} — that your log platform (Loki, CloudWatch, Datadog) parses into queryable fields. You can filter level >= warn, group by path, or pull every line for one requestId with a single query. That is impossible with free-form strings. Honour log levels (debug/log/warn/error) so prod can drop debug and you are not paying to store noise. (Nest’s own ConsoleLogger also supports new ConsoleLogger({ json: true }) if you do not want a dependency — but pino gives you redaction and far higher throughput.)
Correlation: one request id across every line
The missing piece in the hook was correlation: no way to tie the twelve log lines of one request together, let alone follow that request into a downstream service. The fix is a per-request id, generated once at the edge and stored in AsyncLocalStorage so any code — your filter, a deep service method, an outbound HTTP call — can read it without threading it through every function signature.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { Request, Response, NextFunction } from 'express';
export const als = new AsyncLocalStorage<{ requestId: string }>();
export const getRequestId = () => als.getStore()?.requestId ?? 'no-request-id';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Reuse an upstream id if present (trace across services), else mint one.
const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
res.setHeader('x-request-id', requestId);
als.run({ requestId }, () => next()); // store survives every await in the chain
}
}Because the middleware wraps next() inside als.run(...), the store is alive for the entire async lifetime of that request, and getRequestId() returns the same id in your logger and your filter without any parameter passing. Crucially, the middleware propagates an inbound x-request-id header rather than always minting a new one — so when service A calls service B with that header, both services log the same id and you can stitch one request’s journey across the whole system. Configure your structured logger to attach requestId to every line (pino’s mixin, or genReqId), and the correlation is automatic.
Where each responsibility lives
The whole lesson is one placement decision: error shape belongs in a global filter, error logging in a structured logger called once at that boundary, and correlation in request-scoped context. Get them in the wrong place and you get the hook: triple-logged stacks, a 200 on a failure, and un-traceable requests.
| Responsibility | Where it belongs | Why there | Anti-pattern |
|---|---|---|---|
| Error shape | Global @Catch() filter | Single sink for every throw → one envelope | Hand-formatting per handler |
| Status code | res.status(s).json(…) | Skipping .status() → Express defaults 200 | res.json(…) alone |
| Logging | Structured logger, once at the filter | JSON is queryable; one log avoids dup stacks | console.log in every layer |
| Correlation | Request id in AsyncLocalStorage | Same id on every line + downstream call | No id; un-traceable requests |
| 500 message | Generic to client, full stack to logs | Stack/err.message leaks internals | Returning the raw stack |
▸Why this works
Why AsyncLocalStorage and not just req? You could stash the id on req and pass req everywhere — but that means threading the request object through every service method, repository, and helper that might want to log, which couples your whole stack to the HTTP layer. AsyncLocalStorage gives you an ambient, request-scoped store that survives every await: a deep PaymentService.charge() can call getRequestId() with zero arguments and get the right id, even though it has no idea an HTTP request exists. It is the Node primitive that makes correlation possible without polluting signatures — the same idea as thread-local storage in other runtimes.
You need a consistent error response shape, useful logs, and the ability to trace one request across services. Where does error formatting and logging belong?
Your filter calls res.json({ statusCode: 500, message: 'oops' }) but never calls res.status(...). What HTTP status does the client receive?
A plain throw new Error('boom') (not an HttpException) reaches a filter decorated @Catch(HttpException). What happens?
- 01Describe a production-grade global exception filter: what it catches, the envelope it emits, how it sets the status, what it logs, and what it must NOT leak.
- 02Why structured JSON logging over console.log, and how does AsyncLocalStorage give you a correlation id across an entire request and across services?
Errors and observability come down to three placements. First, error shape: register one global AllExceptionsFilter through the APP_FILTER token, decorated @Catch() (no argument) so it catches everything; map HttpException to its status and everything else to 500, and emit one consistent JSON envelope { statusCode, message, timestamp, path, requestId } with res.status(status).json(…) — forget the .status() and Express ships a 200 on a failure. Second, logging: replace console.log with a structured JSON logger (nestjs-pino, set in bootstrap with bufferLogs: true and app.useLogger), because JSON lines are machine-queryable and levels let prod drop noise; log the error exactly once at the filter boundary with full context and the stack, so the same error isn’t logged three times. Don’t leak internals — return a generic message for 500s to the client while logging the full stack server-side, and map known domain errors to safe HTTP shapes. Third, correlation: a middleware mints or reuses a per-request id and stores it in AsyncLocalStorage so every log line and the error envelope carry the same id; propagate an inbound x-request-id header so one request is traceable across handlers and downstream services. Shape at the boundary, log once, correlate by id. Now when you see a wall of unrelated log lines the next time an on-call page fires, the first thing you reach for is that requestId — and everything falls into place.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.