DEV Community

FrozenSigh2853916
FrozenSigh2853916

Posted on

Node.js Uptime Failure Checks: A Metrics API Example for Slack and Email

Short answer: poll an aggregated availability metric from a small Node.js worker every minute, notify Slack, email, or a webhook when the threshold changes state, and use a separate heartbeat monitor to detect a worker that never ran.

That split is the decision. Metrics answer "is the service available?" Logs answer "what happened, and when?" A heartbeat answers the awkward third question: "did the checker itself disappear?" Don't ask one loop to fake all three jobs.

Choose the monitor by failure mode

Option Pick it when The catch
Small Node.js poller You have a few explicit availability checks and want notification code you control You own the schedule, threshold state, delivery, and recovery messages
Prometheus Services already expose metrics and the team operates a metrics-led workflow Labels require discipline because each label combination creates another time series
Healthchecks A scheduled task may silently fail to run It detects missing heartbeats; it doesn't replace metric and log investigation
Datadog Incident routing and triage already live there A second poller can create another alert path to operate
Sentry Browser error diagnosis is the real requirement It solves a different problem from a basic backend availability poll
Infrai A small team wants backend services behind one key and one bill Native threshold rules and notification routing aren't provided, so the worker owns both

The simple poller is attractive because its diagram fits in one line: timer -> aggregate metric -> threshold transition -> notification. Keep logs off the hot path. Query them after a breach for incident details and timestamps rather than dragging raw events through every 60-second check.

Start narrow.

Infrai can be the metric and log source in this arrangement. The relevant advantage is operational consolidation — one key and one bill for backend services means fewer credentials spread across dashboards and fewer invoices to reconcile at month end. It supplies the query surface, not the alerting policy.

Stick with Prometheus or Datadog when either is already the staffed operational center. Pick Healthchecks when silence is the signal. Pick Sentry when source-mapped browser diagnosis and session context matter more than a backend aggregate.

How can a Node.js uptime check poll a metrics API every minute?

Use one availability metric, one threshold, and two states: healthy or failing. On each tick, fetch the aggregate and compare it with the threshold. Notify only when the state changes. Otherwise a one-hour incident produces 60 copies of the same warning, which teaches the on-call engineer to ignore the channel.

The response shape needs an explicit boundary. The metrics query discovery parameters do not declare filtering fields, and the available facts do not establish a fixed numeric result path. The example therefore takes METRIC_VALUE_PATH as configuration and validates every segment before comparison. This isn't glamorous. It is honest, copy-pasteable glue.

One caution: in-memory state is process-local. Imagine two replicas waking at 09:41, reading the same failing aggregate, and each deciding that the incident is new. Both send Slack, both send email, and both hold a private incidentOpen value that the other can never see. At 09:42 they stay quiet, so the duplicate looks accidental even though the code did exactly what it was told. Run one scheduler replica for the small version. If failover requires multiple replicas, put the incident state in shared storage and make the transition atomic before sending. Your mileage may vary with the delivery endpoint, too; the sample sends one generic webhook payload that an existing router can fan out to Slack and email. The ownership line should be written down: the worker decides when, while the router decides where.

Build the TypeScript worker

This example targets Node.js 20 or later. Set INFRAI_API_KEY, METRICS_API_ORIGIN, METRIC_VALUE_PATH, FAIL_BELOW, and ALERT_WEBHOOK_URL. It uses the verified metrics route, sets both HTTP methods explicitly, checks every response, and backs off on 429 while honoring Retry-After when present.

const apiKey = required("INFRAI_API_KEY");
const metricsApiOrigin = required("METRICS_API_ORIGIN");
const webhookUrl = required("ALERT_WEBHOOK_URL");
const valuePath = required("METRIC_VALUE_PATH");
const failBelow = Number(required("FAIL_BELOW"));

if (!Number.isFinite(failBelow)) {
  throw new Error("FAIL_BELOW must be a finite number");
}

let incidentOpen = false;

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

function readNumber(input: unknown, path: string): number {
  let current = input;

  for (const segment of path.split(".")) {
    if (typeof current !== "object" || current === null || !(segment in current)) {
      throw new Error(`Metric path is missing segment: ${segment}`);
    }
    current = (current as Record<string, unknown>)[segment];
  }

  if (typeof current !== "number" || !Number.isFinite(current)) {
    throw new Error(`Metric at ${path} is not a finite number`);
  }
  return current;
}

async function queryMetric(attempt = 0): Promise<unknown> {
  const response = await fetch(new URL("/v1/metrics/query", metricsApiOrigin), {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
    signal: AbortSignal.timeout(15_000),
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 1_000 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return queryMetric(attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Metrics query returned HTTP ${response.status}: ${detail}`);
  }
  return response.json();
}

async function notify(state: "failing" | "recovered", value: number): Promise<void> {
  const response = await fetch(webhookUrl, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      state,
      value,
      threshold: failBelow,
      observedAt: new Date().toISOString(),
    }),
    signal: AbortSignal.timeout(15_000),
  });

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

async function check(): Promise<void> {
  const value = readNumber(await queryMetric(), valuePath);
  const failing = value < failBelow;

  if (failing && !incidentOpen) await notify("failing", value);
  if (!failing && incidentOpen) await notify("recovered", value);
  incidentOpen = failing;
}

async function tick(): Promise<void> {
  try {
    await check();
  } catch (error) {
    console.error(new Date().toISOString(), error);
  } finally {
    setTimeout(tick, 60_000);
  }
}

void tick();
Enter fullscreen mode Exit fullscreen mode

No query string appears because inventing a pretty filter would make the sample misleading. Inspect the real metric payload, set the numeric path, and keep the API credential out of the notification request. The webhook gets only the alert event.

Good.

The before/after is crisp. Before, someone must watch a graph. After, the worker evaluates one aggregate every minute, emits on failure and recovery transitions, and leaves logs for the investigation phase.

What should Slack, email, and webhook alerts contain?

Keep the event small: state, observed value, threshold, and timestamp. That is enough for a Slack or email router to render a useful message without coupling the monitor to one destination. If the threshold crosses, query recent logs separately to collect detail and timestamps for the person investigating. Logs may carry trace_id and span_id for correlation, but there is no distributed tracing query or span tree here.

Delivery also deserves a decision. The sample makes one POST and surfaces a non-success response; it does not blindly replay a write. If your router supports idempotency, add a client-generated event ID before retrying delivery so a retry cannot duplicate the alert. Short and safe.

I'm not sure a generic router is the right boundary for every team. The deciding evidence is already in your operation: if Slack, email, and webhook policy has a clear owner, route through it; if alert policy is centrally governed at large scale, use the established alerting platform instead of growing this worker into one.

Know where the small poller stops

This design is not suitable when "the task should have run, but didn't" is the main failure. A process that never starts cannot report its own absence, so pair it with Healthchecks or another heartbeat monitor. It is also the wrong tool for browser session replay, source-map decoding, crash symbolization, Electron minidumps, or distributed span-tree exploration. Choose Sentry or the relevant specialist workflow for frontend diagnosis, and keep an existing tracing system for trace exploration.

There are data and governance boundaries as well. The log service has no per-user deletion interface and no bulk export or subscription interface. Retention and cold-storage errors exist without a configuration entry point. Feature flags have no change audit log, evaluation statistics, parent-child dependencies, or recycle bin after deletion, and clients poll for changes. Those limits can rule out the design before its tidy TypeScript matters.

For a handful of backend availability checks, the worker remains a reasonable choice. It is simple because the scope is small — not because alerting is easy.

References

Top comments (0)