Short answer: send window.onerror and unhandledrejection events to a small backend collector, but treat the browser payload as untrusted evidence: allowlist fields, scrub likely PII again on the server, attach release and environment at build time, and correlate each error with the scheduled import run that stopped producing customer-support results.
The operational constraint changes the design. An alert that says "the React app threw" is weak evidence when an import expected at 02:00 produced zero tickets; the on-call engineer needs to reconstruct which release, environment, route, import run, and failure class coincided without receiving a customer's message text, email address, access token, or full URL. Error tracking is useful here only if the event contract serves that reconstruction. Everything else is storage and liability.
Keep it narrow.
Reconstruct the missing-result timeline before reading the exception
Start with the service-level symptom, not the exception. For a customer-support import, define a freshness indicator such as seconds_since_last_successful_result and a result counter for each scheduled run. The alert should fire because the import violated its freshness objective or completed with zero output outside an expected empty-input condition. OpenTelemetry distinguishes a metric's data from the event that produced it; that distinction is useful because a counter or gauge can page reliably while an error event carries the reconstruction detail.
Browser exceptions are supporting evidence. window.onerror covers uncaught synchronous script errors and resource-loading failures through the global error event, while unhandledrejection covers promises that reach the end of a turn without a rejection handler. They aren't interchangeable, and registering both doesn't prove that every failed import will be observed: a tab can close, an extension can block delivery, network access can disappear, or the actual failure can live entirely in a worker or backend scheduler. Your mileage may vary with browser policy and application architecture — measure accepted events and dropped events rather than assuming the hook is complete.
The correlation key is the import run ID. Generate it when a run is scheduled, propagate it through the API response that the frontend can safely observe, and include it in the browser event as an opaque value. Do the same with a W3C traceparent only when the current page has one; don't invent a trace identifier in the collector and pretend it proves causality. During an incident, the timeline should read: run scheduled, last successful result, frontend request or render failure, collector acceptance, run completion or timeout, alert. That sequence answers "what changed?" far better than a page of stack traces.
A practical event contract is deliberately boring:
| Field | Source | Incident use | Privacy rule |
|---|---|---|---|
event_id |
Browser-generated UUID | Deduplication | Opaque only |
occurred_at |
Browser clock | Rough ordering | Collector also records receipt time |
kind |
Handler | Split error from unhandledrejection
|
Fixed enum |
message |
Browser | Failure grouping | Scrub and truncate |
stack |
Error object | Symbolication and grouping | Scrub, truncate, never trust |
release |
Build constant | Deployment correlation | Reject unknown syntax |
environment |
Build constant | Prevent cross-environment confusion | Fixed allowlist |
import_run_id |
Scheduling workflow | Join to the stopped run | Opaque only |
route_template |
Router metadata | UI location | No query string or fragments |
Do not send component props, Redux state, form values, request headers, response bodies, location.href, or the raw promise rejection object. A rejected value can be anything. Convert it in the browser to an Error name, a short message, and a stack when present; use a constant fallback such as NonErrorRejection for everything else. This is one of those boundaries where "we'll scrub later" doesn't survive contact with an incident archive.
How can a React frontend send window.onerror and onunhandledrejection evidence to a backend collector?
Install the two listeners once at application bootstrap and remove them during teardown in development environments that remount the root. For window.onerror, read only the error name, normalized message, and stack; don't retain the source URL's query string. For unhandledrejection, inspect event.reason only long enough to produce the same small shape. Add build-injected release and environment, the current route template rather than the literal path, and the active import_run_id from workflow context.
Delivery needs a modest queue because a handler that performs synchronous work can make the original failure worse. Batch a few records, cap memory, apply a short timeout, and drop oldest records under pressure. navigator.sendBeacon can help during page dismissal, but it isn't a durable queue and its return value reports whether the user agent queued the data, not whether the collector stored it. Use ordinary asynchronous delivery while the page is active, reserve dismissal delivery for best effort, and make each event idempotent with event_id.
Don't retry forever. A collector returning 202 Accepted has taken responsibility for processing; 400 Bad Request means the payload contract is wrong and retrying it is waste; 413 Content Too Large should trigger a client-side truncation fix in the next release, not an expanding retry queue; and 429 Too Many Requests calls for bounded backoff with jitter. The browser queue itself needs a capacity assumption. If peak clients can generate 2,000 events per minute during a bad release and the collector budget is 500 per minute, sampling and grouping must happen before a fourfold burst becomes an on-call problem.
The endpoint should be same-origin when possible, authenticated by the application's existing session or protected with a narrowly scoped ingestion mechanism, and guarded against cross-site submission. A secret embedded in a React bundle isn't a secret. Also remember that error tracking can become a new telemetry loop: the collector path must not report its own delivery failures back into the same browser handler.
Capacity-plan the collector for a bad-release burst
This Go example accepts one event at a pseudonymous endpoint, limits the body to 32 KiB, uses strict JSON decoding, validates the small enums and identifiers, and scrubs common high-risk fragments before handing the record to storage. The regular expressions are defense in depth, not a claim of complete PII detection. I'm not sure any static scrubber can provide that guarantee; resolving it requires testing against your real payload corpus, retention policy, and threat model.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
)
const maxBodyBytes = 32 << 10
var (
opaqueID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,80}$`)
releaseID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
email = regexp.MustCompile(`(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}`)
bearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._~+/-]+=*`)
)
type BrowserEvent struct {
EventID string `json:"event_id"`
OccurredAt time.Time `json:"occurred_at"`
Kind string `json:"kind"`
Message string `json:"message"`
Stack string `json:"stack"`
Release string `json:"release"`
Environment string `json:"environment"`
ImportRunID string `json:"import_run_id"`
RouteTemplate string `json:"route_template"`
}
type StoredEvent struct {
BrowserEvent
ReceivedAt time.Time `json:"received_at"`
GroupKey string `json:"group_key"`
}
type EventStore interface {
PutIfAbsent(StoredEvent) error
}
type Collector struct {
store EventStore
now func() time.Time
}
func (c Collector) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var event BrowserEvent
if err := decoder.Decode(&event); err != nil {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "one event required", http.StatusBadRequest)
return
}
if !valid(event) {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
event.Message = scrubAndLimit(event.Message, 512)
event.Stack = scrubAndLimit(event.Stack, 8192)
event.RouteTemplate = scrubAndLimit(event.RouteTemplate, 160)
sum := sha256.Sum256([]byte(event.Kind + "\x00" + event.Message + "\x00" + event.Stack))
record := StoredEvent{
BrowserEvent: event,
ReceivedAt: c.now().UTC(),
GroupKey: hex.EncodeToString(sum[:12]),
}
if err := c.store.PutIfAbsent(record); err != nil {
log.Printf("collector storage error: %v", err)
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func valid(event BrowserEvent) bool {
kindOK := event.Kind == "error" || event.Kind == "unhandledrejection"
environmentOK := event.Environment == "production" || event.Environment == "staging"
timeOK := !event.OccurredAt.IsZero()
return kindOK && environmentOK && timeOK &&
opaqueID.MatchString(event.EventID) &&
opaqueID.MatchString(event.ImportRunID) &&
releaseID.MatchString(event.Release)
}
func scrubAndLimit(value string, limit int) string {
value = email.ReplaceAllString(value, "[email]")
value = bearer.ReplaceAllString(value, "[credential]")
value = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r >= 0x20 {
return r
}
return -1
}, value)
if len(value) > limit {
value = value[:limit]
}
return value
}
Production code also needs authentication, rate limiting, origin checks, durable storage, and an explicit retention job. Keep those concerns outside the decoding function so they can be tested independently. In particular, hash-based grouping is an operational convenience, not authentication and not anonymization; small or predictable strings can still reveal information through guessing.
There is a sharp edge in the example: truncating arbitrary UTF-8 by bytes can end on an incomplete code point. If downstream storage requires valid UTF-8, truncate by runes or normalize after truncation. It is worth calling out because stack data frequently contains non-ASCII file names, and a collector that accepts JSON only to produce invalid storage records damages the evidence precisely when the release is already under suspicion.
Test the reconstruction SLO before choosing who operates ingestion
Test the pipeline with synthetic errors that contain fake email addresses and fake bearer tokens, then assert that neither survives in the stored record. Exercise both handlers, duplicate event_id delivery, an unknown JSON field, a 33 KiB body, a stale browser timestamp, a missing run ID, a staging release accidentally labeled production, and a dismissal-time send. The useful assertion isn't merely "the endpoint returned 202." It is "one sanitized event can be joined to the synthetic import run and appears within the incident-reconstruction objective."
I would give the telemetry path its own SLO, separate from the customer-support import SLO: accepted-event availability, p95 acceptance latency, queue age, deduplication rate, rejected bytes, and scrub-rule match counts. Avoid paging on every rejection. Page when sustained collector loss threatens incident reconstruction; ticket contract violations to the owning team with representative, already-sanitized samples. Capacity planning should model a bad release as the normal peak case because correlated exceptions arrive in bursts, not as a tidy average.
The build decision follows ownership and failure budget, not feature count:
| Choice | Prefer it when | The catch |
|---|---|---|
| Small in-house collector | The schema is narrow, correlation is domain-specific, and the team already operates durable ingestion | You own abuse controls, symbol storage, retention, deletion, and on-call load |
| Managed error service | Source-map handling, grouping, retention controls, and operational coverage outweigh lock-in concerns | Verify data residency, deletion semantics, field filtering, export, and burst limits before sending production data |
| Existing telemetry gateway | The organization already has governed ingestion and trace correlation | Browser security, source-map workflows, and exception grouping may require additional components |
A custom collector is not suitable when nobody has capacity to own it through a high-volume bad release. A managed service is not suitable when contractual data-location rules or required deletion guarantees cannot be met. Stick with an existing gateway when it already satisfies the privacy contract and reconstruction SLO; adding a parallel error silo creates another timeline to reconcile during the incident.
Roll back collection without silencing import freshness
Deploy in shadow mode first: accept and sanitize synthetic events, but keep alert decisions tied to the import freshness metric. Then enable a small production sample, compare browser acceptance counts with stored unique IDs, inspect only sanitized fields, and raise the sample rate within a fixed ingestion budget. Source maps belong behind access control with retention aligned to releases; they should never be served as public production assets merely to make stack traces readable.
Rollback has two switches. The first disables browser collection through a configuration value that can be changed without rebuilding the frontend; the second makes the collector reject new ingestion while preserving already accepted evidence under the normal retention policy. Neither switch should disable the scheduled-import freshness alert. If telemetry starts consuming its capacity budget or privacy validation fails, turn off collection, retain the domain alert, and reconstruct from scheduler, API, and result metrics.
Finally, run a game day around the actual job: schedule an import with synthetic records, create a controlled rejected promise associated with its opaque run ID, verify the sanitized event and metric timeline, and exercise both switches. Success means the responder can identify the affected release and environment, prove which import run stopped producing results, and find no synthetic PII in the stored event. The stack is evidence. The SLO decides urgency.
Make that game day specific enough to fail usefully. At 01:55, load ten synthetic support records; at 02:00, start the scheduled import under a known run ID; after the first accepted result, trigger a rejection whose message contains a fake address such as case-owner@example.invalid and whose stack contains a fake bearer credential; then stop the synthetic producer so the freshness indicator crosses its test threshold. The responder should begin from the alert, locate the run without searching customer text, join the sanitized browser event by run ID, identify release and environment, and explain why only one result arrived. Next, deliver the same event_id twice and confirm that storage still holds one record. Finally, activate the collection rollback and repeat the stopped import: the domain alert must still fire, while the browser event does not enter storage. This rehearsal separates three claims that teams often blur together — detection works, reconstruction works, and the optional evidence path can be removed without taking detection down.
References
- 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://www.w3.org/TR/trace-context/
- https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
Top comments (0)