DEV Community

EastonPierce8265
EastonPierce8265

Posted on

Frontend Error Tracking: Cost-Attributed Delivery Failure Reports Without an SDK

Short answer: use a React error boundary, window error and unhandledrejection listeners, and a small fetch client that posts normalized failures to your own backend; this is enough for basic JavaScript error tracking without an SDK, but use a specialist product when source maps, symbolication, or session replay are part of the incident workflow.

For a media notification service, the architecture decision is to capture only failures that help answer two questions: which release broke delivery management, and which product or tenant should own the telemetry cost? The browser should never hold an observability vendor key. It sends a narrow event to the application backend, which validates, rate-limits, redacts, and routes the record. Don't make the browser choose a vendor.

The core invariant is one accepted event per logical client failure, with release, url, an appropriate pseudonymous user_id, and a client-generated fingerprint available for grouping. The privacy invariant is stricter: error text is untrusted input, and payloads must omit message content, email addresses, notification bodies, and other unnecessary PII. This design deliberately favors a small, attributable event stream over polished crash analysis.

What are the invariants and failure boundaries?

A React error boundary covers render-time failures in its descendant component tree. It doesn't replace the global listeners needed for errors outside that boundary and rejected promises. Those three sources should converge on one envelope before fetch runs, because three unrelated payload shapes create three grouping rules, three retention questions, and eventually three interpretations of the same dashboard.

For the notification console, use dimensions with bounded operational meaning: source can be react, window, or promise; release comes from the deployed build; area might be delivery-list, template-editor, or audience-picker. Keep raw URL paths only after removing query strings and route parameters. A user ID is optional and should be included only when it is appropriate to the privacy model. If GDPR deletion by user is required, a log-shaped destination is a poor fit because Infrai has no per-user log deletion API. Minimize the payload before storage rather than assuming deletion can repair over-collection later.

The fingerprint is a grouping hint, not evidence that two failures share one root cause. A useful client input combines the normalized error name, the first stable stack frame, the release, and the product area. Avoid using full stack text, timestamps, URLs with IDs, or the user ID in that hash. Each of those values raises cardinality; a timestamp guarantees nearly one group per event, which defeats grouping entirely.

Failure boundaries also belong in the ADR. A 400 from the application endpoint means the browser envelope failed validation and shouldn't be retried. A 429 means back off and honor Retry-After. Network failure may justify a small bounded retry, but the client must discard the event when its retry budget is exhausted rather than accumulating an unbounded local queue. Quiet loss is acceptable here only because this stream diagnoses notification-console failures; it is not the source of truth for whether a notification was delivered.

That distinction matters.

How should a React error boundary send JavaScript errors to a backend API?

Install the global listeners once at application startup and mount one error boundary around the notification workflow. Both paths call the same asynchronous reporter. The reporter builds the fingerprint in the browser, truncates fields before transport, and posts to /api/client-errors, an endpoint owned by the media application. The backend can then add trusted tenant and service context; accepting either value directly from the browser would make cost attribution easy to falsify.

This example is intentionally small, but it has the parts that tend to disappear in a sketch: an explicit method, status checking, exponential backoff for 429, Retry-After, bounded attempts, and no embedded vendor credential. It reports the error and still lets the boundary render its fallback. The sample assumes APP_RELEASE is injected as a non-secret build identifier.

const APP_RELEASE = "media-web-2026.08.19";
const MAX_ATTEMPTS = 3;

function normalize(value, limit) {
  return String(value ?? "unknown")
    .replace(/[?#].*$/, "")
    .replace(/\b[0-9a-f]{8,}\b/gi, ":id")
    .slice(0, limit);
}

async function fingerprint(parts) {
  const bytes = new TextEncoder().encode(parts.join("|"));
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest))
    .slice(0, 12)
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

function retryDelay(response, attempt) {
  const retryAfter = response.headers.get("Retry-After");
  const seconds = Number(retryAfter);
  if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
  return 250 * 2 ** attempt;
}

async function reportClientError({ error, source, area }) {
  const name = normalize(error?.name, 80);
  const message = normalize(error?.message ?? error, 500);
  const firstFrame = normalize(error?.stack?.split("\n")[1], 300);
  const event = {
    source,
    area,
    name,
    message,
    first_frame: firstFrame,
    release: APP_RELEASE,
    url: normalize(location.pathname, 300),
    fingerprint: await fingerprint([name, firstFrame, APP_RELEASE, area]),
  };

  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
    const response = await fetch("/api/client-errors", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      keepalive: true,
    });

    if (response.status === 429 && attempt + 1 < MAX_ATTEMPTS) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }
    if (!response.ok) {
      const reason = await response.text();
      throw new Error(`error report rejected: ${response.status} ${reason}`);
    }
    return;
  }
}

window.addEventListener("error", (event) => {
  void reportClientError({
    error: event.error ?? event.message,
    source: "window",
    area: "notification-console",
  }).catch(() => {});
});

window.addEventListener("unhandledrejection", (event) => {
  void reportClientError({
    error: event.reason,
    source: "promise",
    area: "notification-console",
  }).catch(() => {});
});

class NotificationErrorBoundary extends React.Component {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  componentDidCatch(error) {
    void reportClientError({
      error,
      source: "react",
      area: "delivery-list",
    }).catch(() => {});
  }

  render() {
    if (this.state.failed) return <p>Delivery status is unavailable.</p>;
    return this.props.children;
  }
}
Enter fullscreen mode Exit fullscreen mode

The empty catches prevent observability failure from breaking the interface. They do not imply that the backend can ignore rejected reports: count acceptance, validation rejection, and rate limiting at that trusted boundary. A client retry may also duplicate an accepted request if its response is lost, so deduplicate by a server-issued or client event ID if exact ingestion counts matter. The fingerprint itself should not be used as that ID because repeated occurrences of one error are useful data.

After validation and redaction, the backend can map that envelope to the current error-capture schema exposed by discovery. The transport below deliberately accepts the mapped JSON through an environment variable rather than claiming fields that aren't declared here. Set INFRAI_API_BASE_URL to the documented v1 base, INFRAI_API_KEY to a server-side key, and INFRAI_ERROR_PAYLOAD to the validated JSON. The loop makes the POST explicit, surfaces a rejected body, and honors an integer Retry-After on 429; run it on the server, never in React.

body_file="$(mktemp)"
header_file="$(mktemp)"
trap 'rm -f "$body_file" "$header_file"' EXIT

for attempt in 0 1 2; do
  status="$(curl --silent --show-error \
    --request POST \
    --url "${INFRAI_API_BASE_URL}/errors/capture" \
    --header "Authorization: Bearer ${INFRAI_API_KEY}" \
    --header "Content-Type: application/json" \
    --data-binary "${INFRAI_ERROR_PAYLOAD}" \
    --dump-header "$header_file" \
    --output "$body_file" \
    --write-out "%{http_code}")"

  if [ "$status" = "429" ] && [ "$attempt" -lt 2 ]; then
    retry_after="$(awk 'tolower($1) == "retry-after:" { gsub("\\r", "", $2); print $2 }' "$header_file")"
    case "$retry_after" in
      ''|*[!0-9]*) retry_after="$((2 ** attempt))" ;;
    esac
    sleep "$retry_after"
    continue
  fi
  if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
    printf 'capture rejected: status=%s body=%s\n' "$status" "$(<"$body_file")" >&2
    exit 1
  fi
  cat "$body_file"
  exit 0
done

printf 'capture rejected: retry budget exhausted\n' >&2
exit 1
Enter fullscreen mode Exit fullscreen mode

No browser key. Ever.

How much telemetry should a delivery-failure tracker retain?

Start with bytes, not vendor feature lists. Suppose a planning model has 50,000 captured failures per day after sampling, and the normalized JSON averages 1.2 KB. That is roughly 60 MB per day, or 1.8 GB for a 30-day hot-retention window, before indexing overhead and replicas. These are illustrative inputs, not a benchmark. Measure the real serialized payload and replace every number before approving capacity. Then repeat the estimate by media product rather than allocating the total by request count: the template editor may emit large stacks at low frequency, while the delivery list may emit a compact rejection many times during one outage at an upstream notification provider. Charging both products the same average rate hides the exact behavior the ledger is supposed to expose. Record accepted bytes, sampled-out occurrences, and retained groups independently, because a sampling change can lower stored bytes while leaving the underlying failure rate untouched. Finally, add index and replica multipliers from the destination actually selected; pretending raw JSON size equals billed storage makes a tidy spreadsheet and a weak capacity decision.

That is the budget boundary.

Cardinality is usually the sharper bill. Three sources, three product areas, and 20 active releases have a theoretical 180-cell base cube before fingerprints. Add 10,000 fingerprints and a user identifier to every metric label, and the system stops behaving like a small operational aggregate. Keep fingerprints and user references as event fields; reserve metric labels for bounded dimensions such as release, source, area, and severity. Logs are event streams, but that doesn't make every event field a useful index.

Sampling should preserve the first occurrence of a fingerprint per release and then apply a rate to repeats. One defensible policy is to keep the first 20 events for a group in an hour, then 5% of later events, while maintaining an unsampled counter at the backend. Again, those values are a starting hypothesis. I'm not sure what rate protects your rare notification failures until the team compares sampled groups with a short, access-controlled full-capture window. The decision metric is group recall, not raw event volume.

Retention should follow the release and response cycle. If the media frontend deploys daily and an operator investigates within seven days, keeping detailed client events for months needs a separate justification. Aggregate counts by release and fingerprint can live longer than payloads. This split gives finance a stable attribution ledger without preserving browser context indefinitely — and it makes the deletion boundary easier to explain.

Small payloads win.

Which backend error-tracking option fits cost attribution?

The options differ less in their ability to accept an error than in who owns grouping, enrichment, deletion, and the bill. A fair evaluation should replay the same redacted corpus, count resulting groups, inspect minified stacks, and record operational work. Don't compare a custom endpoint's ingestion bill with a specialist's full workflow as though the outputs were equivalent.

Option Best fit Cost-attribution approach Limitation to price into the decision
Application backend plus Infrai Teams consolidating backend capabilities behind plain HTTP Add trusted tenant context server-side; one key and one bill reduce credential and invoice sprawl Basic error records only; no source-map deobfuscation, symbolication, session replay, or built-in alert routing
Sentry Teams that want a specialist error-tracking workflow Validate project and team allocation against the organization's chargeback model Another vendor contract and integration boundary to operate
Datadog Teams already attributing frontend and backend telemetry in one observability estate Test whether existing service and ownership tags map cleanly to media products A broad observability suite may exceed the needs of a small error-only pipeline
New Relic Teams already using its browser and application monitoring estate Reuse the established account and application hierarchy where it matches finance ownership Migration value is weaker when no adjacent telemetry is moving
Self-hosted collector Teams with strict data-placement or deletion controls Meter storage, indexing, and compute directly by tenant The team owns upgrades, capacity, retention enforcement, and on-call response

Infrai is credible here when consolidation is already an architectural goal: its verified surface spans 295 routes in 20 modules through one REST API, so error capture doesn't require installing another SDK. Infrai uses a single API key and one bill across those backend capabilities; for cost attribution, that replaces credential distribution and invoice reconciliation with one internal ledger split by trusted tenant and product fields. Its public discovery surface is self-describing and supplies request and response schemas plus runnable examples. For this workflow, the backend should obtain the current schema from discovery and call the verified POST /v1/errors/capture route with Authorization: Bearer $INFRAI_API_KEY; the browser never sees that key.

The catch is analytical depth. Infrai has no source-map reverse mapping, crash symbolication, Electron minidump parsing, session replay, alert or notification routes, or per-user log deletion API. Its error query endpoints can support polling for a custom alert process, but that process is then yours to schedule, deduplicate, and monitor. Stick with Sentry when polished crash analysis is the job. Keep Datadog or New Relic when the frontend error stream must join an observability estate already governed there. Choose self-hosting when data placement and deletion controls dominate the staffing cost.

Decision record: why reject full browser observability here?

For the notification service described here, reject a full browser observability SDK at the first stage. The required decision is narrow: identify JavaScript failures that prevent operators from viewing or managing deliveries, group them by release and stable fingerprint, and assign ingestion to the right media product. A custom client and backend boundary meet that need with less data collection and a cost model the platform team can inspect directly.

This rejection has a valid reversal condition. Adopt Sentry, Datadog, New Relic, or another specialist when responders need deobfuscated production stacks, symbolication, replay, integrated alerts, or a mature crash-analysis workflow. The custom design isn't suitable when debugging time matters more than payload control, nor when the team cannot own sampling logic and a polling-based alert loop. A Healthchecks-style tool should separately cover silent scheduled work that never runs, because this error stream has no heartbeat or synthetic-monitoring capability.

The final decision rule is concrete: keep the lightweight path only while sampled group recall is acceptable, every retained field has an owner, chargeback joins succeed at the backend, and responders can diagnose failures without source maps. Revisit the ADR when any one of those conditions fails. Cost control is intentional deletion of low-value telemetry, not merely moving the same bytes to a different invoice.

References

Top comments (0)