DEV Community

ZèD
ZèD

Posted on Edited on Originally published at imzihad21.github.io

Enhancing Error Logging in NestJS with Sentry

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 BaseExceptionFilter catches unhandled exceptions and formats standard HTTP responses, but does not forward diagnostics to external telemetry backends. Manual try/catch blocks 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 ConsoleLogger to capture explicit logger.error() and logger.verbose() calls across application code.
  • Exception interception: Handled by a global NestJS exception filter implementing BaseExceptionFilter to 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
Enter fullscreen mode Exit fullscreen mode

Production implementation

Install the required Sentry dependency:

npm install @sentry/node @sentry/profiling-node
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 with Sentry.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_DNS instead of the expected SENTRY_DSN environment 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 SentryExceptionFilter but omitting SentryExceptionFilterProvider from AppModule providers results in unhandled exceptions bypassing Sentry entirely.
  • Capturing message strings without stack traces: Calling Sentry.captureMessage() with simple text rather than Sentry.captureException() strips stack trace frames, rendering debugging difficult.
  • 100% trace sampling in production: Leaving tracesSampleRate: 1.0 enabled in high-throughput environments exhausts quota limits and incurs unnecessary infrastructure costs.

Implementation checklist

  1. Install @sentry/node and @sentry/profiling-node dependencies.
  2. Verify SENTRY_DSN and NODE_ENV environment variables in application configuration.
  3. Implement SentryLogger extending ConsoleLogger with scoped error forwarding.
  4. Implement SentryExceptionFilter extending BaseExceptionFilter with request and user context tagging.
  5. Register SentryExceptionFilterProvider under APP_FILTER in AppModule.
  6. Initialize Sentry during bootstrap before attaching SentryLogger.
  7. Configure payload sanitization to redact authentication tokens and sensitive fields.
  8. Adjust tracesSampleRate and profilesSampleRate according to environment traffic volume.
  9. Configure Sentry alert thresholds for error rate anomalies and verify alert delivery.

Top comments (3)

Collapse
 
gauthierplm profile image
Gauthier POGAM--LE MONTAGNER

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:

import { ConsoleLogger } from "@nestjs/common";
import { ConsoleLoggerOptions } from "@nestjs/common/services/console-logger.service";
import { LogLevel } from "@nestjs/common/services/logger.service";
import type { CaptureContext } from "@sentry/core/build/types/scope";
import * as Sentry from "@sentry/nestjs";
import type { SetRequired } from "type-fest";

export class SentryLogger extends ConsoleLogger {
  private logLevels: LogLevel[] = [];

  constructor(options: SetRequired<ConsoleLoggerOptions, "logLevels">) {
    super(options);
    this.logLevels = options.logLevels;
  }

  debug(message: any, ...optionalParams: any[]) {
    if (!this.logLevels.includes("debug")) {
      return;
    }

    this.logMessage("debug", message, ...optionalParams);
    super.debug(message, ...optionalParams);
  }

  error(message: any, ...optionalParams: any[]): void {
    if (!this.logLevels.includes("error")) {
      return;
    }

    this.logMessage("error", message, ...optionalParams);
    super.error(message, ...optionalParams);
  }

  fatal(message: any, ...optionalParams: any[]): void {
    this.logMessage("fatal", message, ...optionalParams);
    super.fatal(message, ...optionalParams);
  }

  log(message: any, ...optionalParams: any[]) {
    if (!this.logLevels.includes("log")) {
      return;
    }

    this.logMessage("log", message, ...optionalParams);
    super.log(message, ...optionalParams);
  }

  verbose(message: any, ...optionalParams: any[]) {
    if (!this.logLevels.includes("verbose")) {
      return;
    }

    this.logMessage("verbose", message, ...optionalParams);
    super.verbose(message, ...optionalParams);
  }

  warn(message: any, ...optionalParams: any[]) {
    if (!this.logLevels.includes("warn")) {
      return;
    }

    this.logMessage("warn", message, ...optionalParams);
    super.warn(message, ...optionalParams);
  }

  /**
   * Logs a message at the specified log level with optional context and additional parameters.
   *
   * @param {"warning" | "log" | "info" | "debug" | "verbose"} logLevel - The severity level of the log message.
   * @param {any} message - The message to log. Can be of any type.
   * @param {...any[]} optionalParams - Additional parameters or context to include in the log.
   * @return {void} This method does not return a value.
   */
  private logMessage(
    logLevel: LogLevel,
    message: any,
    ...optionalParams: any[]
  ): void {
    let logContext = "";
    if (optionalParams.length >= 1) {
      logContext = optionalParams.shift();
    }

    const formattedMessage: string = logContext
      ? `[${logContext}]: ${message}`
      : message;

    this.sendLogToSentry(logLevel, formattedMessage, optionalParams);
  }

  /**
   * Sends a log message to Sentry with the specified log level, message, and optional context information.
   *
   * @param {"fatal" | "error" | "warning" | "log" | "info" | "debug" | "verbose"} logLevel
   *        The severity level of the log message.
   * @param {string} message
   *        The log message to be sent to Sentry.
   * @param {CaptureContext} [captureContext]
   *        Optional additional context to include with the log message.
   * @return {void}
   *        Does not return a value.
   */
  private sendLogToSentry(
    logLevel: LogLevel,
    message: string,
    captureContext?: CaptureContext,
  ): void {
    switch (logLevel) {
      case "fatal":
        Sentry.logger.fatal(message, captureContext);
        break;
      case "error":
        Sentry.logger.error(message, captureContext);
        break;
      case "warn":
        Sentry.logger.warn(message, captureContext);
        break;
      case "log":
        Sentry.logger.info(message, captureContext);
        break;
      case "debug":
        Sentry.logger.debug(message, captureContext);
        break;
      case "verbose":
        Sentry.logger.trace(message, captureContext);
        break;
      default:
        break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Register it to be used by NestJS in your main.ts:

  const logLevels: LogLevel[] = ["error", "warn", "log", "debug"];
  if (process.env.VERBOSE_LOGGING == "true") {
    logger.warn("Verbose logging enabled.");
    logLevels.push("verbose");
  }

  const app = await NestFactory.create(AppModule, {
    logger: new SentryLogger({
      logLevels,
    }),
  });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
imzihad21 profile image
ZèD

Thanks for the heads up and that fix, your custom logger tweak’s a solid upgrade over my janky one. Spot on, mate!

Collapse
 
jdnichollsc profile image
J.D Nicholls • Edited

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