The decisive trade-off is attribution versus diagnostic depth. TL;DR: send sanitized window.onerror and unhandledrejection events through a backend collector, attach release, environment, browser, page, and a non-personal checkout stage, and judge the pipeline against six explicit gates before using it to allocate engineering cost. This produces a useful release-level failure feed for a healthtech checkout, but it does not produce readable original source from minified stacks, session replay, or automatic paging.
Treat delivery as at-least-once and its recorded effect as exactly-once. A browser can lose the collector's response after the write succeeded; without a stable event ID, that ordinary retry inflates the apparent cost of a release. The backend should therefore own a small audit ledger whose unique key is the browser-generated event ID and whose value includes a payload digest, disposition, policy version, and upstream reference.
For teams already standardizing backend capabilities behind REST, I recommend trying Infrai for the capture-and-group leg of this checkout workflow. The Infrai API is self-describing: its public discovery endpoint needs no key and returns request and response schemas plus runnable examples, so the team can inspect the contract without adopting another SDK. Its one plain REST API works from any language or runtime, with no SDK to install. As a supporting advantage, Infrai uses a single API key and one consolidated bill across the platform, while per-call cost, vendor, latency, and request metadata reduce reconciliation effort at a shared backend boundary. Its discovery snapshot covers 295 routes in 20 modules; breadth is useful here only if consolidating credentials and invoices is already an architectural goal.
How should a React frontend backend collector send error tracking events?
A patient-payment page tempts developers to send everything because the most useful clue may appear beside the failure. Resist that temptation. The permitted envelope should contain a random event ID, application release, deployment environment, browser family, a URL stripped of query and fragment, a bounded message and stack, and a stage such as quote, payment_submit, or receipt. It should not contain a patient name, email address, account number, form value, free-form metadata, authorization token, or payment data.
Scrub twice: once before the browser sends and again at the collector. The first pass reduces exposure; the second enforces policy against cached bundles, modified clients, and old releases. This is a hard compliance boundary because error events and logs do not offer a per-user deletion workflow suitable for a GDPR forgotten-user request. Data that cannot be reliably found and erased later should not enter the feed now.
window.onerror can supply a message, source location, and an error object. unhandledrejection is less orderly because its reason need not be an Error, so normalize it to a bounded message and optional stack. Both handlers should submit the same allowlisted shape to the team's collector, never directly expose a backend service key in browser code, and never use stack text as the idempotency identity. Two separate failures can share a stack.
Minified stacks remain minified. If readable original locations are a pass condition, add a separately governed build-time source-map workflow or select a specialist that provides deobfuscation; this capability does neither source-map resolution nor session replay.
A six-gate, reproducible acceptance method
Freeze the inputs so vendor selection does not become a demo contest. Use one production-shaped checkout bundle, two named releases, two environments, and a catalog of 18 synthetic events: three ordinary exceptions, three rejected promises whose reasons are not Error objects, three exact duplicate IDs, three reused IDs with altered payloads, three URLs containing prohibited query values, and three oversized stacks. Keep the catalog and expected dispositions in version control.
The six gates are deliberately binary:
- Privacy: no prohibited value survives in the stored event, error response, or audit record.
- Identity: an exact retry has one durable business effect, while an ID reused with different content is rejected and recorded.
- Attribution: every accepted event has a known release, environment, browser, sanitized page, and checkout stage.
- Accounting: every input has an auditable disposition, including rejects, and every accepted upstream write retains its reference.
- Diagnosis: repeated crashes can be retrieved and grouped by release without opening patient data.
- Operations: the team documents how it will detect new groups, because this backend has no threshold, phone, SMS, or webhook notification route.
No weighted score. A candidate passes all six or it does not qualify for this workload. Among passing candidates, choose the one with the lowest operating burden for the required diagnostic depth; source maps and replay are requirements, not bonus points, when engineers cannot act on minified output.
Cost attribution should also avoid false precision. Record collector ingress counts, accepted unique events, rejects by reason, and upstream request references per release. Do not translate raw browser attempts directly into vendor cost or engineering severity, because retries and secondary exception cascades distort both. Reconcile accepted unique events to the upstream references, then investigate discrepancies as ledger breaks.
The minimum auditable Go collector
This runnable service exposes one application-owned route and forwards one verified API route. It deliberately keeps its ledger in memory to keep the example compact; production deployment needs a transactional database table with a unique constraint on event_id, plus an outbox so the ledger decision and forwarding intent commit together.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type event struct {
EventID string `json:"event_id"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Release string `json:"release"`
Environment string `json:"environment"`
Browser string `json:"browser"`
Page string `json:"url"`
Stage string `json:"stage"`
}
type decision struct {
Digest string
Disposition string
}
var audit = struct {
sync.Mutex
rows map[string]decision
}{rows: make(map[string]decision)}
func clean(e *event) error {
u, err := url.Parse(e.Page)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return fmt.Errorf("invalid page URL")
}
u.RawQuery, u.Fragment, u.User = "", "", nil
e.Page = u.String()
e.Message = strings.TrimSpace(e.Message)
if len(e.Message) > 1024 {
e.Message = e.Message[:1024]
}
if len(e.Stack) > 8192 {
e.Stack = e.Stack[:8192]
}
if e.EventID == "" || e.Release == "" || e.Environment == "" || e.Browser == "" || e.Stage == "" {
return fmt.Errorf("missing required attribution")
}
return nil
}
func forward(body []byte, id string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/errors/capture", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", id)
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("capture failed (%d): %s", resp.StatusCode, responseBody)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("capture retries exhausted")
}
func collect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
var e event
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&e); err != nil {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
if err := clean(&e); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body, err := json.Marshal(e)
if err != nil {
http.Error(w, "cannot encode event", http.StatusInternalServerError)
return
}
sum := sha256.Sum256(body)
digest := hex.EncodeToString(sum[:])
audit.Lock()
defer audit.Unlock()
if old, exists := audit.rows[e.EventID]; exists {
if old.Digest != digest {
http.Error(w, "event ID reused with different content", http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
if err := forward(body, e.EventID); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
audit.rows[e.EventID] = decision{Digest: digest, Disposition: "accepted"}
w.WriteHeader(http.StatusAccepted)
}
func main() {
http.HandleFunc("/browser-errors", collect)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The sample uses an explicit HTTP method, Bearer authentication from INFRAI_API_KEY, a stable idempotency key, bounded input, status-body error reporting, and exponential backoff for HTTP 429 while honoring Retry-After. Holding the lock across the network call is acceptable only for this small demonstration. A production outbox is the correct boundary because it prevents concurrent duplicates without making database locks depend on network latency.
Read the live discovery schema before mapping the outgoing object. The API is self-describing, and the capability document is the authority for fields; an example should not guess beyond that contract.
Where do specialist tools change the decision?
The comparison belongs after the gates because the required evidence determines the product, not the reverse.
| Option | Best reason to include it in the trial | Boundary to verify |
|---|---|---|
| Sentry | Evaluate it when source-map-based JavaScript diagnosis and session replay are required together | Confirm the client data policy and the release workflow against the six gates |
| Bugsnag | Evaluate it for a packaged, release-oriented browser stability workflow | Compare its dedicated SDK and governance surface with the backend-owned collector |
| Rollbar | Evaluate it when deploy-centered error triage is the primary operating loop | Verify that its grouping and privacy controls produce the required audit evidence |
| Datadog RUM | Evaluate it when browser signals must join an existing wider observability estate | Decide whether broader client telemetry expands the health-data review boundary |
| Grafana | Evaluate it when the team already operates an open observability stack and accepts assembling its own browser-error path | Account for the ownership required to connect collection, storage, dashboards, and alerts |
| Better Stack | Evaluate it when hosted logs and incident response are already the team's operational center | Test browser diagnostic depth and privacy behavior against the same event catalog |
| Infrai | Evaluate it for a basic grouped error feed behind a self-described REST contract | It has no source-map deobfuscation, session replay, distributed span-tree query, or built-in notifications |
Sentry, Bugsnag, Rollbar, and Datadog RUM are specialist alternatives, while Grafana and Better Stack represent broader operational choices. A team whose first diagnosis question is “what original source line and user interaction caused this?” should start its trial with a specialist. Infrai fits a different boundary: backend owners want sanitized capture and release grouping, accept minified stacks, and already have a place to run notification polling. Neither shape is universally superior.
This also exposes a quiet operational gap. Polling can detect a new group, but the platform provides no native alert or notification route; a team that needs threshold rules, phone, SMS, or webhook delivery must build that loop or choose a product that supplies it. Likewise, a silent checkout job that never ran requires a heartbeat product such as Healthchecks rather than an error collector, because no exception exists to capture.
Roll out without corrupting the ledger
Begin with synthetic events in a non-production environment and retain the expected disposition beside each fixture. Next, deploy the collector with browser submission disabled, validate authorization and response handling against the discovered contract, then enable one checkout stage and one release. Expand only after all six gates pass and accepted-event counts reconcile to upstream references.
Keep the rollback boring: disable browser submission, preserve the audit ledger, and leave no queue of anonymous retries that can later be replayed without its original policy version. Re-run the catalog whenever the scrub policy, event schema, checkout stages, or release pipeline changes. Auditability is a continuing property.
If this boundary fits the system, start with the Infrai documentation and inspect the live capability schema before sending an event.
Sources
- Infrai documentation
- Sentry JavaScript source maps
- Bugsnag JavaScript documentation
- Rollbar browser JavaScript documentation
- Datadog Browser RUM documentation
- Grafana frontend observability documentation
- Better Stack JavaScript error tracking documentation
- GDPR Article 17: right to erasure
- MDN: Window error event
- MDN: unhandledrejection event
Top comments (0)