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 |
Pick this when: a shortlist for a support SaaS
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.
How should a NestJS filter, a cron job, and a queue worker each capture exceptions in production?
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.
The capture layer, three entry points
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.
Reading cohort deltas when the volume is uneven
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.
The limits worth knowing before you commit
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.
Sources
- NestJS exception filters: https://docs.nestjs.com/exception-filters
- NestJS task scheduling: https://docs.nestjs.com/techniques/task-scheduling
- BullMQ workers and retries: https://docs.bullmq.io/guide/workers
- Sentry NestJS SDK: https://docs.sentry.io/platforms/javascript/guides/nestjs/
- OpenTelemetry JavaScript: https://opentelemetry.io/docs/languages/js/
- Healthchecks monitoring for scheduled jobs: https://healthchecks.io/docs/
- GrowthBook on experiment analysis: https://docs.growthbook.io/statistics/overview
Top comments (0)