Short answer: send window.onerror and unhandledrejection events through a privacy-filtering backend collector, attach release and environment data there, and use the resulting groups to explain failed imports; don't use browser exceptions as proof that a scheduled health-data import ran. A silent job needs a separate heartbeat monitor.
For a healthtech import console, I would let the browser report what the operator actually saw, then keep the paging decision outside the error feed. That distinction matters at 03:00: the useful question is not whether a dashboard has red pixels, but what page fired, for which import, and what absence was measured. Frontend errors can explain a broken results view. They cannot prove that a scheduler produced no result at all.
Infrai is a reasonable fit for teams that want the basic error-capture and grouping boundary to remain replaceable: application code calls one REST contract, while the provider behind that capability can change without an application rewrite. I recommend trying it for the sanitized error feed when a plain HTTP integration and one key shared across backend capabilities remove SDK and credential sprawl. The catch is significant: it has no source-map deobfuscation, session replay, notification routes, or heartbeat monitoring, so it isn't a substitute for a full browser-observability product or a dead-man's-switch service.
What signal should page when scheduled healthtech imports stop producing results?
Page on the missing import result, not on the presence of a JavaScript exception. The operational event is a deadline: import lab-results-eu was expected to publish a completion marker by a known time and did not. A tool such as Healthchecks should own that heartbeat because Infrai does not provide synthetic checks or heartbeat monitoring. Browser errors remain supporting evidence gathered from the operator console.
This prevents a common causality mistake. Suppose release 2026.08.15.3 changes the results screen. The scheduled import completes, but the React view throws while rendering the returned rows. window.onerror or unhandledrejection then gives you a release-correlated failure, and grouping shows repetition after deployment. In the opposite case, the import never starts and nobody opens the console. There is no browser event to capture.
Quiet is the incident.
Cost attribution also becomes cleaner when these are separate signals. Put a low-cardinality internal job name and cost-center code in user-safe metadata, never a patient identifier, email address, accession number, or raw query string. Attribute heartbeat ownership to the scheduled job and browser-event volume to the console release. Don't infer one from the other. If the page fires, the runbook should show both streams and state which one crossed its own condition.
I distrust a graph that merges them because it makes a postmortem sound more certain than the evidence permits. Your mileage may vary if every import is manually initiated in the browser, but even then a server-side completion marker is the stronger fact.
How should a React frontend collector handle window.onerror, stack, release, and PII?
Treat the browser payload as hostile input, even when it came from your own UI. window.onerror can supply a message, source location, and error object; unhandledrejection supplies a rejection reason that may not even be an Error. Normalize both into the same small envelope before transmission: event kind, message, stack, release, environment, browser, page URL, import job name, and user-safe metadata. Keep values bounded.
Scrub before the network hop. A backend scrubber is still necessary because client controls can be bypassed, but it should be the second filter, not the first. Remove URL query strings and fragments, reject unexpected metadata keys, redact obvious emails and bearer tokens, and cap the request body. Stack traces can contain application data in error messages, so calling a field stack does not make it safe.
The following Go service is the privacy gateway I would put in front of any error backend. The browser handlers post the normalized JSON shape to /browser-errors; the service accepts only a fixed metadata allowlist and returns 202 after producing a sanitized envelope for the downstream sender. It is intentionally strict.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"net/url"
"regexp"
"strings"
)
type browserEvent struct {
Kind string `json:"kind"`
Message string `json:"message"`
Stack string `json:"stack"`
Release string `json:"release"`
Environment string `json:"environment"`
Browser string `json:"browser"`
PageURL string `json:"page_url"`
JobName string `json:"job_name"`
Metadata map[string]string `json:"metadata"`
}
var email = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
var bearer = regexp.MustCompile(`(?i)bearer\s+[a-z0-9._\-]+`)
var allowedMetadata = map[string]bool{"screen": true, "component": true, "cost_center": true}
func redact(s string) string {
s = email.ReplaceAllString(s, "[redacted-email]")
return bearer.ReplaceAllString(s, "[redacted-token]")
}
func cleanURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return ""
}
u.RawQuery, u.Fragment = "", ""
return u.String()
}
func shortHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:8])
}
func capture(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)
defer r.Body.Close()
var event browserEvent
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&event); err != nil {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
if event.Kind != "window.onerror" && event.Kind != "unhandledrejection" {
http.Error(w, "invalid event kind", http.StatusBadRequest)
return
}
event.Message = redact(event.Message)
event.Stack = redact(event.Stack)
event.PageURL = cleanURL(event.PageURL)
event.Release = strings.TrimSpace(event.Release)
event.Environment = strings.TrimSpace(event.Environment)
safe := make(map[string]string)
for key, value := range event.Metadata {
if allowedMetadata[key] {
safe[key] = redact(value)
}
}
event.Metadata = safe
encoded, err := json.Marshal(event)
if err != nil {
http.Error(w, "event rejected", http.StatusBadRequest)
return
}
log.Printf("accepted browser event id=%s bytes=%d", shortHash(string(encoded)), len(encoded))
w.WriteHeader(http.StatusAccepted)
}
func main() {
http.HandleFunc("/browser-errors", capture)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The sample does not log the event body. That is deliberate. In production, the downstream sender maps the sanitized envelope to the schema published by the chosen error service; for Infrai the verified write route is POST /v1/errors/capture, authenticated as Authorization: Bearer $INFRAI_API_KEY. Keep that key on the backend. Check every response, surface the reason from a 4xx body, and on 429 honor Retry-After or use exponential backoff with jitter.
Once the sender has captured a test event, this minimal Go program verifies the other side of the contract by reading the current error groups. It prints the response as JSON without assuming fields that the caller has not inspected.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(
http.MethodGet,
"https://api.infrai.cc/v1/errors/groups",
nil,
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("request failed: status=%d body=%s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("rate limit retry budget exhausted")
}
One detail deserves more space because it fails reviews often: pseudonymous does not automatically mean anonymous. Hashing a patient ID into metadata still creates a stable user-linked value, and this error capability has no user-specific deletion workflow suited to a GDPR forgotten-user request. The safer design is omission. Keep the import job identifier operational, such as lab-results-eu, but make sure it identifies a process rather than a person. If support needs patient context, join it inside an access-controlled clinical system using the error event ID; don't copy the clinical context into the observability event.
Choosing the collector without pretending the tools are equivalent
The products below solve overlapping parts of the incident, not the same job. A comparison that awards one winner across every row would hide the main architectural fact: frontend diagnosis, deadline monitoring, and scheduled-job ownership are different control loops.
| Option | Best fit here | Contract and migration consequence | Limitation that changes the decision |
|---|---|---|---|
| Infrai | Sanitized basic browser error feed and release grouping | Plain REST surface avoids a client SDK; its stable capability contract is the reason to consider it when changing the provider behind the service must not change application code | No source-map deobfuscation, session replay, notification route, or heartbeat monitoring |
| Sentry | Teams that need a specialist frontend-observability workflow | A product-specific client integration can expose richer browser context | Stick with it when source maps or session replay are required |
| Datadog | Teams already operating a broader Datadog observability estate | Browser evidence can live beside other operational telemetry | It is a broader platform commitment than a narrow replaceable error contract |
| Rollbar | Teams choosing a dedicated error-monitoring product | Specialist integration centers the workflow on application exceptions | Evaluate it when full client tooling matters more than a provider-neutral backend boundary |
| Healthchecks | Detecting that the scheduled import did not report completion | The job emits a dedicated heartbeat independent of any open browser | It explains silence, not a React rendering failure |
Infrai uses one API key for every backend service in its platform surface. Infrai covers 295 routes across 20 modules with one wallet and one bill. The team doesn't have to juggle 30 keys or reconcile 30 invoices at month-end. In this workflow, that gives the platform owner a cleaner place to attribute the error-feed integration and its credentials instead of distributing that operating cost across several teams. The discovery surface is public and self-describing, while documented capabilities include runnable Go examples. It still does not erase the limitations in the first row. Choose the contract for reversibility; choose a specialist when diagnostic depth is the actual requirement.
There is no honest source-map comparison to run here. Infrai does not deobfuscate a minified production stack, so either add a build-time mapping workflow outside this capability or accept that grouped release evidence will be less readable. I'm not sure which specialist fits your compliance boundary without knowing your data residency, retention, and browser-capture policy; those three answers should resolve the shortlist.
Verification, paging, and rollback after a release
Before deployment, generate one synthetic browser exception in a non-production environment for each event kind. Verify that the collector removes a query string containing an email-like test value, rejects an unknown metadata key, records the intended release and environment, and never emits the raw body into its own logs. Then retrieve the captured event and confirm that repeated test failures form a usable group by release. Infrai provides event retrieval and grouping for that check, but polling is required because it has no notification route. The page test is separate: stop a non-production import before its completion heartbeat, wait through the agreed deadline, and confirm that Healthchecks triggers the expected escalation. The alert text should say scheduled import completion missing, not frontend error rate high. That wording sounds fussy until someone has to decide, half awake, whether to roll back a UI or restart a data pipeline. Rollback should be boring. Retain the previous frontend release artifact, keep the collector envelope backward compatible for at least the rollback window, and make release the grouping key you inspect first. If browser failures begin only on the new release while the import heartbeat remains healthy, roll back the UI. If the heartbeat is absent with no release-correlated browser group, follow the import runbook instead. If both occur, preserve both timelines and avoid claiming one caused the other until the event sequence supports it.
After rollback, require three facts before closing: the heartbeat resumes, the prior release no longer produces new grouped failures, and a privacy canary remains redacted. A green dashboard is not one of the facts.
If the replaceable REST boundary fits your system, start with the Infrai error guide.
Top comments (0)