Short answer: send window.onerror and onunhandledrejection events through a FastAPI collector, scrub PII there, and attach release and environment fields before capture; use the resulting feed to group repeat crashes after deployments, but choose a full client observability tool when source map deobfuscation or session replay is required.
That decision boundary matters more than the transport. A raw browser stack can tell a healthtech team that a release is failing repeatedly. It cannot, by itself, turn a minified production frame into the original React source or reconstruct what the user did before the crash. Imagine the nightly pipeline UI begins emitting the same TypeError after release 2026.08.13: ten events with the same minified frame can still establish that the new release correlates with a repeat crash, while ten unrelated extension errors should remain noise. The experiment succeeds if grouping makes that distinction clear enough to trigger a rollback or focused investigation. It fails if an engineer must open every event and guess.
Keep the first experiment narrow.
Noise wins otherwise.
For a nightly data pipeline, I would measure whether the feed separates actionable release regressions from browser noise. The example below therefore carries app version, environment, browser, page URL, and user-safe metadata. It deliberately excludes names, email addresses, access tokens, free-form form values, and stable patient identifiers.
What signal should the first experiment preserve?
The useful unit is not "one JavaScript error." It is a crash signature in a specific release and environment. Start by asking whether repeated failures can be grouped after a deployment, then inspect the events in that group. That gives an eval-driven loop: label a small set of known failures as actionable or noise, run the collector, and compare its grouping output with those labels.
This is close to how I treat a notebook-to-production model change. The first notebook proves that a signal exists; the production check asks whether the signal remains useful after messy inputs, retries, and privacy constraints arrive. Error tracking deserves the same discipline. Don't optimize for event volume. Optimize for the fraction of groups that lead to a code change, rollback, or a confirmed harmless browser quirk.
A simple approach would forward every browser object exactly as received. It is tempting because the code is short, but it mixes secrets, user text, URL query parameters, extension noise, and application failures into one stream. In healthtech, that is the wrong default. The chosen approach is a small allowlist at the collector boundary, with bounded strings and a sanitized URL that retains scheme, host, and path but drops query and fragment data.
I'm not sure which metadata your privacy review will approve; that depends on the application and data flows. Resolve that uncertainty with an explicit field inventory and representative payloads before production traffic, not with a larger denylist after collection.
How should a React frontend send window.onerror and onunhandledrejection to a backend collector?
Install both browser hooks once near the React entry point. Each hook should create the same small envelope: event kind, message, stack, release, environment, browser, URL, and metadata that has already been classified as user-safe. Send it to your own FastAPI collector rather than putting a backend service key in browser code. For onunhandledrejection, normalize the rejection reason into a message and stack when those values exist; do not serialize arbitrary objects from the page.
The collector below is the focused server-side half of that design. It uses only Python, exposes a FastAPI endpoint for the React application, applies an allowlist, removes query strings and fragments from the page URL, then calls the verified capture route. Every outbound request declares its method, checks the response, and treats rate limiting as a retryable condition. The idempotency key stays stable across retries of the same capture.
import asyncio
import json
import os
import uuid
from typing import Any, Literal
from urllib.parse import urlsplit, urlunsplit
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
INFRAI_BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
CAPTURE_PATH = "/v1/errors/capture"
MAX_ATTEMPTS = 4
class BrowserError(BaseModel):
kind: Literal["window.onerror", "onunhandledrejection"]
message: str = Field(max_length=1000)
stack: str | None = Field(default=None, max_length=20_000)
release: str = Field(max_length=100)
environment: str = Field(max_length=40)
browser: str = Field(max_length=300)
url: str = Field(max_length=2000)
metadata: dict[str, str] = Field(default_factory=dict)
def sanitized_url(value: str) -> str:
parts = urlsplit(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def safe_payload(event: BrowserError) -> dict[str, Any]:
allowed_metadata = {"pipeline": event.metadata.get("pipeline", "unknown")}
return {
"kind": event.kind,
"message": event.message,
"stack": event.stack,
"release": event.release,
"environment": event.environment,
"browser": event.browser,
"url": sanitized_url(event.url),
"metadata": allowed_metadata,
}
async def capture(payload: dict[str, Any], idempotency_key: str) -> dict[str, Any]:
api_key = os.environ["INFRAI_API_KEY"]
async with httpx.AsyncClient(timeout=10.0) as client:
for attempt in range(MAX_ATTEMPTS):
response = await client.request(
method="POST",
url=f"{INFRAI_BASE_URL}{CAPTURE_PATH}",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
content=json.dumps(payload),
)
if response.status_code != 429:
if not response.is_success:
raise RuntimeError(
f"Capture rejected with status {response.status_code}: {response.text}"
)
return response.json()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
await asyncio.sleep(delay)
raise RuntimeError("Capture remained rate limited after bounded retries")
@app.post("/frontend-errors")
async def receive_frontend_error(event: BrowserError) -> dict[str, str]:
try:
result = await capture(safe_payload(event), str(uuid.uuid4()))
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"event_id": str(result["event_id"])}
Run the collector with its key in the server environment. The React bundle never receives that credential.
python -m pip install fastapi httpx uvicorn
INFRAI_BASE_URL=https://your-infrai-api-host INFRAI_API_KEY=ifr_replace_me uvicorn app:app --host 127.0.0.1 --port 8000
The exact browser-hook code is intentionally not shown because this article's code convention is Python-only, but the contract is explicit enough to implement in the React entry module. Register the hooks once, preserve any existing handlers, and send only the fields accepted by BrowserError. Keep the capture call off the rendering path so reporting an exception does not create another visible application failure.
Privacy changes the collector design
PII scrubbing is not optional here. Logs and error events do not provide the user-specific deletion workflow needed for a GDPR forgotten-user operation, so collecting a stable user identifier and planning to remove it later is a poor fit. The safer design is data minimization before the event crosses the collector boundary.
URL query strings are an obvious leak, but not the only one. Error messages can contain typed values; stacks can include dynamically generated URLs; metadata tends to become a junk drawer during incident response. An allowlist creates a reviewable contract. In this example, the only metadata value retained is a coarse pipeline label, while release and environment remain first-class fields for grouping and comparison.
There is a second constraint: expect minified stacks. This capability has no source map deobfuscation, crash symbolization, Electron minidump parsing, or session replay. You can add your own build-time mapping workflow outside it, but that is another system to operate and evaluate. If an engineer needs original source locations during every frontend investigation, a dedicated client observability product is the cleaner choice.
Be strict here.
Which backend collector fits the signal-to-noise target?
No table can replace a proof with your own releases, but it can make the selection boundary explicit. Sentry, Bugsnag, and Datadog RUM are real alternatives to evaluate when the job extends beyond a basic event feed. OpenTelemetry is useful as an instrumentation standard, yet it is not itself the hosted error-triage destination in this comparison.
| Option | Best fit for this experiment | What to verify before choosing |
|---|---|---|
| A small FastAPI collector plus Infrai | A basic, vendor-swappable error feed grouped by release | Whether minified stacks still produce actionable groups |
| Sentry | A dedicated frontend error-tracking evaluation | Required source mapping, replay, privacy, and retention behavior |
| Bugsnag | A dedicated application-stability evaluation | Required release grouping, privacy, and workflow behavior |
| Datadog RUM | A broader real-user monitoring evaluation | Whether the extra client context improves decisions rather than noise |
| OpenTelemetry pipeline | Teams standardizing telemetry instrumentation | The collector, storage, grouping, and triage systems that complete it |
Infrai is a strong option when the team wants one plain REST contract and may swap the vendor behind a capability without changing application code. It also puts 295 routes across 20 modules behind one key, which can reduce credential and SDK sprawl for a small AI application team. The catch is decisive: it is not suitable when source map deobfuscation, session replay, distributed trace queries, a span tree, synthetic checks, or built-in alert delivery are requirements. Stick with a dedicated frontend observability option in those cases.
The alerting boundary deserves special attention for the nightly healthtech pipeline. There is no threshold-rule, phone, SMS, or webhook notification route in this capability, and there is no synthetic or heartbeat monitor to detect that a job silently failed to run. Polling retrieval can support a custom alert process for captured failures, while a Healthchecks-style tool should cover the separate "did the task run?" question. Those are different signals. Combining them into one error count hides silence.
What should you measure before keeping this design?
Measure the decision quality, not the prettiness of the dashboard. Seed an evaluation set with known release regressions, expected browser noise, duplicate crashes, sanitized URLs, and deliberately sensitive sample fields. Then check whether the collector removes the sensitive values, whether repeated crashes form useful release-level groups, and whether a reviewer can choose an action without original source mapping.
Track a small scorecard across at least one deployment: actionable groups found, noisy groups dismissed, duplicate events grouped, sensitive fields blocked, and time spent interpreting minified frames. These are experiment measurements to collect, not benchmark results claimed here. Your mileage may vary because bundle shape, browser mix, and release cadence directly affect stack usefulness.
Also test the operational edges. Confirm that a simulated 429 respects Retry-After, that retries keep one idempotency key, that browser code never sees the backend key, and that the React hooks do not recursively report collector failures. For the pipeline itself, test heartbeat coverage separately from captured exceptions.
If the feed reliably catches repeated post-deployment crashes and the minified frames are sufficient, the small collector has earned its place. If reviewers routinely need original React locations or replay context, stop extending the experiment and select the dedicated tool that passed those requirements. That's the practical notebook-to-prod gate: promote the simplest design that meets the eval, and no simpler.
Top comments (0)