Nest gives you a solid answer to "what should the client see when something goes wrong". That's what exception filters are for. What it intentionally doesn't cover is the other half: what should you see, and when?
I want to go through both here. How errors move through a Nest app, the one distinction that makes error data useful instead of noisy, and how to get from "an exception was thrown" to "we know about it, we know it's new, and we know which line".
How an error travels through Nest
Anything thrown in a guard, pipe, interceptor, controller or provider bubbles up to the exceptions layer, where the built-in ExceptionsHandler (a BaseExceptionFilter) deals with it. If it's an HttpException (or a subclass like NotFoundException or BadRequestException), the client gets that status and response body. If it's anything else, say a TypeError, a driver error or a rejected promise, the client gets a plain 500 Internal Server Error and the details stay on the server. One exception to that: errors that look like HTTP errors (anything with a numeric statusCode and a message, such as http-errors objects or a FastifyError) pass their status and message through. A third-party SDK error that happens to carry a statusCode can end up in front of your client that way.
The handler also decides what gets logged, and the line it draws is worth knowing. Every HttpException extends IntrinsicException, and the default filter logs everything that isn't intrinsic, with the stack and the cause chain, under the ExceptionsHandler context. A NotFoundException is never logged. A TypeError always is. You can use the same line yourself: extend IntrinsicException for a failure that is expected and shouldn't produce a log entry.
That default is a useful baseline. If the response shape needs to be consistent with the rest of your API, don't replace it, extend it:
@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// a global @Catch() filter also receives WebSocket and RPC exceptions
if (host.getType() !== 'http' || !(exception instanceof HttpException)) {
// keeps the opaque 500 and the ExceptionsHandler log line
return super.catch(exception, host);
}
const ctx = host.switchToHttp();
const status = exception.getStatus();
this.httpAdapterHost!.httpAdapter.reply(
ctx.getResponse(),
{
statusCode: status,
errorCode: exception.errorCode,
// getResponse(), not .message: for a ValidationPipe error, .message is
// "Bad Request Exception" and the per-field messages live in here
details: exception.getResponse(),
},
status,
);
}
}
// app.module.ts
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }];
Two details matter more than the shape. A filter that implements ExceptionFilter from scratch silently loses the ExceptionsHandler log line, so after adopting it your unhandled errors vanish from the logs. Extending BaseExceptionFilter and delegating keeps it. And keep the error status. A filter that catches everything and returns 200 { ok: false } makes future failures much harder to detect.
Nest 12 also lets you attach a machine-readable code and a cause to any HttpException:
throw new ConflictException('Email already registered', {
errorCode: 'EMAIL_TAKEN',
cause: err,
});
The built-in exceptions don't put errorCode into the response body, which is why the filter above does. And be careful with cause on the 5xx ones. new InternalServerErrorException('Sync failed', { cause: err }) looks like good hygiene, but it's intrinsic, so Nest no longer logs it, and the original error, now only a cause, goes nowhere. If you don't have something specific to tell the client, let the real error propagate.
Handled vs unhandled
This is the part a filter can't help you with, and it decides whether your error data is worth looking at.
A NotFoundException thrown because somebody asked for order 999 is not a bug. It's your API answering a question correctly. A ConflictException on a duplicate email is a business rule working. If those land in the same bucket as TypeError: Cannot read properties of undefined, your error rate sits at 4% forever and nobody looks at it anymore. That's exactly the state in which a real regression goes unnoticed for a week.
It helps to split them. Handled errors are the ones you threw intentionally, usually 4xx responses that represent a rejected request or business rule. They generally should not page someone, although a sudden increase can be useful to investigate. Unhandled errors are unexpected failures that normally become 500 responses and deserve prompt attention when they first appear.
NestJS Observe does this split for you. Over HTTP it goes by the response: a 4xx is handled, a 5xx is unhandled. So a NotFoundException is handled, and a TypeError that became a 500 is unhandled. On transports without a status code (microservices, WebSocket gateways, GraphQL, queue jobs) it uses the same line Nest does: an IntrinsicException, which includes every HttpException, is handled, and anything else is unhandled. The two are counted and charted separately, so the unhandled count is the number that means "stuff that's broken".
The dashboard calls the handled bucket "Intrinsic", after the Nest class that draws the line.
Handled errors are still requests, though, and by default each one ships its full trace: every guard, pipe, service call and query behind a request you turned away on purpose. If your API leans on exceptions for control flow (a lot of 404s from crawlers, 401s from expired tokens, 400s from the ValidationPipe), that's a lot of waterfalls nobody opens, and every span is a metered event. skipSpans (SDK 0.3.3 and later) reports those outcomes without their span trees:
ObserveModule.forRoot({
// ...
skipSpans: [400, 401, 403, 404],
});
The request itself is still recorded, with its route, status, duration, user and error, stack included, so it stays in every chart and on the Errors page. Only the tree underneath it is dropped. It also takes a function, for something like "skip 404s, but keep the slow ones": ({ statusCode, duration }) => statusCode === 404 && duration < 1000. Jobs always keep their spans.
There's a third kind worth mentioning: errors you caught yourself. A try/catch around a payment sync that logs and moves on never reaches the exceptions layer, so nothing downstream knows it happened. If it matters, report it:
constructor(
private readonly billing: BillingService,
private readonly tracer: TracerService, // from @nestjs/observe
) {}
async syncAccount(accountId: string) {
try {
await this.billing.sync(accountId);
} catch (error) {
await this.tracer.captureError(
error instanceof Error ? error : new Error(String(error)),
{ accountId, retryable: true },
);
}
}
The error is attached to the span that was running when you caught it, with your tags, so it shows up in that request's trace next to everything else that happened. The request itself still succeeds, so it doesn't count toward your error numbers and won't trigger a new-error email. If a swallowed failure should wake someone up, rethrow it. And captureError needs a traced operation around it; outside one (in onModuleInit, say) it throws.
3,140 errors is usually one bug
You ship a deploy with a null dereference in a hot path. Ten minutes later you have 3,140 errors. That's not 3,140 problems, and an inbox or a log stream that treats it that way is useless.
Error tracking groups occurrences into defects. Same exception class plus the same normalised stack shape means the same bug, no matter how the message was worded or which line numbers moved between deploys. Then the group carries what you need for triage: when it was first and last seen, which release introduced it, how often it fires in the window you're looking at, and a status (open, resolved, ignored).
Resolved and ignored are different states. Resolved describes a fix, so a recurrence should reopen the group automatically. Ignored describes a decision about noise, so it should remain ignored unless that decision changes.
"Introduced in" works at the release level too. Observe's Releases page gives each deploy a verdict (Regression, Changed, Clean, or Not enough data) and lists the error groups first seen in it as part of the evidence, so "what did this deploy break" is one page, not a search.
Hear about new defects, not every occurrence
An alert for every error occurrence is often noisy. A more useful starting point is an alert for an unhandled defect that has not appeared before.
Observe sends exactly that as an email to the project's members, on every plan including the free one. It fires the first time a new unhandled defect appears. A thrown 404 will never trigger it. A defect that fires ten thousand times triggers it once. New defects that show up together are batched into one email, there are at most six a day per project, and each member can turn it off for themselves in settings.
Anything fancier (error rate thresholds, new defects per hour, routing to Slack or a webhook) is what alert rules are for, and those are on the paid plans.
From a defect to a fix you can trust
Finding the defect is half the job. The other half is tracking it until it's fixed, and knowing that the fix worked rather than assuming it.
From an error group you can promote it to an issue in one click. (Promoting it again while that issue is open links to the existing one instead of opening a duplicate.) The issue opens with an incident record, assembled from the telemetry rather than typed up afterwards: what failed, since when, the release that introduced it, how many users hit it and which customer accounts they belong to, and the release that fixed it. Copy summary turns it into plain text for the postmortem or the note to a customer.
Then comes the question every fix deserves and few get: did it actually work? Pick a release on the issue, and Observe checks that release's own requests on the routes where the error used to fail, against how often it failed there before:
- Fixed: none of the release's requests failed with it, where the earlier rate predicted several would.
- Not fixed: the release's requests still fail with it.
- Too early to tell: not enough of the release's traffic yet.
- Not enough data: the error is too rare or too bursty to judge.
The card also lists new error groups first seen on the same routes in that release, and that line is worth reading before you celebrate. A fix that only moved where the exception is thrown produces a new fingerprint, so the old group goes quiet while the bug carries on under a new name. Once you mark the issue resolved, Observe keeps watching that operation against its pre-incident baseline for up to seven days.
Make the stack trace point at your code
A production stack trace that says dist/main.js:1:20481 isn't very helpful. The fix is in Node, not in any tool: emit source maps in your build (the Nest starter's tsconfig.json already has "sourceMap": true) and start the process with --enable-source-maps, or call process.setSourceMapsEnabled(true) first thing in main.ts. Then every stack trace, in your logs and everywhere else, points at src/orders/orders.service.ts:42.
Observe goes one step further and attaches the failing line and a few lines around it, read from the process that threw:
This is on by default: five lines either side, for up to five of your own frames. Only your application's frames are read, never node_modules, and the lines go through the same redaction as log lines before they leave the process. Those lines are read from disk at the path in the stack trace, which with --enable-source-maps is src/.... So a multi-stage Docker image that ships only dist/ has nothing to read and shows the trace without the code: copy src/ into the runtime image too. If you can't run with --enable-source-maps, sourceContext: { sourceMaps: true } makes the agent resolve the maps itself so the captured lines are TypeScript rather than compiled JavaScript (the stack trace text stays as Node printed it). And sourceContext: false turns it off completely, if shipping even a few lines of source isn't OK for your codebase:
export const { ObserveModule, ObserveInstrument } = createObserveModule({
sourceContext: { sourceMaps: true },
});
Keep the error and the request together
The most useful thing about an error report is when it isn't separate from the request that produced it. Same page: the exception, the route and status, the user it happened to (once you tell Observe how to find it, with http.getUserId), the trace of what ran before it failed, and the log lines written during that request, in order (with forwardLogs on, which is on the paid plans).
That's the difference between "TypeError in OrdersService.create" and "TypeError in OrdersService.create, 40 ms after a SELECT customers came back empty, for user 8812, on the release that went out at 14:02".
Not everything is HTTP
Three places where errors behave differently and tend to get missed.
Queue jobs. A failing BullMQ job never touches an exception filter. It fails, retries, and eventually lands in the failed set. In Observe, a failed job is recorded with its error and trace but doesn't open an error group or trigger the new-error email, so watch jobs with a job failure rate alert per job name, and keep an eye on attempts.
WebSocket gateways and microservice handlers. WsException and RpcException extend plain Error, not IntrinsicException, so Observe records them as unhandled, even when you threw one on purpose as a protocol-level "no". Don't swap in an HttpException to fix that: the built-in WebSocket and RPC filters answer anything that isn't a WsException or RpcException with a generic "Internal server error", so your clients lose the real reason. If the distinction matters, throw your own subclass of IntrinsicException and add a gateway or RPC filter that turns it into the payload your clients expect.
Outside any handler. A promise nobody awaited, a setTimeout callback, an event listener: these never reach an exceptions layer at all. They end up as unhandledRejection or uncaughtException, and Node's default is to crash the process. Observe doesn't hook those events either, so register handlers yourself that log through Nest's Logger with the stack and then exit, and let your orchestrator restart the instance. A crash loop is also something your deploy tooling should be alerting on.
What I'd check on any Nest app
Use the default exceptions layer, or a filter that extends BaseExceptionFilter: real statuses for HttpException, an opaque 500 and a log line for the rest. Make sure nothing swallows errors silently, and report the ones you catch and continue from. Count handled and unhandled separately. Group occurrences into defects, and let resolved ones reopen. Check that a fix worked instead of assuming it did. Make sure somebody gets told when a new unhandled defect appears. Get your stack traces to resolve to source. Keep every error one click away from its request, trace and logs. And don't forget the jobs, gateways, microservice handlers and the errors that never reach a handler at all.
The first two are code you write. Most of the rest is what an error tracking tool is for, and in a Nest app that's npm i @nestjs/observe, a module in AppModule and the instrument option.
You can poke at the error pages on a live project here: the demo dashboard.





Top comments (0)