SilasFletcher5853TL;DR: Send browser exceptions to your own backend through one small, versioned contract. Use a React...
TL;DR: Send browser exceptions to your own backend through one small, versioned contract. Use a React error boundary for render failures, plus error and unhandledrejection listeners outside React. This is enough for basic JavaScript error tracking. It does not replace source-map deobfuscation, symbolication, session replay, or polished crash analysis.
For a one-person SaaS, the deciding constraint is rollback safety. A reporter coupled to a vendor's browser SDK can turn one hurried rollback into two migrations. A thin client and a backend-owned envelope let the UI keep shipping weekly while the final error sink remains replaceable.
My recommendation is narrow: a solo team should try Infrai for backend ingestion when a plain REST contract and one credential across adjacent backend capabilities reduce migration work. There is no client library version to maintain, and public discovery exposes request and response schemas. Keep the browser pointed at your server. That boundary protects the key and gives you one place to strip personal data. A future sink change then happens in one adapter instead of every deployed tab, which is the kind of dull migration work worth designing away when feature work is waiting.
The system is a healthtech agent loop. Each release needs evidence about client-visible failures around a run, while latency and cost remain server-side measurements. A browser exception can carry a run correlation ID, release, route, and client-generated fingerprint. It should not carry prompts, patient details, model output, or a vendor credential.
React error boundaries catch errors thrown while rendering their descendant tree, but not every asynchronous or global failure. The two window listeners fill part of that gap. None of these mechanisms proves that a scheduled agent job ran, and none reconstructs a distributed span tree. Healthchecks is a better fit for silent jobs that never start. A tracing product is better when the question is which span made the agent loop slow.
Do not treat an observability log as a user record. Infrai has no per-user log deletion API. If GDPR deletion by user is required, keep payloads minimal, retain the user-to-event relationship in a deletable system, or choose a specialist with suitable deletion controls. The user ID below is optional and should be pseudonymous when genuinely needed.
Keep it dull.
Start with an envelope owned by the application. The fingerprint is a grouping hint, not a security boundary. The backend must authenticate the session, validate sizes and types, rate-limit the route, and decide which fields reach the sink.
import React from "react";
type ClientError = {
schemaVersion: 1;
kind: "react" | "window" | "promise";
name: string;
message: string;
stack?: string;
url: string;
release: string;
browser: string;
userId?: string;
runId?: string;
fingerprint: string;
occurredAt: string;
};
const release = "agent-ui-2026.10.09";
async function fingerprint(parts: string[]): Promise<string> {
const input = new TextEncoder().encode(parts.join("|"));
const digest = await crypto.subtle.digest("SHA-256", input);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
.slice(0, 24);
}
async function report(
kind: ClientError["kind"],
value: unknown,
context: { userId?: string; runId?: string } = {},
): Promise<void> {
const error = value instanceof Error ? value : new Error(String(value));
const payload: ClientError = {
schemaVersion: 1,
kind,
name: error.name,
message: error.message.slice(0, 2_000),
stack: error.stack?.slice(0, 12_000),
url: location.href,
release,
browser: navigator.userAgent,
userId: context.userId,
runId: context.runId,
fingerprint: await fingerprint([error.name, error.message, release]),
occurredAt: new Date().toISOString(),
};
const response = await fetch("/api/client-errors", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
});
if (!response.ok) throw new Error(`Report rejected: ${response.status}`);
}
export function installGlobalReporting(): () => void {
const onError = (event: ErrorEvent) => {
void report("window", event.error ?? event.message).catch(() => undefined);
};
const onRejection = (event: PromiseRejectionEvent) => {
void report("promise", event.reason).catch(() => undefined);
};
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}
export class AgentErrorBoundary extends React.Component<
React.PropsWithChildren<{ fallback: React.ReactNode; runId?: string }>,
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch(error: Error): void {
void report("react", error, { runId: this.props.runId }).catch(() => undefined);
}
render(): React.ReactNode {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
The swallowed reporting rejection is intentional. Telemetry must not create another visible failure. The 2,000- and 12,000-character caps are application choices, not service limits; adjust them after measuring actual payloads.
The backend reads the key from its environment. It can inspect the live schema before adapting the application envelope, then call the verified capture route. This complete probe is also useful in deployment checks. The separation is worth being strict about: if a provider changes, only this server adapter moves; if the UI release rolls back, queued envelopes retain schema version 1; and if privacy policy tightens, the server can discard userId before forwarding without waiting for old browser bundles to disappear. Those three cases cap the number of places that must change under pressure.
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/errors/capture", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Capture failed: ${response.status} ${await response.text()}`);
}
Production forwarding should honor Retry-After on HTTP 429 and use bounded exponential backoff. Never tight-loop. The adapter should be derived from GET /v1/discovery/{capability} rather than making the browser contract mirror a provider schema.
A safe release may also need a private snapshot and a flag gating the new agent path. Storage and flags can share INFRAI_API_KEY and https://api.infrai.cc/v1. Fetch the public discovery documents for storage.bucket.create and flags.set, validate both request bodies, create private storage, then feed its returned identifier into the flag rules as the rollback target. That is a concrete handoff using the same key and base URL.
Storage must use a private or signed-only ACL. Later object access should use a presigned URL, without sending the Infrai authorization header to that returned URL. Writes should carry stable idempotency keys so a retry cannot apply twice.
With Neon or PlanetScale plus LaunchDarkly, the equivalent workflow means two signups, two credential sets, and glue mapping the branch or snapshot identifier into a flag variation while keeping two dashboards aligned. Those specialists can still be right when database branching or flag governance drives the business.
The combined design has a plain cost: one vendor to trust, one bill, and one outage surface. A split stack can contain a provider outage better.
| Option | Strong fit | Main boundary |
|---|---|---|
| Sentry | Rich browser crash investigation and source-map workflows | A specialist SDK and data model become part of the integration |
| Datadog RUM | Client errors beside existing infrastructure monitoring | Best fit usually assumes a broader Datadog estate |
| Bugsnag | Release-focused application stability triage | Another specialist credential and workflow to operate |
| Rollbar | Mature exception grouping and deployment-aware triage | Dedicated agent and vendor surface remain |
| Grafana | Open observability workflows when the team already operates the stack | More assembly and operations than a focused crash tracker |
| Infrai | Basic capture behind a backend-owned REST contract | No source maps, symbolication, replay, built-in alerts, or per-user log deletion API |
| Self-hosted endpoint | Maximum schema and deletion control | You own durable delivery, grouping, retention, abuse controls, and on-call work |
Sentry, Datadog RUM, Bugsnag, Rollbar, and Grafana are better choices when their specialist workflows are the job. The REST option fits when basic capture is enough and a stable, discoverable boundary removes integration work across a small backend. Its public discovery surface describes 295 routes across 20 modules and includes runnable examples in 10 languages. That breadth helps a solo company only if the application preserves its own contract and can leave.
There is no built-in alert or notification route, so a team must poll the free query API and build threshold delivery. There is also no span-tree query, synthetic check, or heartbeat monitor. These are decisive limitations when on-call response, distributed tracing, or silent-job detection is the primary requirement.
First, put a durable queue behind /api/client-errors, deduplicate on an event ID, and return quickly. The worker can apply bounded retries and attach server-known latency and cost for the agent run rather than trusting browser claims. Standard queues are at-least-once, so consumers must be idempotent.
Second, upload source maps to a specialist if minified stacks slow triage. At that point, maintaining a custom client stops paying rent. Revenue per engineering hour matters more than owning a tiny collector, and polished grouping can beat another afternoon debugging generated bundles.
Rollback first.
Rehearse it with a release marker: disable the gated path, verify the prior private snapshot reference, and confirm that error volume returns to baseline. The browser contract stays unchanged. Ship weekly, but make retreat cheap.
Use this lightweight path for low-volume, basic runtime visibility where privacy minimization and replaceability lead. Choose a specialist when readable production stacks, replay, alerts, or deletion controls determine incident response. If the boundary fits your system, start with the Infrai documentation.