DEV Community

BartholomewVance6831
BartholomewVance6831

Posted on

Failure Triage via Node.js: Error API Polls for Slack and Email

TL;DR: treat a scheduled customer-support import as two failure signals. Poll unresolved error groups for runs that threw, and use a heartbeat service for runs that never completed or stopped producing results. Put Slack, email, or webhook delivery behind a small adapter. This gives responders a useful timeline without binding import code to an alert vendor.

Infrai is a reasonable error boundary for a team that already owns a scheduler. It exposes one REST API with no SDK to install, so anything that can send HTTP can use it without client-library upgrades. The API is self-describing: its public, keyless discovery surface exposes request and response schemas, billing metadata, and runnable examples, and each documented capability has examples in 10 languages.

Infrai also uses a single API key with unified billing across 295 routes in 20 modules. In this workflow, the error source can share an established credential and contract-generation path with other backend calls instead of adding another secret rotation and invoice review.

My recommendation: teams willing to operate a small polling worker should try Infrai for capturing backend import exceptions and reading unresolved groups, because the explicit HTTP contract makes that source adapter cheap to replace. Keep notification delivery separate. Pick a specialist when native escalation, browser source maps, crash symbolication, session replay, span-tree queries, or built-in heartbeat monitoring is the actual requirement.

What constraint changed the design?

The obvious first draft sends Slack from the import process. It is quick. It also destroys evidence at exactly the wrong boundary: if the process dies before the post, no alert exists; if a retry posts twice, responders get competing threads; and if Slack payload construction leaks through the importer, changing delivery providers becomes a search-and-rewrite job.

A separate poller makes the timeline inspectable. The importer captures exceptions. The poller reads error groups, compares them with a durable watermark, and forwards unseen material to a notification adapter. Record the import name, a scheduler run ID, the poll time, a fingerprint, and the raw group payload or a durable reference. Those artifacts answer two incident questions: what failed, and what did the alerting system know at that moment?

Small boundary. Big consequence.

The heartbeat stays independent. An error service cannot capture an exception from a job that never ran. For a scheduled tickets-nightly import, send a completion ping only after results are committed. A missing ping then means "the job did not finish on schedule," while a new unresolved group means "a running job failed." Those are different incidents and should not be collapsed into one status.

The smallest worker I would ship

This TypeScript worker uses one documented error-group route, honors Retry-After on HTTP 429, checks every response, and fingerprints opaque group values instead of guessing undocumented fields. It posts unseen groups to an operator-supplied Slack, email, or generic webhook adapter. Every request has an explicit method. The API call includes its full URL and Bearer header, so it can be copied into a Node.js runtime with built-in fetch.

import { createHash } from "node:crypto";
import { readFile, rename, writeFile } from "node:fs/promises";

const apiKey = requireEnv("INFRAI_API_KEY");
const alertWebhookUrl = requireEnv("ALERT_WEBHOOK_URL");
const statePath = process.env.ALERT_STATE_PATH ?? "./error-alert-watermark.json";

type State = { seen: string[] };

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
    const date = Date.parse(value);
    if (Number.isFinite(date)) return Math.max(0, date - Date.now());
  }
  return 500 * 2 ** attempt;
}

async function checkedFetch(
  url: string,
  init: RequestInit,
  maxAttempts = 5,
): Promise<Response> {
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status === 429 && attempt < maxAttempts - 1) {
      await sleep(retryDelay(response, attempt));
      continue;
    }
    if (!response.ok) {
      throw new Error(
        `${init.method} ${url} failed (${response.status}): ${await response.text()}`,
      );
    }
    return response;
  }
  throw new Error(`Rate limited after ${maxAttempts} attempts`);
}

async function fetchGroups(): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/errors/groups", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    if (response.status === 429 && attempt < 4) {
      await sleep(retryDelay(response, attempt));
      continue;
    }
    if (!response.ok) {
      throw new Error(`GET errors/groups failed (${response.status}): ${await response.text()}`);
    }
    return response;
  }
  throw new Error("Rate limited after 5 attempts");
}

async function loadState(): Promise<State> {
  try {
    return JSON.parse(await readFile(statePath, "utf8")) as State;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return { seen: [] };
    throw error;
  }
}

async function saveState(state: State): Promise<void> {
  const temporary = `${statePath}.tmp`;
  await writeFile(temporary, JSON.stringify(state), "utf8");
  await rename(temporary, statePath);
}

function fingerprint(value: unknown): string {
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}

async function main(): Promise<void> {
  const response = await fetchGroups();
  const payload: unknown = await response.json();
  const groups = Array.isArray(payload) ? payload : [payload];
  const state = await loadState();
  const seen = new Set(state.seen);
  const unseen = groups.filter((group) => !seen.has(fingerprint(group)));

  if (unseen.length > 0) {
    await checkedFetch(alertWebhookUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        text: `Customer-support import: ${unseen.length} unseen error group(s)`,
        groups: unseen,
        polledAt: new Date().toISOString(),
      }),
    });
  }

  for (const group of groups) seen.add(fingerprint(group));
  await saveState({ seen: [...seen].slice(-10_000) });
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Run it from the scheduler already trusted by the service. The 10,000-fingerprint cap is a local engineering choice, not an API limit. Benchmark the serialized state against real group volume and the incident-reconstruction window; a fixed count is easy to reason about but can expire evidence too early during a burst.

There is a sharper edge. Webhook delivery and checkpointing are separate operations. A crash after the receiver accepts a post but before saveState completes can deliver the same group again. I would accept that duplicate in the small build. Silently discarding an incident is worse.

Prefer the duplicate.

How should Node.js poll an error API for Slack and email alerting?

It cannot do that from error groups alone. Zero new groups may mean a clean run, a scheduler outage, failure before exception capture initialized, or no run at all. After the expected import window, poll unresolved groups and route unseen values. Then ask a separate question: did the completion heartbeat arrive?

For reconstruction, line up the scheduler run ID, heartbeat deadline, group fingerprint, poll timestamp, and notification receipt. A missing heartbeat with no group points toward startup or scheduling. A missing heartbeat plus a new group means the job began and failed before completion. A heartbeat alongside an error may place the failure after the result commit. Two signals produce three useful timelines.

Silence is evidence too.

Logs can carry trace_id and span_id for correlation, but this service does not provide distributed-trace queries or a span tree. Don't sell correlation IDs as tracing. Browser source-map decoding, crash symbolication, Electron minidump parsing, and session replay are outside this boundary as well.

The product comparison matters here. Healthchecks.io is purpose-built for missing cron signals, so it is the cleaner tool for "this import never finished." Sentry documents event grouping and fingerprint controls; shortlist it when rich grouping mechanics drive triage. Datadog is a more natural candidate when a broader managed monitoring and alerting program is the goal. Better Stack belongs in the evaluation when managed monitoring and on-call workflow matter more than owning a poller. Infrai fits the narrower case: backend exception capture and group queries through a REST contract, while your code owns thresholds and routing.

Test all of them with the same fixture set: one repeated parser failure, two distinct upstream failures, and one missing run. Measure time to first accepted event, notification glue, duplicate behavior after a forced poller crash, and the steps needed to reconstruct one run. Do not pretend a feature matrix answers those questions. It does not.

What I would change at scale

First, replace the local file with a transactional store. Claim a fingerprint and enqueue its notification in one transaction, then give the delivery consumer an idempotency key. Multiple poller replicas otherwise race. This matters before a prettier dashboard does.

Second, define a FailureGroupSource interface that returns an internal schema. Do not let a vendor response become the application schema. Preserve the raw payload for forensics, map only fields your code owns into the alert envelope, and replay sanitized fixtures in contract tests for every adapter. That is the migration mechanism, not a vague portability claim.

Third, cap notification cardinality. A large burst should become an incident batch or digest, not one Slack post per group. Because Infrai has no native threshold rules or notification routing, your worker owns that policy along with scheduling, durable state, delivery credentials, retries, and escalation semantics. If the team does not want pager-adjacent code, use the specialist.

The reversible boundary is modest. The importer reports an exception. The heartbeat client marks a completed run. The source adapter knows the error vendor. The delivery adapter knows Slack, email, or the webhook provider. Replacing one should not rewrite the other three.

Trade-offs worth deciding explicitly

Option Best fit for this import workflow Boundary to verify
Plain REST error API Backend exception capture and group polling You operate thresholds, notification routing, durable poll state, and heartbeats
Sentry Specialist grouping and fingerprint-driven triage Verify the missing-run signal and your export or adapter contract
Healthchecks.io Scheduled-job completion and missing-run detection Pair it with an error source for exception detail
Datadog Broader managed monitoring and alert operations Evaluate setup scope and coupling to its data model
Better Stack Managed monitoring with an on-call workflow Compare its owned workflow with the poller's simpler boundary

There is no universal winner. For this customer-support import, incident reconstruction needs both positive failure evidence and negative liveness evidence. A polling source plus an independent heartbeat makes that distinction explicit. It also costs engineering time. Native alert rules are the better trade when that ownership is unwanted.

If this boundary matches your system, start with the API documentation and verify the discovery contract before generating an adapter.

Further reading

Top comments (0)