cd /news/developer-tools/tenant-aware-error-capture-in-nestjs… · home topics developer-tools article
[ARTICLE · art-121177] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Tenant-Aware Error Capture in NestJS: HTTP Filters, Cron Jobs, Queue Workers

A developer detailed a tenant-aware error capture setup for NestJS backends, covering HTTP filters, cron jobs, and queue workers. The approach stamps tenant and experiment cohort onto events before they leave the Node.js process, enabling reliable comparison between variant and control groups. The article compares error tracking tools including Sentry, OpenTelemetry, Axiom, PostHog, and Infrai, advising selection based on on-call needs.

read9 min views1 publishedSep 4, 2026

An error-tracking setup for a NestJS backend has one hard problem, and it isn't which vendor you sign up with: HTTP exceptions arrive by the thousand, cron jobs and queue workers arrive by the handful, and if both land in the same bucket the noisy one wins every chart you draw. Use one capture layer with three entry points — a global exception filter for HTTP, a wrapper around each scheduled job, an explicit catch inside every queue worker — and stamp the tenant plus its experiment cohort onto the event before it leaves the Node.js process.

That's the whole shape of it.

The diagram in words: three producers (a request, a tick, a message) feed one capture function, which writes one event schema carrying tenant_id

, cohort

and surface

, which feeds one comparison you can actually trust. Everything below is about keeping that pipe honest when a support SaaS is running an experiment — say, AI-drafted first replies for half the tenants — and someone asks whether the variant cohort is breaking more often than the control.

Option How the app talks to it Cohort tagging The catch
Sentry official NestJS SDK, filter and interceptor helpers tags, releases, scopes per event you track SDK versions against your Nest and Node.js versions
OpenTelemetry + Grafana Loki OTel SDK, span events plus structured logs resource and span attributes you assemble collector, storage and dashboards yourself
Axiom ingest API, or a pino/winston transport any structured field you send log-shaped, so exception grouping is a query you write
PostHog product analytics SDK with exception capture person and group properties cohort math is strong, stack-level triage is thinner
Infrai one plain REST call, no SDK to install whatever tags you put on the event no heartbeat monitoring, so pair it with Healthchecks-style pings

Sentry is the default for a reason. If your team's daily question is "what changed in this release and which line threw," the SDK, the breadcrumbs and the release tracking do more than a REST call ever will. The trade-off is that you now own a client library inside every runtime — the Nest app, the worker, the Lambda you forgot about.

OpenTelemetry plus Loki or Tempo makes sense when errors are one signal among several and you already run the collector. It is the most portable option on this list. It is also the most assembly required, and exception triage is not what it optimizes for.

Axiom fits teams who already think in log lines and want cheap high-cardinality search over them.

PostHog is worth a look precisely because of the cohort angle: it already knows what a group is, so "variant versus control" is a native question rather than a tag you invent. Deep stack triage is where it thins out.

Infrai takes the opposite bet from Sentry: error capture is one plain REST API, so there's no SDK to install and no client version to pin — the same fetch

call works from a Nest controller, a cron tick, and a five-year-old worker image. One key covers Infrai's error capture alongside its logs and metrics, so the cohort join stays inside a single query surface instead of three billing relationships. Its discovery endpoint is public and needs no key, which means you can read the request schema for the capture route before you write a line of code.

Pick by what your on-call actually does at 3am. Line-level triage, stack frames, release diffing: Sentry. Multi-signal correlation you already operate: OpenTelemetry. One backend contract across HTTP, cron and queue with no SDK matrix to babysit: a REST capture API.

Three surfaces, three capture policies. Same event schema.

For HTTP, capture on the response boundary, not on every throw. A NotFoundException

or a validation error is your API doing its job; a 5xx is an incident. If you report both at the same severity, one tenant with a broken integration will out-shout the entire experiment. Record the route pattern (not the raw URL — it carries ticket IDs), the status class, the tenant, the cohort and the request ID.

For a cron job, the unit is one scheduled execution, not one thrown value. Record the schedule name, intended run time, attempt number and a stable run ID. Otherwise a job that retried twice looks like three separate defects on Tuesday morning.

For a queue worker, the unit is one delivery attempt. Standard queues are at-least-once, so the same message can be processed twice while the system is perfectly healthy. Carry the message ID, the attempt count and an idempotency key, then let the consumer's side effects be idempotent — and report a duplicate delivery as context on the event rather than as two independent business errors. This is the part people skip, and it's the part that quietly ruins cohort comparison: if the variant arm retries more because its work is slower, a naive count shows the variant "erroring more" when it is in fact the same failure counted more times.

One module, one function, no per-surface special cases beyond the tags. Explicit method, bearer key from the environment, an idempotency key so a retried capture doesn't double-post, a bounded 429 backoff that honours Retry-After

, and a real status check on the way out.

// capture.ts
const API_ORIGIN = process.env.ERRORS_API_ORIGIN!.replace(/\/+$/, "");
const API_KEY = process.env.INFRAI_API_KEY!;           // ifr_... , never inline
const CAPTURE_PATH = "/v1/errors/capture";

export type Surface = "http" | "cron" | "queue";

export interface CaptureInput {
  surface: Surface;
  error: unknown;
  tenantId: string;
  cohort: "control" | "variant";
  eventKey: string;                                    // stable per attempt
  context?: Record<string, string | number>;
}

export async function capture(input: CaptureInput): Promise<void> {
  const err = input.error instanceof Error ? input.error : new Error(String(input.error));
  const body = JSON.stringify({
    message: err.message,
    stack: err.stack,
    level: input.surface === "http" ? "error" : "fatal",
    tags: {
      surface: input.surface,
      tenant_id: input.tenantId,
      cohort: input.cohort,
      ...input.context,
    },
  });

  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch(API_ORIGIN + CAPTURE_PATH, {
      method: "POST",
      headers: {
        authorization: `Bearer ${API_KEY}`,
        "content-type": "application/json",
        "idempotency-key": input.eventKey,
      },
      body,
    });

    if (res.status === 429) {
      const after = Number(res.headers.get("retry-after"));
      const waitMs = Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** attempt * 500;
      await new Promise((r) => setTimeout(r, waitMs));
      continue;
    }
    if (!res.ok) {
      // 4xx bodies carry the reason — log it, don't swallow it.
      console.error("capture rejected", res.status, await res.text());
    }
    return;
  }
}

Now the three call sites. The filter decides severity, the job owns its run ID, the worker owns its attempt number.

// entry-points.ts
@Catch()
export class CaptureFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const req = host.switchToHttp().getRequest<TenantRequest>();
    const res = host.switchToHttp().getResponse<Response>();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;

    if (status >= 500) {
      void capture({
        surface: "http", error: exception,
        tenantId: req.tenant.id, cohort: req.tenant.cohort,
        eventKey: `http:${req.id}`,
        context: { route: req.route?.path ?? "unknown", status },
      });
    }
    res.status(status).json({ error: status >= 500 ? "internal_error" : String(exception) });
  }
}

@Injectable()
export class MacroSyncJob {
  @Cron("0 * * * *")
  async syncMacros() {
    const runId = `macros:${new Date().toISOString().slice(0, 13)}`;
    try {
      await this.sync();
    } catch (error) {
      await capture({ surface: "cron", error, tenantId: "platform", cohort: "control", eventKey: runId });
      throw error;
    }
  }
}

@Processor("draft-replies")
export class DraftReplyWorker extends WorkerHost {
  async process(job: Job<DraftReplyPayload>) {
    try {
      return await this.draft(job.data);
    } catch (error) {
      await capture({
        surface: "queue", error,
        tenantId: job.data.tenantId, cohort: job.data.cohort,
        eventKey: `draft:${job.id}:${job.attemptsMade}`,
        context: { queue: "draft-replies", attempt: job.attemptsMade },
      });
      throw error;
    }
  }
}

Two details earn their keep. eventKey

includes the attempt number, so attempt 1 and attempt 2 are genuinely different events while a retried HTTP capture of the same attempt collapses into one. And the filter calls capture only above 500, which is the single change that moves your signal-to-noise ratio the most.

Counts lie here. Control and variant almost never see the same traffic, so compare rates: distinct error groups per 1,000 operations, per surface, per cohort. One tenant with a misconfigured webhook can produce 40,000 events in an afternoon and swamp a variant arm of 30 tenants — cap per-tenant contribution, or compare medians across tenants instead of a global sum.

Group before you count. Fingerprint on exception class plus normalized message plus the call site, never on the raw message (a message with a ticket ID in it creates one group per ticket). Then let support leads mark a group resolved through the grouping and resolve endpoints rather than deleting events, so historical arms stay comparable after a fix ships.

And say out loud which failures you deliberately don't capture. Expected 4xx, client aborts, duplicate deliveries that succeeded on retry. Write that list in the repo next to the filter. Six weeks from now, nobody will remember why the graph has a floor.

A capture API only sees code that ran. If a cron container never starts, nothing throws, nothing is reported, and the chart stays flat while the work quietly doesn't happen — that's the failure mode this whole design cannot see, and Healthchecks or a similar dead-man's-switch ping is the fix. Infrai doesn't offer heartbeat or synthetic monitoring, and Sentry's crons product is exactly the counter-argument if you want that in one place.

Two more boundaries on the plain-REST path. There's no alert-rule or notification route, so thresholds mean polling the query API on your own schedule and pushing to your own channel — fine if you already have a notifier, annoying if you wanted paging out of the box. And there's no span tree or session replay; logs carry trace_id

and span_id

you can join on, but if your debugging story is "walk the distributed trace," stick with OpenTelemetry and a tracing backend.

Source maps are the last one. Server-side stacks in a Node.js app are usually readable enough, but a browser bundle in your support widget will need a service that de-minifies for you.

My honest read: for a NestJS SaaS comparing an experiment across tenant cohorts, the low-friction path is one REST capture layer, three entry points, and a heartbeat tool bolted on the side. If your on-call spends its nights inside stack frames instead of cohort tables, that calculus flips, and I wouldn't argue with anyone who picks the SDK-heavy option — your mileage may vary with how many runtimes you're actually shipping.

── more in #developer-tools 4 stories · sorted by recency
── more on @nestjs 3 stories trending now
wpnews · · #developer-tools
BoardUI
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/tenant-aware-error-c…] indexed:0 read:9min 2026-09-04 ·