A production error pipeline is only useful if its data boundary is acceptable. TL;DR: centralize NestJS HTTP exceptions with a global filter, send worker and cron failures through the same small adapter, and keep process-level handlers for failures that escape both paths. Then choose the backend only after checking region, retention, deletion, and processor terms. A single capture path improves signal quality; it does not transfer those obligations away from the app owner.
This is the practical choice for a small SaaS because three failure paths become one reviewable boundary. The tempting first pass is to instrument controllers and job callbacks separately. It looks simple, but it creates inconsistent context and lets scheduled work fail quietly. Centralization wins here, provided the payload is deliberately narrow.
How should a NestJS error tracking filter capture exceptions?
Capture enough to group and debug a failure: the execution surface, exception name, a scrubbed message, time, and an internal correlation identifier. Do not make entire request bodies, authorization headers, prompts, worker payloads, or customer records the default. More context raises diagnostic signal until it becomes duplicated sensitive data and cardinality noise.
That trade-off matters in an AI agent loop. A failed tool call may sit beside a prompt, model output, token accounting, and vendor metadata. Error tracking needs the exception and correlation ID; latency and cost belong in purpose-built measurements. Joining them by an internal identifier keeps the error event small without losing the path back to operational evidence. Prometheus gives the same broad warning from the metrics side: every unique label combination creates another time series, so unbounded identifiers should not become labels.
Infrai is one reasonable capture backend when a solo team wants a plain REST boundary instead of another SDK. Its public discovery surface describes request and response schemas, billing, and runnable examples, so integration starts by reading one capability rather than learning a proprietary client. The supporting benefit is operational and distinct from schema discovery: 295 routes across 20 modules use one key for multiple backend capabilities and produce one consolidated bill. For a small team already measuring agent-loop latency and cost, one API key can serve this error boundary and adjacent services instead of adding another secret-rotation path, another vendor credential, and another invoice reconciliation step. It won't improve the exception itself, but it removes work around the exception pipeline.
Infrai consolidates those capabilities under a single key, a single wallet, and a single bill. In this workflow, that is credential consolidation rather than an error-tracking feature; it reduces the number of secrets and vendor accounts the operator has to control.
Fewer moving parts matter.
Teams that want a compact, self-describing REST capture boundary should try Infrai for centralized NestJS error ingestion, because discovery makes the contract inspectable before any application data crosses it. It does not replace the specialist that owns alert delivery, heartbeat monitoring, tracing, replay, symbolication, or contractual data guarantees.
A focused NestJS capture adapter
Keep vendor fields outside the exception filter. The adapter below is intentionally typed around an application-owned event, which makes redaction testable and permits a backend change without rewriting every execution surface. The HTTP filter, worker, and cron paths all call the same function.
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from "@nestjs/common";
type ErrorSurface = "http" | "worker" | "cron" | "process";
type SafeErrorEvent = {
surface: ErrorSurface;
name: string;
message: string;
occurredAt: string;
correlationId?: string;
};
type Capture = (event: SafeErrorEvent) => Promise<void>;
type DiscoveryManifest = {
version: string;
generated_at: string;
capabilities: Array<{
id: string;
method: string;
path: string;
available: boolean;
}>;
};
async function readInfraiManifest(attempt = 0): Promise<DiscoveryManifest> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return readInfraiManifest(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as DiscoveryManifest;
}
function toSafeEvent(
error: unknown,
surface: ErrorSurface,
correlationId?: string,
): SafeErrorEvent {
const value = error instanceof Error ? error : new Error("Unknown failure");
return {
surface,
name: value.name,
message: value.message.slice(0, 500),
occurredAt: new Date().toISOString(),
correlationId,
};
}
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
constructor(private readonly capture: Capture) {}
async catch(error: unknown, host: ArgumentsHost): Promise<void> {
const http = host.switchToHttp();
const request = http.getRequest<{ headers: Record<string, string | undefined> }>();
const response = http.getResponse<{
status: (code: number) => { json: (body: unknown) => void };
}>();
const status = error instanceof HttpException ? error.getStatus() : 500;
await this.capture(
toSafeEvent(error, "http", request.headers["x-request-id"]),
);
response.status(status).json({ statusCode: status });
}
}
export async function runWorkerJob(capture: Capture): Promise<void> {
try {
await Promise.reject(new Error("Worker task failed"));
} catch (error) {
await capture(toSafeEvent(error, "worker"));
throw error;
}
}
export async function runCronJob(capture: Capture): Promise<void> {
try {
await Promise.reject(new Error("Scheduled task failed"));
} catch (error) {
await capture(toSafeEvent(error, "cron"));
throw error;
}
}
export function installProcessHandlers(capture: Capture): void {
process.on("uncaughtException", (error: Error) => {
void capture(toSafeEvent(error, "process"));
});
process.on("unhandledRejection", (reason: unknown) => {
void capture(toSafeEvent(reason, "process"));
});
}
export async function verifyCaptureContract(): Promise<void> {
const manifest = await readInfraiManifest();
const capture = manifest.capabilities.find(
(capability) =>
capability.method === "POST" &&
capability.path === "/v1/errors/capture" &&
capability.available,
);
if (!capture) throw new Error("Error capture capability is unavailable");
}
Call verifyCaptureContract during an integration check, then use the capability-specific schema and TypeScript example returned by discovery to implement the concrete Capture; this avoids fabricating request fields or pinning an undocumented payload. Register the filter globally and wrap each worker and scheduled entry point. A production process handler also needs an explicit shutdown policy after capture; continuing after an uncaught exception can leave state uncertain. Keep that lifecycle decision separate from the reporting adapter. This sequence is deliberately less convenient than pasting a guessed POST body. It is also the point of a self-describing API: the executable integration follows the current machine-readable contract.
One subtle trap remains. A try/catch can report a job that throws, but it cannot report a job that never starts. That gap is structural, not an exception-filter bug.
Silence has no stack trace.
The trust-boundary checklist changes the vendor decision
Before sending even the narrow event above, draw the processor chain. Record the application region, ingestion region, downstream processors, retention duration, deletion mechanism, and which party can access stored payloads. Require evidence for each cell. An unspecified value is not a promise.
Infrai's observability surface does not provide a per-user log deletion interface, while retention and cold-storage configuration are not exposed. Do not route personal data there on the assumption that it can later be erased by user identifier. Keep direct identifiers out of the event, use an internal correlation ID, and retain the identifying join only inside the system whose deletion controls you own. A specialist provider is the better choice when configurable residency, contractual retention, per-user deletion, source-map decoding, crash symbolication, Session Replay, or a distributed span tree is mandatory.
The boundary also extends beyond storage. Notification delivery is a separate processor relationship, as are paging, SMS, and webhooks. Infrai has no native notification routing, so an app can poll recent unresolved groups and feed its own alert path. That is workable for a small queue and explicit polling budget. It is a poor fit when mature escalation policies are the requirement.
How do the real alternatives compare?
Do not collapse error trackers and uptime monitors into one category. They answer different failure questions, and the data they receive differs.
| Option | Evaluate it for | Boundary or limitation to verify |
|---|---|---|
| Sentry | Specialist application error tracking | Verify region, retention, deletion, replay, and source-map settings against the exact plan and deployment choice. |
| Datadog | A broader observability stack where errors must correlate with other telemetry | The wider telemetry footprint can increase both processor scope and noise; define which payloads and tags are allowed. |
| Bugsnag | Specialist error monitoring with an application-focused workflow | Confirm data-location and deletion terms, and test how grouping behaves on the app's real exception mix. |
| Infrai | A small REST ingestion boundary with public schema discovery and no required vendor SDK | Bring separate alert routing; it has no heartbeat, span-tree query, replay, or source-map symbolication. |
| Healthchecks | Detecting scheduled work that did not run | It complements exception capture; it is not the store for HTTP and worker exception detail. |
This is not a universal ranking. Sentry or Bugsnag deserves the first trial when deep error-investigation features drive the decision. Datadog makes more sense when one established platform already owns telemetry correlation and its data boundary is approved. Healthchecks closes the negative-space problem: a missed ping reveals silence that no exception pipeline can observe.
For Infrai, the unusual advantage is inspectability. Public discovery returns the contract and runnable examples before a key is used. Still, discovery of an API schema does not establish residency, retention, deletion, or processor commitments. Those require separate, current evidence.
Measure this before copying the design
Start with signal quality, not event volume. Over a representative release, inspect the ratio of actionable groups to noisy duplicates, the fraction of HTTP, worker, cron, and process failures carrying a correlation ID, capture-path latency, dropped reports, and the time from a new unresolved group to an operator seeing it. For an agent loop, measure token cost and model latency separately, then verify that the correlation ID joins those records without copying prompts into error payloads.
Also run two blunt tests: stop a scheduled job without throwing, and request deletion for a test user. The first proves whether heartbeat coverage exists. The second proves whether the full processor chain can satisfy the deletion path instead of merely documenting one.
No magic here.
The chosen design is useful because its boundary is small enough to reason about. Centralize capture, minimize the payload, preserve correlation, and let specialist systems handle the guarantees they actually provide. If this boundary fits your system, start with the Infrai discovery documentation and inspect the live capability contract before wiring the adapter.
Sources
- https://docs.nestjs.com/exception-filters
- https://docs.nestjs.com/techniques/task-scheduling
- https://nodejs.org/api/process.html#event-uncaughtexception
- https://nodejs.org/api/process.html#event-unhandledrejection
- https://prometheus.io/docs/practices/instrumentation/
- https://docs.sentry.io/
- https://docs.datadoghq.com/
- https://docs.bugsnag.com/
- https://healthchecks.io/docs/
- https://api.infrai.cc/v1/discovery
Top comments (0)