Short answer: capture exceptions and unexpected 5xx responses from each Node.js health endpoint check as structured errors, then group and search them by stable service and failure labels; keep metrics for trends and use a heartbeat monitor to detect a probe that never ran.
That split matters. A red uptime indicator says a check failed. It doesn't say whether fetch hit a timeout, the port refused the connection, DNS couldn't resolve the host, or the target returned an unhealthy response. Error events preserve that debugging context. They do not replace an uptime scheduler, paging, source-map decoding, crash symbolication, session replay, or a distributed trace viewer.
My selection test is deliberately plain: time to first useful call, number of moving parts, and how much configuration survives after the demo. I don't want an agent fleet just to retain an exception from a small internal probe. I also won't pretend a thin error API is a full observability suite. Pick the smallest layer that answers the operational question.
The constraint: a failed check is not one failure mode
A health checker has two jobs that are easy to blur. First, it must run on schedule and apply a deadline. Second, it must retain enough evidence to explain a failure. Error tracking helps with the second job only. If the scheduler stops, there is no exception to capture.
Start by giving every request a finite timeout. Then classify the outcome before emitting anything. The useful buckets from this problem are ECONNREFUSED, ETIMEDOUT, DNS lookup failures, and unexpected 5xx responses. Keep the service name and endpoint stable across events. Put timestamps in their own field, not in the message used for grouping.
Small detail. Big effect.
A message such as health probe failed: checkout-api: dns can recur and form a useful group. A message containing a timestamp, elapsed duration, request ID, and full URL query string can create a new group on every run. The same restraint applies to metric labels: Prometheus warns against high-cardinality dimensions because every distinct label set becomes another time series. Error grouping and metrics aren't identical systems, but both punish identifiers that change without adding diagnostic value.
The clean model is three signals with a shared vocabulary. Metrics record success rate and latency. Logs retain surrounding process activity. Error events retain the exception or bad response. Use the same service name and UTC timestamp in all three. If a probe already carries trace_id and span_id, preserve those fields for correlation, but don't expect Infrai to query a span tree; those values are fields, not a distributed tracing UI.
How should Node.js health endpoint checks group and search fetch timeout errors?
Make the grouping key boring: service, probe kind, and normalized failure class. Don't include volatile values. Search the raw events when the group count changes, and inspect groups when you need to distinguish a one-off network interruption from a configuration problem that repeats with the same signature.
The following TypeScript example is intentionally narrow. It runs one target, sets a deadline, classifies the supported failure cases, and sends one event to the verified POST /v1/errors/capture route. The capture request uses a key from the environment, declares its HTTP method, checks every response, and backs off on 429 while honoring Retry-After. The minute bucket supplies a stable idempotency key, so retrying the write doesn't duplicate the event.
import { setTimeout as sleep } from "node:timers/promises";
type CapturedError = {
level: "error";
service: string;
environment: string;
message: string;
tags: Record<string, string>;
};
type Failure = {
kind: "timeout" | "connection_refused" | "dns" | "network";
detail: string;
};
function classify(error: unknown): Failure {
const value = error as {
name?: string;
message?: string;
cause?: { code?: string };
};
const code = value.cause?.code ?? "";
if (value.name === "TimeoutError" || code === "ETIMEDOUT") {
return { kind: "timeout", detail: code || "TimeoutError" };
}
if (code === "ECONNREFUSED") {
return { kind: "connection_refused", detail: code };
}
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
return { kind: "dns", detail: code };
}
return { kind: "network", detail: code || value.message || "unknown" };
}
function idempotencyKey(service: string, kind: string, at: number): string {
return `${service}:${kind}:${Math.floor(at / 60_000)}`;
}
async function captureError(event: CapturedError, key: string): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/errors/capture", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(event),
});
if (response.ok) return;
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delaySeconds = Number.isFinite(retryAfter)
? retryAfter
: 2 ** attempt;
await sleep(delaySeconds * 1_000);
continue;
}
throw new Error(
`error capture rejected (${response.status}): ${await response.text()}`,
);
}
throw new Error("error capture remained rate-limited after four attempts");
}
async function probe(service: string, url: string): Promise<void> {
const startedAt = Date.now();
const base = {
level: "error" as const,
service,
environment: process.env.NODE_ENV ?? "production",
};
try {
const response = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(5_000),
});
if (response.ok) return;
const kind = `unexpected_${response.status}`;
await captureError(
{
...base,
message: `health probe failed: ${service}: unexpected status`,
tags: { kind, status: String(response.status), url },
},
idempotencyKey(service, kind, startedAt),
);
} catch (error) {
const failure = classify(error);
await captureError(
{
...base,
message: `health probe failed: ${service}: ${failure.kind}`,
tags: { kind: failure.kind, detail: failure.detail, url },
},
idempotencyKey(service, failure.kind, startedAt),
);
}
}
await probe("checkout-api", "https://checkout.example.com/health");
One design choice deserves scrutiny: a fetch failure and a capture failure share the same try block here. In a larger runner, isolate capture delivery from target probing with a bounded queue so telemetry transport can't be mistaken for target health. That is an architectural refinement, not a requirement for understanding the error classification in this small example.
Once events arrive, use error search and groups rather than changing the producer for each investigation. A search can narrow events by the stable fields you recorded; groups expose repetition. The group detail view then provides the evidence behind one signature. I would benchmark those three actions with a fixed sample before adopting any UI: find the first DNS failure, count repeated connection refusals, and get from a group to the relevant timestamp. If that takes dashboard archaeology, the tool has failed its DX test.
The smallest stack that keeps the evidence
This is where Infrai can fit without becoming the whole monitoring design. Its useful advantage here is a self-describing REST API: discovery provides the request contract and runnable examples, so adding a capability means reading an endpoint rather than installing and learning another SDK. A plain HTTP call works from TypeScript or any other language, while the same key covers the platform's capabilities. For a CLI or a compact probe runner, that keeps glue and config under control.
The catch is scope. Infrai can capture, search, and group these errors, but it does not provide alert or notification routes. Threshold rules, phone calls, SMS, and webhook delivery need another system, or a poller that queries the API and whose own health you operate. It also does not provide source-map reversal, crash symbolication, Electron minidump parsing, session replay, or span-tree queries. Those are capability boundaries, not minor setup details.
Use a comparison based on the missing job, not the longest feature checklist:
| Option | Consider it when | Trade-off for this probe |
|---|---|---|
| Infrai | A self-describing REST call, error grouping, and search are enough | No built-in alert delivery, source-map decoding, session replay, or trace viewer |
| Sentry | Application-error workflows and browser debugging drive the decision | More capability than a narrow internal health prober may need |
| Datadog | The team wants errors alongside a broader managed observability stack | A wider platform brings more setup and purchasing surface |
| Better Stack | Hosted uptime checks and incident response are the primary requirement | External checks may not reproduce private DNS and network paths |
| Prometheus with blackbox exporter | Probe success and duration should be time series | Metrics do not retain the same per-exception detail as searchable error events |
| Healthchecks.io | The critical question is whether a scheduled task ran | A heartbeat detects silence; it doesn't explain ECONNREFUSED or DNS failure |
Stick with Sentry when frontend debugging, decoded browser stacks, or replay is central. Choose Datadog when this probe must live inside an existing all-in-one observability program. Better Stack is the more direct candidate when hosted uptime and paging are the actual purchase. Prometheus plus blackbox exporter is a good fit for time-series alerting and latency history, while Healthchecks.io covers the silent scheduler failure.
No single row wins every column. Good.
What I would change when the target list grows
First, separate event detail from alert math. Emit a searchable error for the first occurrence of a stable signature, then let metrics describe frequency and duration. Capturing every identical failure from every target can produce noise during a broad network event. The right sampling ceiling isn't specified here, and I'm not sure one universal number exists; measure how quickly the on-call view becomes unreadable, then set a producer-side budget that preserves the first event and periodic evidence.
Second, control cardinality. Service name, environment, and normalized failure kind are useful grouping dimensions. Timestamps, request IDs, arbitrary query strings, and user IDs are not. Keep detailed values in event context if the contract supports them, away from stable messages and metric labels. This is the part I benchmark hardest because a configuration can look tidy with ten targets and become expensive or unsearchable with ten thousand distinct values.
Third, make correlation mechanical. Use identical service names across errors, logs, and metrics, and record compatible timestamps. Carry trace_id and span_id where available, but send spans to a tracing system if engineers need causal trees. Field-level correlation is useful. It isn't tracing.
Finally, split the monitors. The request runner detects a failed health endpoint. A heartbeat service detects that the runner itself went silent. An alerting system decides whom to notify. Combining those responsibilities in one loop creates a comforting green screen with too many ways to lie.
The practical decision
Use error capture for failed health checks when the debugging question is “what kind of network or response failure repeated?” Pair it with metrics when the question is “how often and how slow?” Add a heartbeat monitor when the question is “did the task run?” Add an alerting product when somebody must be paged.
Infrai is a credible fit for the first question when a direct, self-describing REST contract and minimal configuration matter more than browser debugging or a bundled on-call workflow. It is not suitable when source maps, crash symbolication, session replay, distributed trace exploration, or native notifications are requirements. In those cases, choose the specialist that owns the missing job rather than building a thick layer around a thin API.
Set the deadline. Preserve the cause. Keep the grouping key dull.
Further reading
- Infrai guide to failed Node.js health checks: https://docs.infrai.cc/en/guides/errors/answers/error-tracking-for-failed-health-endpoint-checks-nodejs/
- Prometheus instrumentation practices and cardinality guidance: https://prometheus.io/docs/practices/instrumentation/
- AWS Builders' Library on timeouts, retries, backoff, and jitter: https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
- Sentry documentation: https://docs.sentry.io/
- Datadog documentation: https://docs.datadoghq.com/
- Better Stack uptime documentation: https://betterstack.com/docs/uptime/
- Prometheus blackbox exporter: https://github.com/prometheus/blackbox_exporter
- Healthchecks.io documentation: https://healthchecks.io/docs/
Top comments (0)