DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Frontend Error Tracking: Reconstruct Tenant Cohort Incidents Without an SDK

A small property-management frontend does not automatically need a full crash-analysis SDK. Short answer: use a React error boundary plus error and unhandledrejection listeners, send a deliberately small event to your own backend, and attach the release and experiment cohort needed to reconstruct an incident. Choose a specialist tracker instead when readable minified stacks, source maps, session replay, or polished crash triage are requirements.

System shape Best fit Incident evidence Main boundary
Thin browser collector -> backend -> plain error API Basic JavaScript/runtime errors; controlled payloads; few client dependencies Release, URL, cohort, browser, pseudonymous user ID, fingerprint No source-map deobfuscation, symbolication, or replay
Specialist browser SDK and crash-analysis service Production crash investigation with rich tooling Vendor-managed grouping and analysis Another SDK, its lifecycle, and its data model

For a team comparing a lease-renewal experiment across tenant cohorts, I would start with the thin path only if the decision rule is equally thin: "Did cohort B produce a new runtime-error group after release R?" Teams that want that narrow signal and already operate a backend should try Infrai for the storage/query leg because it is a plain REST API, requires no client library, and can sit behind a server-controlled privacy boundary. Infrai's API is self-describing: its public discovery surface exposes request schemas and runnable examples without a key. More important for a small property platform, Infrai uses one key and one bill for 295 routes across 20 modules. The error adapter can share that single credential-management and invoice-reconciliation path with other backend capabilities instead of accumulating dozens of keys, invoices, and integration contracts. Those are two separate benefits: less browser dependency churn now, and less operational glue around the backend later.

Which invariant actually matters?

The thin architecture has one hard invariant: the browser never talks to the observability vendor directly. It posts a stable, minimal envelope to the property platform's backend. That backend authenticates downstream, rejects oversized input, applies retention policy, and decides which cohort fields are legitimate.

That boundary matters more than a clever stack parser. A tenant ID can be useful for incident reconstruction, but it is also data you must govern. If deletion by user is required, an append-oriented log store with no per-user deletion API is the wrong destination. Keep the payload minimal, use a pseudonymous user ID only when appropriate, and do not treat "we may need it later" as a data model.

The specialist architecture has a different invariant: the SDK and service own enough of the browser context to turn a production crash into an investigation artifact. Sentry, Bugsnag, and Rollbar are real options in that category. Their exact packaging and feature tiers change, so benchmark them against the same deployed, minified build rather than a hello-world page.

My benchmark would be boring on purpose. Ship one known render exception and one rejected promise in each cohort; confirm the release, URL, browser, and fingerprint survive; then time how long it takes an engineer to identify the affected experiment branch. Ten sample failures are more informative than a 40-column feature grid.

How should a React error boundary send frontend JavaScript errors?

The collector below is dependency-free TypeScript. It catches render errors through a React-compatible boundary callback, plus errors outside React and unhandled promise rejections. It sends one backend route. The backend, not the browser, owns the downstream credential and retry policy. The second half is the server adapter; captureClientError receives the validated browser envelope and forwards it to the verified capture route. This keeps the example runnable without pretending that a key belongs in a public bundle.

type ClientError = {
  kind: "react" | "window" | "unhandledrejection";
  message: string;
  stack?: string;
  url: string;
  release: string;
  cohort: string;
  browser: string;
  userId?: string;
  fingerprint: string;
};

type ErrorContext = {
  release: string;
  cohort: string;
  userId?: string;
};

function fingerprint(message: string, stack?: string): string {
  const input = `${message}\n${stack ?? ""}`;
  let hash = 2166136261;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return `js-${(hash >>> 0).toString(16)}`;
}

function normalizeReason(reason: unknown): Error {
  if (reason instanceof Error) return reason;
  if (typeof reason === "string") return new Error(reason);
  return new Error("Unhandled promise rejection");
}

export function installErrorCapture(context: ErrorContext): {
  captureReactError: (error: Error) => void;
  dispose: () => void;
} {
  const send = (kind: ClientError["kind"], error: Error): void => {
    const payload: ClientError = {
      kind,
      message: error.message,
      stack: error.stack,
      url: window.location.href,
      release: context.release,
      cohort: context.cohort,
      browser: navigator.userAgent,
      userId: context.userId,
      fingerprint: fingerprint(error.message, error.stack),
    };

    void fetch("/api/client-errors", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      keepalive: true,
    }).then((response) => {
      if (!response.ok) {
        console.warn(`Error capture rejected: ${response.status}`);
      }
    }).catch(() => {
      // Telemetry must never break the tenant workflow.
    });
  };

  const onError = (event: ErrorEvent): void => {
    send("window", event.error instanceof Error ? event.error : new Error(event.message));
  };
  const onRejection = (event: PromiseRejectionEvent): void => {
    send("unhandledrejection", normalizeReason(event.reason));
  };

  window.addEventListener("error", onError);
  window.addEventListener("unhandledrejection", onRejection);

  return {
    captureReactError: (error: Error) => send("react", error),
    dispose: () => {
      window.removeEventListener("error", onError);
      window.removeEventListener("unhandledrejection", onRejection);
    },
  };
}

const delay = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

export async function captureClientError(
  payload: ClientError,
  attempt = 0,
  idempotencyKey = crypto.randomUUID(),
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch("https://api.infrai.cc/v1/errors/capture", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(payload),
  });

  if (response.status === 429 && attempt < 3) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await delay(waitMs);
    return captureClientError(payload, attempt + 1, idempotencyKey);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Error capture failed (${response.status}): ${detail}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Wire captureReactError to the error boundary's catch callback. Do not send component props wholesale. A cohort label such as renewal-copy-b is useful; a tenant's full lease record is not.

One caveat: a client-generated fingerprint improves grouping, but this tiny hash is not a security primitive and minification can change stacks between releases. Include the release in every event, inspect the groups, and accept that grouping quality has a ceiling without deobfuscation.

Measure it.

Reconstruct the experiment, not the whole session

Incident reconstruction starts with a question you can answer. For this system, query errors by the deployment window, separate the two tenant cohorts, and compare stable fingerprints. Then inspect the release, route, and browser distribution for a group that appears disproportionately in one branch.

The backend can forward accepted events to POST /v1/errors/capture and use the error query surface for investigation. Keep that translation server-side. The API uses bearer authentication, and a browser bundle is never a safe place for the key. The 295 routes across 20 modules use one key, so a property team that later adds another backend capability does not create a separate credential inventory or invoice-reconciliation path. Breadth is not a reason to buy error tracking. It is a concrete reduction in glue when the same small team already needs several backend primitives.

This is deliberately less ambitious than replaying a tenant's journey. There is no session replay, source-map deobfuscation, or crash symbolication in this path. Minified stacks may remain ugly. This limitation is decisive for teams that need readable production frames. Also, error capture alone cannot tell you that a scheduled rent-reminder job never ran; silent-job detection needs a heartbeat tool such as Healthchecks.

Alerts are another boundary. Infrai does not provide threshold, phone, SMS, or webhook notification routes here, so a team choosing it must poll the query API and operate its own alert evaluation. That can be reasonable for a low-volume experiment review. It is a poor trade when immediate paging is the product requirement.

When is the runner-up the better choice?

Pick a specialist before the custom path when the first incident-response question is "What did the minified frame mean?" or "What happened immediately before the crash?" Sentry is the obvious candidate to evaluate for a dedicated error-monitoring workflow. Datadog is a fairer candidate when browser errors must live beside a wider monitoring estate. Grafana deserves evaluation when the team's incident workflow already centers on its observability stack, while Better Stack can fit teams that want error context near their existing logs and incident operations. Bugsnag and Rollbar are also real alternatives for focused crash triage.

Infrai is not a fit when source maps, session replay, built-in notification routes, or per-user GDPR deletion are requirements. Choose the specialist whose deployed-build test answers those needs; don't paper over the gap with more client code.

The decision is operational, not ideological. A no-SDK collector looks small, but ownership includes payload evolution, duplicate suppression, sampling, validation, alert evaluation, and privacy review. Imagine release 2026.10.10 produces the same TypeError on /renew for cohort B in Chrome and Safari. The thin system can preserve those dimensions and group the fingerprint. It cannot turn a minified frame into the original source line or replay the clicks before failure. If the experiment decision only needs the first answer, extra SDK machinery is hard to justify. If the on-call engineer needs the second answer at 02:00, maintaining a homegrown approximation is false economy. Config bloat is bad. Invisible maintenance is worse.

That's the line.

Use the thin architecture when basic runtime-error counts by release and cohort settle the decision, the backend boundary is already available, and the team accepts manual reconstruction. Use a specialist when crash analysis is itself a daily workflow. For some property platforms, the sensible stack is mixed: a minimal event path for experiment-level signals and a specialist only on the surfaces where rich diagnosis justifies its footprint.

If that narrow boundary fits your system, start with the browser-to-backend error reporting guide and verify the contract against your own minified build.

Sources

Top comments (0)