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 context are just expensive noise. In a real NestJS system, we want logs that show what failed, where it failed, and who was affected.

This setup uses Sentry with a custom NestJS logger and a global exception filter, while keeping default console logging behavior.

Why It Matters

  • Centralized error tracking reduces debugging time.
  • Request and user context make incidents easier to reproduce.
  • Global exception capture prevents silent failures.
  • Console logging remains available for local and container logs.

Core Concepts

1. Install Sentry SDK

Install required package:

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

2. Custom Logger Extension

Extend ConsoleLogger and forward error/verbose logs to Sentry.

import { ConsoleLogger } from "@nestjs/common";
import * as Sentry from "@sentry/node";

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

3. Global Exception Filter

Catch all unhandled exceptions and send enriched request context to Sentry.

import { Catch, type ArgumentsHost, type Provider } from "@nestjs/common";
import { APP_FILTER, BaseExceptionFilter } from "@nestjs/core";
import * as Sentry from "@sentry/node";

@Catch()
class SentryExceptionFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const http = host.switchToHttp();
    const request = http.getRequest();

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

4. Sentry Initialization in Bootstrap

Initialize Sentry once during app startup and register custom logger.

import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
import { AppModule } from "./app.module";
import { SentryLogger } from "./utility/logger/sentry.logger";

const logger = new Logger("MyApp");

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  const configService = app.get(ConfigService);

  Sentry.init({
    dsn: configService.get<string>("SENTRY_DSN", ""),
    environment: configService.get<string>("NODE_ENV", "development"),
    tracesSampleRate: 1.0,
    profilesSampleRate: 1.0,
    normalizeDepth: 5,
    integrations: [nodeProfilingIntegration()],
  });

  app.useLogger(new SentryLogger());

  await app.listen(3000);
}

bootstrap()
  .then(() => logger.log("Server is running"))
  .catch((error) => logger.error("Bootstrap failed", error));
Enter fullscreen mode Exit fullscreen mode

5. Register Filter in App Module

Register the global filter provider in AppModule.

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { ValidationProvider } from "./validation.provider";
import { SentryExceptionFilterProvider } from "./utility/logger/sentry-exception.filter";

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, ValidationProvider, SentryExceptionFilterProvider],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

6. Logging Strategy

Use this layered strategy:

  • Logger captures operational messages and error signals.
  • Exception filter captures unhandled failures with request context.
  • Sentry groups and tracks incidents across environments.

Practical Example

Typical project structure for this setup:

src/
  utility/
    logger/
      sentry.logger.ts
      sentry-exception.filter.ts
  app.module.ts
  main.ts
Enter fullscreen mode Exit fullscreen mode

When a controller throws an unhandled error, the filter sends request/user metadata to Sentry, and the logger still writes to console output. Two observability channels, one less panic.

Common Mistakes

  • Using SENTRY_DNS instead of correct SENTRY_DSN environment key.
  • Sending sensitive request data to Sentry without sanitization.
  • Forgetting to register the global exception filter provider.
  • Capturing only message strings and losing stack/context details.
  • Running tracesSampleRate at 1.0 in high-traffic production without budget planning.

Quick Recap

  • Extend NestJS logger to forward important logs to Sentry.
  • Add global exception filter for unhandled errors.
  • Initialize Sentry at bootstrap with environment-based config.
  • Register filter provider in module DI container.
  • Keep logs contextual, structured, and privacy-aware.

Next Steps

  1. Add data scrubbing for secrets and personal data before sending events.
  2. Tune sampling rates by environment (dev, staging, prod).
  3. Add alert rules in Sentry for critical error thresholds.
  4. Add release tracking to map errors to deployments.

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