TL;DR: React frontend error tracking works best with a small, scrubbed browser envelope and a backend collector that retains only the evidence needed to reconstruct a learner report. For a solo edtech product, that is the least complex path to answering why one learner saw a broken lesson without collecting a transcript of their session.
| Choice | Reconstruction value | Privacy exposure | Operating burden |
| --- | --- | --- |
| Browser console alone | Low after the tab closes | Low | Low |
| Full session capture | High | High | High |
| Scrubbed error envelope plus server intake | High for exceptions | Bounded by the schema | Moderate |
| Aggregate error counters | Low for one incident | Low | Low |
Recommendation: use the third row as the default. Counters tell me that a release is unhealthy; a bounded event tells me what a learner encountered, where, and which deployed build produced it. Full session capture is a separate decision with a much larger privacy review.
The useful unit is not an error message. It is a reconstruction record: an exception fingerprint, a cleaned stack, the release and environment, a route template, a timestamp, and a request or support correlation key that cannot be turned back into a learner identity. The exact schema is the product decision.
Small on purpose.
How should a React frontend send error tracking evidence to a backend collector?
Start with the question support will actually ask: "A learner could not submit a quiz at 09:14. Can we tell which code path failed, in which release, without reading their answer?" A stack by itself rarely answers that. It needs deployment context and a narrow description of the action.
For this workload I keep two identifiers separate. eventId identifies the browser occurrence. incidentKey is a random, short-lived key created when the learner opens a support flow; it can be shared with support, but it is not their account ID or email. The collector should reject fields it has not explicitly allowed. That awkward constraint pays off when a future UI change adds a new value to an error object.
The two criteria that matter most are reconstructability and collection boundaries. Reconstructability means an engineer can group the event with the correct source maps, deployment, route, and timestamp. It also means a report from 09:14 can be compared with the release that was live at 09:14, rather than with whatever happened to deploy later. Collection boundaries mean the client cannot casually send raw form data, authorization headers, URL query strings, or arbitrary objects because somebody put them on an exception. The boundary belongs before the network request. This is where a small schema earns its keep: a quizAnswer property cannot arrive if the schema has no such field. A backend check is still necessary because browser code can be altered, but a client-side allowlist prevents routine mistakes from leaving the tab. I would rather add one reviewed field after a support investigation than discover an unreviewed field in durable storage.
A metric still has a role. OpenTelemetry describes metrics as numeric measurements, which makes a release-level count useful for detecting a rise in failures. It does not preserve the per-incident evidence needed to debug a single quiz submission. Keep the two signals distinct.
Capture one browser envelope at the boundary
The browser has two separate exception paths worth wiring: resource and script errors delivered through window.onerror, and rejected promises delivered through unhandledrejection. Neither is a license to serialize an entire Error object. Pull out only the fields the investigation needs, cap their size, and scrub strings before transport.
type ClientFault = {
eventId: string;
kind: "error" | "rejection";
message: string;
stack?: string;
release: string;
environment: "production" | "staging";
route: string;
occurredAt: string;
incidentKey?: string;
};
const limit = (value: string, size: number) => value.slice(0, size);
function scrub(value: string): string {
return limit(
value
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
.replace(/\b(?:\d[ -]?){13,19}\b/g, "[number]"),
4_000,
);
}
function fault(kind: ClientFault["kind"], reason: unknown): ClientFault {
const error = reason instanceof Error ? reason : new Error(String(reason));
return {
eventId: crypto.randomUUID(),
kind,
message: scrub(error.message),
stack: error.stack ? scrub(error.stack) : undefined,
release: "web-2026.09.15",
environment: "production",
route: location.pathname,
occurredAt: new Date().toISOString(),
};
}
function sendFault(event: ClientFault): void {
const body = JSON.stringify(event);
const queued = navigator.sendBeacon(
"/telemetry/client-faults",
new Blob([body], { type: "application/json" }),
);
if (!queued) {
void fetch("/telemetry/client-faults", {
method: "POST",
headers: { "content-type": "application/json" },
body,
keepalive: true,
credentials: "omit",
});
}
}
window.onerror = (_message, _source, _line, _column, error) => {
sendFault(fault("error", error ?? _message));
};
window.addEventListener("unhandledrejection", (event) => {
sendFault(fault("rejection", event.reason));
});
This intentionally uses a route path, not location.href; query parameters are a common place for accidental identifiers. The sample release string is a build artifact, not a version to type by hand. In a release pipeline, inject an immutable build identifier once and attach the same value to frontend artifacts and server logs. That is what turns a cleaned stack into a deployable fix.
There is one quiet trap here. Browser error reporting can be limited for cross-origin scripts unless the script and server are configured for CORS error details. Treat a missing stack as a valid event state, then test the production asset path. Do not relax the data schema just to compensate.
Retain, aggregate, and test the event contract
The intake service is where I would make the contract strict. It should parse a small JSON object, check the release and environment against expected shapes, strip unknown keys, rate-limit abusive clients, and store the accepted envelope separately from application logs. The browser is untrusted. A collector that accepts every property is an ingestion endpoint for whatever a browser extension or malicious script decides to send.
type AcceptedFault = Pick<
ClientFault,
"eventId" | "kind" | "message" | "stack" | "release" | "environment" | "route" | "occurredAt" | "incidentKey"
>;
export async function collectClientFault(request: Request): Promise<Response> {
let candidate: Partial<AcceptedFault>;
try {
candidate = await request.json() as Partial<AcceptedFault>;
} catch {
return new Response(null, { status: 400 });
}
if (
typeof candidate.eventId !== "string" ||
(candidate.kind !== "error" && candidate.kind !== "rejection") ||
typeof candidate.message !== "string" ||
typeof candidate.release !== "string" ||
(candidate.environment !== "production" && candidate.environment !== "staging") ||
typeof candidate.route !== "string" ||
typeof candidate.occurredAt !== "string"
) {
return new Response(null, { status: 422 });
}
const accepted: AcceptedFault = {
eventId: candidate.eventId,
kind: candidate.kind,
message: limit(scrub(candidate.message), 1_000),
stack: typeof candidate.stack === "string" ? limit(scrub(candidate.stack), 4_000) : undefined,
release: limit(candidate.release, 128),
environment: candidate.environment,
route: limit(candidate.route, 512),
occurredAt: candidate.occurredAt,
incidentKey: typeof candidate.incidentKey === "string" ? limit(candidate.incidentKey, 128) : undefined,
};
await storeAcceptedFault(accepted);
await incrementFaultCounter(accepted.release, accepted.kind);
return new Response(null, { status: 202 });
}
declare function storeAcceptedFault(event: AcceptedFault): Promise<void>;
declare function incrementFaultCounter(release: string, kind: AcceptedFault["kind"]): Promise<void>;
The 422 path is useful. It exposes a bad client contract to the client developer without turning malformed payloads into durable records. The 202 path says only that the collector accepted the envelope; it should not claim that a downstream index is already searchable. Those are different reliability promises.
I would test this as a release boundary. An end-to-end test should trigger a synchronous throw and a rejected promise, assert that the event has the injected release and no query string, then submit strings resembling an email address and a long number to prove the scrubber behaves as intended. Add a test for an unknown key as well. Privacy failures often arrive as schema drift, not as a dramatic breach.
Retention needs a written rule. Keep raw accepted envelopes only for the incident window the support workflow needs, then delete or aggregate them. Keep release-level counters longer if they remain non-identifying. The correct duration depends on support response time, legal obligations, and the debugging value of old releases; it is not a magic number copied from another SaaS.
When the fuller event is worth retaining
The runner-up, full session capture, is reasonable for a narrowly consented diagnostic flow where an exception alone cannot reveal a multi-step editor state. It needs field masking before capture, access controls around playback, and an explicit retention policy. For ordinary lesson delivery, the smaller envelope is easier to audit and usually enough to connect the learner report to a source-mapped failure.
A console-only approach is also fine during local development. It fails the moment a report arrives after the tab is gone. Aggregate counters are a good companion when the job is detecting a regression across a release, but they cannot answer what one learner saw.
For a one-person SaaS, the useful trade-off is boring: store the minimum evidence that closes a support loop, make the schema hostile to accidental collection, and attach every event to an immutable release. That keeps incident reconstruction from becoming a second product to operate.
Further reading
- https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event
- https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event
- https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
- https://opentelemetry.io/docs/concepts/signals/metrics/
- https://logback.qos.ch/manual/appenders.html
Top comments (0)