NestJS error tracking: the filter only covers a third of your app

A global exception filter never sees @Cron jobs or BullMQ processors. An injectable reporter, one filter, and explicit capture in the contexts Nest leaves uncovered.

A global ExceptionFilter in NestJS catches exceptions thrown inside the HTTP request pipeline. Scheduled @Cron methods and BullMQ processors don’t run in that pipeline, so a filter-only setup silently misses the failures that nobody is watching a browser tab for. The fix is boring and effective: one injectable reporter that does a POST /v1/errors/capture against Infrai, wired into the filter and called explicitly from your schedulers and workers.

Second thing worth settling before any code: a filter and an interceptor will both see the same exception. Nest runs interceptor catchError operators before the exception layer, so registering capture in both means every HTTP failure is reported twice and billed twice. Pick the filter. Interceptors are for shaping responses and timing, not for reporting.

What the global filter actually covers

Execution contextReaches a global ExceptionFilter?Where to capture
HTTP controller / route handlerYesthe filter
Guards, pipes, interceptors on a routeYes — they’re inside the pipelinethe filter
@Cron / @Interval (@nestjs/schedule)Notry/catch in the method
BullMQ processor (@Processor)NoonFailed hook or try/catch
Microservice @MessagePatternYes, with an RPC-aware filtera second filter
Lifecycle hooks (onModuleInit)Notry/catch at bootstrap

The rows that say No are why “we have Sentry’s Nest integration installed” and “our cron failures are visible” are two different claims. Sentry’s Nest SDK does instrument the scheduler decorators for you, which is a genuine reason to buy it; without an SDK you write four lines per worker instead.

The reporter

// src/observability/error-reporter.service.ts
import { Injectable, Logger } from "@nestjs/common";

export type CaptureContext = {
  kind: "http" | "cron" | "queue" | "boot";
  scope: string;          // "POST /orders/publish", "nightly-digest", "email-queue"
};

@Injectable()
export class ErrorReporter {
  private readonly log = new Logger(ErrorReporter.name);
  private readonly key = process.env.INFRAI_API_KEY ?? "";
  private readonly release = process.env.APP_RELEASE ?? "dev";

  async capture(err: unknown, ctx: CaptureContext): Promise<string | null> {
    if (!this.key) { this.log.warn("INFRAI_API_KEY unset — not reporting"); return null; }
    const error = err instanceof Error ? err : new Error(String(err));
    const res = await fetch("https://api.infrai.cc/v1/errors/capture", {
      method: "POST",
      headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
      body: JSON.stringify({
        message: (error.stack ?? error.message).slice(0, 8000),
        exception: error.name,
        fingerprint: `${ctx.kind}:${ctx.scope}:${error.name}`,
        environment: process.env.NODE_ENV ?? "development",
        release: this.release,
      }),
    });
    if (!res.ok) { this.log.error(`capture failed ${res.status}: ${await res.text()}`); return null; }
    const body = (await res.json()) as { data: { error_group_id: string } };
    return body.data.error_group_id;
  }
}

The kind prefix in the fingerprint is the part that earns its keep. Group keys built only from the error class collapse an HTTP 500 and a failed nightly job into one issue, and then you resolve the wrong thing.

The filter, with a 4xx floor

// src/observability/all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
import { Request, Response } from "express";
import { ErrorReporter } from "./error-reporter.service";

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(private readonly reporter: ErrorReporter) {}

  async catch(exception: unknown, host: ArgumentsHost): Promise<void> {
    const http = host.switchToHttp();
    const res = http.getResponse<Response>();
    const req = http.getRequest<Request>();
    const status =
      exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

    // 4xx is the client's problem. Reporting it burns budget and buries real bugs.
    if (status >= 500) {
      const route = `${req.method} ${req.route?.path ?? req.path}`;
      await this.reporter.capture(exception, { kind: "http", scope: route });
    }

    res.status(status).json({
      statusCode: status,
      path: req.originalUrl,
      requestId: req.headers["x-request-id"] ?? null,
      timestamp: new Date().toISOString(),
    });
  }
}

That 4xx floor is the single highest-value line in this article. A validation pipe rejecting bad input throws BadRequestException on every malformed request, and an unfiltered filter turns a bot probing your API into thousands of captured events.

Wiring it, plus the contexts the filter can’t reach

// src/app.module.ts (excerpt) and the two uncovered contexts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { Cron, CronExpression, ScheduleModule } from "@nestjs/schedule";
import { Injectable } from "@nestjs/common";
import { ErrorReporter } from "./observability/error-reporter.service";
import { AllExceptionsFilter } from "./observability/all-exceptions.filter";

@Injectable()
export class DigestJob {
  constructor(private readonly reporter: ErrorReporter) {}

  @Cron(CronExpression.EVERY_DAY_AT_2AM)
  async run(): Promise<void> {
    try {
      await this.sendDigest();
    } catch (err) {
      // Nothing else will see this — @Cron swallows rejections into the logger.
      await this.reporter.capture(err, { kind: "cron", scope: "nightly-digest" });
    }
  }

  private async sendDigest(): Promise<void> {
    throw new Error("digest query timed out");
  }
}

@Module({
  imports: [ScheduleModule.forRoot()],
  providers: [ErrorReporter, DigestJob, { provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}

For BullMQ, the equivalent hook is @OnWorkerEvent("failed") on the processor class — one handler covers every job in that queue, and job.attemptsMade tells you whether it’s the final attempt, which is the only one worth capturing. (If retries are already inflating your counts, the grouping rules are covered in more depth at https://docs.infrai.cc/en/guides/errors/answers/spotty-client-networks-mean-my-retry-logic-double-repor/.)

Confirming it end to end

Run the same payload by hand before you trust the wiring:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "TypeORMError: could not serialize access due to concurrent update\n    at OrdersService.publish (/app/dist/orders/orders.service.js:64:15)",
    "exception": "TypeORMError",
    "fingerprint": "http:POST /orders/publish:TypeORMError",
    "environment": "production",
    "release": "api@3.9.1"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_heDZzt3YRbW0rpxvdzvbQMxA",
    "fingerprint": "e2d734037f4050d633bb8ea9e50ddaa5f3d24580e5eb66422e29de16fef39658",
    "error_group_id": "errgrp_9oBybq1KFWa5z6hQAul4PQ2D",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_heDZzt3YRbW0rpxvdzvbQMxA"
  }
}

Then read it back — free, and filterable by level:

curl -sS "https://api.infrai.cc/v1/errors/list?level=error&limit=2" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "event_id": "evt_err_heDZzt3YRbW0rpxvdzvbQMxA",
        "error_group_id": "errgrp_9oBybq1KFWa5z6hQAul4PQ2D",
        "timestamp": "2026-07-26T01:31:56.161936Z",
        "level": "error",
        "title": "TypeORMError: could not serialize access due to concurrent update",
        "environment": "production",
        "release": "api@3.9.1",
        "tags": {}
      }
    ],
    "next_cursor": "1",
    "total": 103
  }
}

Once a group is fixed, close it. The route takes the group id as a path segment and returns the whole updated group:

curl -sS -X POST "https://api.infrai.cc/v1/errors/resolve/{error_group_id}" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json"

Caveats before you commit

The stored event keeps no frames. Your exception value is normalised into {"type": "Message", "value": "…", "stacktrace": []}, so the stack is whatever text you put in message — no source maps, no in-app frame highlighting, no linking a frame to a commit. For a compiled Nest service on Node 22 that’s usually survivable, since error.stack already points at dist/ paths you can map by hand, but if frame-level triage is how your team works, buy Sentry or Rollbar and let their SDK do the instrumentation.

Two smaller ones. Capture is billable per event (about $0.00005 as of 2026-07-26, with reads free and $2 of free credit on a new account — check GET /v1/discovery for today’s figure, and expect it to drift downward), so an unfiltered filter is a budget risk as well as a noise risk. And the capture call is a plain await in your request path; in a filter that’s fine at a few hundred requests a second, but if you’re capturing on a hot path, push the call into a queue instead of blocking the response.

What you get in exchange is a reporter you can read in one file, and a key that also covers the queue you just pushed to, the cron that failed and the email you send when it does.

References

Browse more errors developer guides