Enhancing error logging in NestJS with Sentry
Error logs without contextual metadata make diagnosing failures in distributed production environments difficult and time-consuming. In high-concurrency NestJS services, plain console output fails to capture active request payloads, authenticated user identifiers, or execution traces needed to reproduce edge-case crashes.
Integrating Sentry using a custom NestJS logger alongside a global exception filter establishes automated error capture with rich HTTP execution context while preserving standard console streaming for container diagnostics.
The problem and production context
Standard backend logging typically writes unstructured error messages to stdout. When uncaught exceptions happen in production, operations teams lack the context necessary to reproduce the incident.
- Failure scenario: An unhandled exception occurs inside a nested controller handler processing a complex request. The service writes a generic error string to stdout without HTTP method, URL, headers, request body, or authenticated user identity. Engineers cannot determine which client payload triggered the failure, delaying incident mitigation.
-
Why default approaches fall short: NestJS
BaseExceptionFiltercatches unhandled exceptions and formats standard HTTP responses, but does not forward diagnostics to external telemetry backends. Manualtry/catchblocks inside controllers introduce boilerplate and risk omissions. - Production impact: Unhandled errors fail silently or clutter container logs without stack traces, increasing Mean Time to Resolution (MTTR) and obscuring recurring bugs.
Mental model and core concepts
A production-ready error monitoring pipeline separates structured runtime logging from uncaught exception filtering.
1. Dual-pipeline observability model
The architecture partitions telemetry into two distinct paths:
- Operational logging: Handled by extending
ConsoleLoggerto capture explicitlogger.error()andlogger.verbose()calls across application code. - Exception interception: Handled by a global NestJS exception filter implementing
BaseExceptionFilterto intercept unhandled application crashes.
2. Custom logger extension mechanics
Extending ConsoleLogger preserves local console logging for container stdout while packaging context parameters (such as execution stack and component names) into isolated Sentry scopes via Sentry.withScope() and Sentry.captureMessage().
3. Exception filter and HTTP context enrichment
The global filter intercepts unhandled exceptions, extracts the active Request object from ArgumentsHost, and enriches Sentry event scopes with:
- HTTP method and request URL.
- Request headers, route parameters, query strings, and body payloads.
- Authenticated user identity (
userId,userEmail,userName,userRole). - Nested exception response structures.
4. Global filter registration via APP_FILTER
Binding the exception filter using the APP_FILTER dependency injection token ensures that the filter participates in the NestJS dependency injection lifecycle and intercepts exceptions across all controllers globally.
5. SDK initialization and environment sampling
Initializing the Sentry SDK during bootstrap configures environment boundaries (development, staging, production), sampling rates, normalization depth, and profiling integrations before application modules begin accepting traffic.
6. File structure layout
The components are organized cleanly within the application source tree:
src/
utility/
logger/
sentry.logger.ts
sentry-exception.filter.ts
app.module.ts
main.ts
Production implementation
Install the required Sentry dependency:
npm install @sentry/node @sentry/profiling-node
Implement the custom logger, global exception filter, module registration, and bootstrap initialization.
import {
ArgumentsHost,
Catch,
ConsoleLogger,
Controller,
Get,
Injectable,
Logger,
Module,
Provider,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { APP_FILTER, BaseExceptionFilter, NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
interface AuthenticatedUser {
userId: string;
userEmail: string;
userName: string;
userRole: string;
}
interface RequestWithContext {
url: string;
method: string;
headers: Record<string, string>;
params: Record<string, string>;
query: Record<string, string>;
body?: Record<string, unknown>;
user?: AuthenticatedUser;
}
export class SentryLogger extends ConsoleLogger {
error(message: unknown, ...optionalParams: unknown[]): void {
const errorMessage = String(message ?? "");
let stack: unknown = "";
let context = "";
if (optionalParams.length === 1) {
context = String(optionalParams[0] ?? "");
}
if (optionalParams.length >= 2) {
stack = optionalParams[0];
context = String(optionalParams[1] ?? "");
}
const formattedMessage = context ? `${context}: ${errorMessage}` : errorMessage;
Sentry.withScope((scope) => {
scope.setExtra("message", errorMessage);
scope.setExtra("context", context);
scope.setExtra("stack", stack);
Sentry.captureMessage(formattedMessage, "error");
});
super.error(errorMessage, ...(optionalParams as []));
}
verbose(message: unknown, ...optionalParams: unknown[]): void {
const verboseMessage = String(message ?? "");
const context = String(optionalParams[0] ?? "");
const extra = optionalParams.slice(1);
const formattedMessage = context ? `${context}: ${verboseMessage}` : verboseMessage;
Sentry.withScope((scope) => {
scope.setExtra("message", verboseMessage);
scope.setExtra("context", context);
scope.setExtra("extra", extra);
Sentry.captureMessage(formattedMessage, "info");
});
super.verbose(verboseMessage, ...(extra as []));
}
}
@Catch()
export class SentryExceptionFilter extends BaseExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<RequestWithContext>();
Sentry.withScope((scope) => {
if (request) {
scope.setTag("url", request.url);
scope.setTag("method", request.method);
scope.setTag("environment", process.env.NODE_ENV ?? "development");
scope.setExtra("request", {
url: request.url,
method: request.method,
headers: request.headers,
params: request.params,
query: request.query,
body: request.body ?? {},
});
if (request.user) {
scope.setUser({
id: request.user.userId,
email: request.user.userEmail,
username: request.user.userName,
});
scope.setExtra("userRole", request.user.userRole);
}
}
if (typeof exception === "object" && exception !== null && "response" in exception) {
const exceptionResponse = (exception as { response?: unknown }).response;
scope.setExtra("response", exceptionResponse);
}
Sentry.captureException(exception);
});
super.catch(exception as never, host);
}
}
export const SentryExceptionFilterProvider: Provider = {
provide: APP_FILTER,
useClass: SentryExceptionFilter,
};
@Injectable()
export class AppService {
getHello(): string {
return "Hello World";
}
}
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [AppController],
providers: [AppService, SentryExceptionFilterProvider],
})
export class AppModule {}
const applicationLogger = new Logger("Bootstrap");
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
const dsn = configService.get<string>("SENTRY_DSN", "");
const environment = configService.get<string>("NODE_ENV", "development");
Sentry.init({
dsn: dsn,
environment: environment,
tracesSampleRate: environment === "production" ? 0.1 : 1.0,
profilesSampleRate: environment === "production" ? 0.1 : 1.0,
normalizeDepth: 5,
integrations: [nodeProfilingIntegration()],
});
app.useLogger(new SentryLogger());
await app.listen(3000);
}
bootstrap()
.then(() => applicationLogger.log("Server is running"))
.catch((error: unknown) => applicationLogger.error("Bootstrap failed", error));
Architectural trade-offs and edge cases
Integrating third-party error trackers requires balancing diagnostic completeness with network latency and data security.
-
Latency versus consistency: Capturing exceptions asynchronously via Sentry's Node SDK offloads event transmission to background workers, keeping HTTP response latency minimal. However, unhandled process-level crashes (such as
process.on('uncaughtException')) require flushing pending events withSentry.flush(2000)before process exit to prevent telemetry loss. - Failure recovery: If Sentry endpoints experience downtime or network partitions, the SDK queues events in a bounded in-memory buffer, dropping older events when capacity is exceeded without blocking or failing active HTTP requests.
-
Scale limitations: Sending full request bodies and 100% trace sampling (
tracesSampleRate: 1.0) on high-traffic production endpoints quickly saturates network bandwidth and Sentry ingestion quotas. Production systems must tune sampling rates and implement payload size limits.
Common anti-patterns and gotchas
-
Environment variable misconfiguration: Configuring
SENTRY_DNSinstead of the expectedSENTRY_DSNenvironment variable causes silent initialization failure without error reporting. - Unscrubbed sensitive data leakage: Forwarding raw request bodies containing passwords, credit card numbers, or authorization bearer tokens to external Sentry dashboards violates data security compliance. Implement data scrubbing before capturing events.
-
Missing filter provider registration: Creating
SentryExceptionFilterbut omittingSentryExceptionFilterProviderfromAppModuleproviders results in unhandled exceptions bypassing Sentry entirely. -
Capturing message strings without stack traces: Calling
Sentry.captureMessage()with simple text rather thanSentry.captureException()strips stack trace frames, rendering debugging difficult. -
100% trace sampling in production: Leaving
tracesSampleRate: 1.0enabled in high-throughput environments exhausts quota limits and incurs unnecessary infrastructure costs.
Implementation checklist
- Install
@sentry/nodeand@sentry/profiling-nodedependencies. - Verify
SENTRY_DSNandNODE_ENVenvironment variables in application configuration. - Implement
SentryLoggerextendingConsoleLoggerwith scoped error forwarding. - Implement
SentryExceptionFilterextendingBaseExceptionFilterwith request and user context tagging. - Register
SentryExceptionFilterProviderunderAPP_FILTERinAppModule. - Initialize Sentry during bootstrap before attaching
SentryLogger. - Configure payload sanitization to redact authentication tokens and sensitive fields.
- Adjust
tracesSampleRateandprofilesSampleRateaccording to environment traffic volume. - Configure Sentry alert thresholds for error rate anomalies and verify alert delivery.
Top comments (3)
Thank you for this guide.
After applying it, I noticed that the log collector you wrote send all logs as Events / Errors to sentry and not to their log ingestion service: sentry.io/product/logs/
I wrote a custom logger and injected it to NestJS and it now works as expected:
Create a file for your logger:
Register it to be used by NestJS in your main.ts:
Thanks for the heads up and that fix, your custom logger tweak’s a solid upgrade over my janky one. Spot on, mate!
I think you can include this "--import ./instrument.mjs" from the dev and start commands (package.json), so you can initialize Sentry before starting to run this NestJS app as well