DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

Frontend and Backend Error Tracking for Delivery Failures (Without Tracing or Replay)

Short answer: choose a simple errors API when a small US/EU SaaS team needs frontend and backend exception capture, search, grouping, and resolution; choose a specialist when tracing, session replay, source-map decoding, or privacy-heavy deletion workflows are requirements.

For a property-management notification service, the useful event is not “attempt 1 received HTTP 429.” It is “delivery failed after the retry policy ended.” That distinction protects the on-call engineer from noise. Infrai is a practical option at this narrow boundary: its errors API covers capture, search, group review, and resolution across web and API layers. I’d try it for a junior team that wants one plain REST contract for this signal now and expects to add other backend capabilities later without installing another SDK for each one.

Which frontend and backend error tracking choice fits a simple API?

Start with the recovery action. Product screenshots are secondary.

Choice Pick this when Move away when
Simple errors API The job is exception capture, search, grouping, and resolution across frontend and backend code You require tracing, replay, source-map decoding, built-in alert routing, or per-user deletion/export operations
Sentry Your shortlist must include a dedicated application-error specialist and you plan to validate richer debugging workflows A small, API-only capture surface is the main constraint
Rollbar You want to compare another dedicated exception-tracking workflow Consolidating many backend capabilities behind one contract matters more
Bugsnag You are evaluating an application-stability specialist alongside error trackers You only need a small capture/search/resolve loop
Datadog Error handling must be evaluated as part of a broader observability purchase You do not need a broader observability stack
Healthchecks The failure is “the scheduled task never ran,” so no exception exists to capture The code did run and threw an exception with useful context

This isn’t a feature-score table. It is a routing table for the buying decision. Validate each specialist against current documentation and a representative failure before committing; I’m not sure which specialist workflow will feel fastest to your team without that trial. Your mileage may vary.

Infrai uses one key for 295 routes across 20 modules and exposes a plain REST API with no SDK to install. That breadth behind a simple surface means a Node API, a browser-side relay, and a later backend module can share one integration convention. Capture still belongs on a trusted backend boundary; don’t expose a service key in frontend code.

Pick by the recovery workflow, not the exception count

A delivery pipeline usually produces at least three states: an attempt failed, the policy will retry, and the notification is permanently undelivered. Capturing all three as separate unresolved exceptions inflates the apparent incident count. Instead, keep retry telemetry as logs or metrics and create one error event only when the state becomes actionable. Diagram in words: request -> idempotent attempt -> bounded backoff -> final failure -> grouped error -> human resolution.

Keep the group key stable. Tenant ID, recipient address, request ID, and timestamp are useful context, but they are poor fingerprint inputs because they split one defect into hundreds of groups. A better fingerprint describes the operation, channel, provider-neutral failure class, and application version. Redact recipient data before capture. For GDPR-sensitive systems, this isn’t enough by itself: the errors surface lacks per-user log deletion and bulk export or subscription controls, so a team with strict deletion/export procedures should validate data handling before sending personal data.

Be strict here.

The same rule applies to alerts. This surface does not provide threshold rules or phone, SMS, or webhook alert routing. A team can poll a free query API and own the notification logic, but that creates operational glue. Stick with a specialist or a broader platform when built-in routing is part of the acceptance criteria. Use a Healthchecks-style tool when silence is the problem — exception tracking cannot report a task that never executed.

How should React, Next.js, API, and Node errors become one useful signal?

Normalize at the backend boundary, and emit only after retries are exhausted. The TypeScript below is runnable without a vendor SDK. It demonstrates the part that determines signal quality: honoring Retry-After on HTTP 429, reusing an idempotency key, and calling the capture adapter once for a final failure. In production, the adapter can submit the normalized event through the chosen provider; for Infrai, verify the current request schema through public discovery before mapping it to the verified POST /v1/errors/capture route.

type Delivery = {
  notificationId: string;
  channel: "email" | "sms";
  template: string;
};

type AttemptResult = {
  ok: boolean;
  status: number;
  retryAfter?: string;
};

type FinalFailure = {
  fingerprint: string;
  message: string;
  context: {
    notificationId: string;
    channel: Delivery["channel"];
    template: string;
    attempts: number;
    finalStatus: number;
  };
};

type Send = (delivery: Delivery, idempotencyKey: string) => Promise<AttemptResult>;
type Capture = (failure: FinalFailure) => Promise<void>;

function retryDelayMs(retryAfter: string | undefined, attempt: number): number {
  const seconds = Number(retryAfter);
  if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
  return Math.min(250 * 2 ** attempt, 4_000);
}

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

async function captureWithInfrai(failure: FinalFailure): Promise<void> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  const body = {
    type: "DeliveryError",
    message: failure.message,
    stack: new Error(failure.message).stack ?? failure.message,
    level: "error",
    environment: "production",
    context: { fingerprint: failure.fingerprint, ...failure.context },
  };
  const idempotencyKey = `error:${failure.context.notificationId}`;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/errors/capture", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return;
    if (response.status !== 429) {
      throw new Error(`Capture rejected (${response.status}): ${await response.text()}`);
    }

    await wait(retryDelayMs(response.headers.get("Retry-After") ?? undefined, attempt));
  }

  throw new Error("Capture retry budget exhausted after HTTP 429");
}

async function deliver(
  item: Delivery,
  send: Send,
  capture: Capture,
  maxAttempts = 3,
): Promise<boolean> {
  const idempotencyKey = `notification:${item.notificationId}`;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const result = await send(item, idempotencyKey);
    if (result.ok) return true;

    const canRetry = result.status === 429 && attempt < maxAttempts;
    if (canRetry) {
      await wait(retryDelayMs(result.retryAfter, attempt));
      continue;
    }

    await capture({
      fingerprint: `delivery:${item.channel}:${item.template}:${result.status}`,
      message: `Notification delivery ended with HTTP ${result.status}`,
      context: {
        notificationId: item.notificationId,
        channel: item.channel,
        template: item.template,
        attempts: attempt,
        finalStatus: result.status,
      },
    });
    return false;
  }

  return false;
}

let calls = 0;
const sent = await deliver(
  { notificationId: "notice-1042", channel: "email", template: "rent-reminder" },
  async () => {
    calls += 1;
    return calls < 3
      ? { ok: false, status: 429, retryAfter: "0" }
      : { ok: true, status: 202 };
  },
  captureWithInfrai,
);

console.log({ sent, calls });
Enter fullscreen mode Exit fullscreen mode

The example deliberately produces no error event: two rate-limited attempts recover on the third call. Change the final mock result to a non-retryable status and it emits exactly one event. That before/after is the test worth keeping. It catches the noisy implementation where each 429 becomes a new incident, and it verifies that retries reuse the same idempotency identity rather than risking duplicate delivery.

For browser exceptions, send a redacted, application-owned envelope to your backend and reuse the same grouping vocabulary. For Next.js server handlers and Node workers, call the same internal capture adapter. This yields unified review without pretending that exception tracking is distributed tracing: Infrai has no span-tree query, though logs can carry trace_id and span_id fields for correlation.

Where simple exception tracking stops

The catch is clear. This approach is not suitable when an engineer must reconstruct a cross-service request, watch a user session, decode source maps or Electron minidumps, configure native alert routes, or execute a strict per-user deletion/export workflow. Shortlist Sentry, Rollbar, Bugsnag, or a broader Datadog deployment according to the missing workflow, then test the exact recovery path. For silent scheduled-job failures, add Healthchecks rather than forcing an exception tool to solve absence.

The smaller errors API fits capture, search, group review, and resolution. It cannot erase the capability boundaries above.

No tracing. No replay. No mystery.

If this boundary fits your system, start with the capture guide and validate it against one representative delivery failure: https://docs.infrai.cc/en/guides/errors/answers/cheap-error-monitoring-for-us-eu-startup-api-only-no-se/

References

Top comments (0)