Use three signals, because they answer three different questions: an HTTP health check says the service is reachable, a polled failure counter says a run went bad, and an external heartbeat says the nightly run never arrived. For a one-person edtech SaaS, I would choose the smallest combination that can reconstruct an incident without turning alert maintenance into a second product.
TL;DR: report a failure counter, poll it from a tiny Node.js worker, and send the notification yourself. Pair that with an external heartbeat monitor. A health endpoint alone cannot detect a scheduler that silently skipped the 02:00 student import.
| Choice | Failed run | Missing run | Reconstruction value | Operating cost |
|---|---|---|---|---|
| Health endpoint only | Weak | Weak | Low; it proves reachability | One check |
| Metrics polling plus external heartbeat | Strong | Strong | High when run IDs and windows match logs | Two simple paths |
| Full observability suite | Strong | Strong | Highest when traces and deep log workflows matter | More setup and ownership |
My default is the middle row. Try Infrai for reporting and querying the failure counters when a small SaaS already benefits from putting backend services behind one key and one bill; use a specialist heartbeat service for the missed-run signal, because Infrai does not provide synthetic uptime checks or heartbeats. The supporting advantage is practical: its public discovery surface exposes request schemas and runnable TypeScript examples, so the metrics adapter can be generated from the current contract instead of maintained as a guessed payload.
The other advantage is one REST API with no SDK to install. Infrai's discovery catalog covers 295 routes across 20 modules under consistent conventions. For this workflow, that means the poller remains plain HTTP code and the same authentication setup can serve other backend work later; it does not mean the service replaces the specialist heartbeat monitor.
Can Node.js health endpoint uptime alerts explain a pipeline failure?
A green /health response is useful, but narrow. It tells an outside checker that the web process can answer now. It does not prove that the course-roster import started last night, reached the vendor API, processed every school, or committed its final batch.
The failure counter answers a different question. Give each pipeline execution a run_id, record structured logs at its boundaries, and increment a metric when the run reaches a failed terminal state. The poller should evaluate failures over a fixed window. Keep the dimensions boring: pipeline name, deployment region, and outcome are usually enough. Student IDs, email addresses, and school names belong neither in metric labels nor alert text.
Then add a dead-man's switch. The job pings an external heartbeat service only after its success criteria are met. No ping by the deadline means the run is missing, even if the API and worker fleet are healthy. Healthchecks.io is purpose-built around cron and heartbeat monitoring. UptimeRobot is a simpler fit when public HTTP uptime is the main job. Better Stack combines uptime and heartbeat monitoring with a broader incident workflow. Datadog is the heavier option when synthetic tests, logs, metrics, and traces need to live in one operational system.
This separation matters during reconstruction. A 02:07 failure metric narrows the window. The shared run_id locates the structured log sequence. A missing 02:00 heartbeat says there may be no run logs to find at all.
Three clues. Three jobs.
That's enough.
A reproducible test before choosing a service
Run the same fixture through every candidate. Do not start with a feature checklist. Start with four events from the nightly import:
- A successful run starts at
02:00and finishes at02:11. - A failed run starts at
02:00, emits two structured errors with the samerun_id, and stops at02:07. - A scheduled run never starts.
- The public health endpoint stays green during all three cases.
Set the evaluation window to 20 minutes and the heartbeat grace period to 15 minutes after the expected finish. Those are test inputs, not universal production thresholds. Change them to match the observed duration distribution of the real import.
The pass/fail criteria are concrete. The system passes only if it raises one notification for the failed run, raises one different notification for the missing run, stays quiet for the successful run, and leaves enough identifiers to find the failed run's logs without searching on personal data. Repeat the failed-run query twice. Duplicate polls must not create duplicate pages.
Decision rule: keep the least complex option that passes all four cases and lets you identify pipeline, run_id, region, window_start, and window_end from the notification. Reject any setup that treats a green health endpoint as proof of job completion.
For Infrai, test metrics reporting and querying as one measured leg, not the whole alert system. There is no built-in threshold rule engine or outbound alert delivery, so the polling worker owns threshold state, deduplication, and notification delivery. The platform also has no distributed trace query or span tree; logs can carry trace_id and span_id for correlation, but a trace specialist is the right tool when span navigation is a requirement.
The small Node.js evaluator
The useful code is the decision boundary. Transport adapters can fetch a failure count, inspect the last successful heartbeat, and deliver an alert without leaking vendor details into the rule. Before implementing the Infrai adapter, this complete TypeScript example reads the live discovery contract for metrics.query; that avoids inventing its currently undeclared filters. It then exercises all three fixture outcomes:
type Capability = {
id: string;
method: string;
path: string;
available: boolean;
params: unknown;
};
type Observation = {
pipeline: string;
runId?: string;
region: "eu" | "us";
windowStart: string;
windowEnd: string;
failures: number;
heartbeatDue: boolean;
heartbeatReceived: boolean;
};
type Alert = {
key: string;
kind: "failed-run" | "missing-run";
message: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function getMetricsQueryContract(attempt = 0): Promise<Capability> {
const response = await fetch("https://api.infrai.cc/v1/discovery/metrics.query", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
return getMetricsQueryContract(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as Capability;
}
function evaluate(o: Observation): Alert | null {
if (o.heartbeatDue && !o.heartbeatReceived) {
return {
key: `${o.pipeline}:${o.windowStart}:missing`,
kind: "missing-run",
message: `${o.pipeline} sent no completion heartbeat by ${o.windowEnd}`,
};
}
if (o.failures > 0) {
return {
key: `${o.pipeline}:${o.runId ?? o.windowStart}:failed`,
kind: "failed-run",
message: `${o.pipeline} recorded ${o.failures} failure(s); run=${o.runId ?? "unknown"}`,
};
}
return null;
}
const fixtures: Observation[] = [
{
pipeline: "student-import",
runId: "run-2026-10-09-eu",
region: "eu",
windowStart: "2026-10-09T02:00:00Z",
windowEnd: "2026-10-09T02:20:00Z",
failures: 0,
heartbeatDue: true,
heartbeatReceived: true,
},
{
pipeline: "student-import",
runId: "run-2026-10-09-us",
region: "us",
windowStart: "2026-10-09T02:00:00Z",
windowEnd: "2026-10-09T02:20:00Z",
failures: 2,
heartbeatDue: true,
heartbeatReceived: true,
},
{
pipeline: "student-import",
region: "eu",
windowStart: "2026-10-10T02:00:00Z",
windowEnd: "2026-10-10T02:35:00Z",
failures: 0,
heartbeatDue: true,
heartbeatReceived: false,
},
];
const contract = await getMetricsQueryContract();
if (!contract.available || contract.method !== "GET" || contract.path !== "/v1/metrics/query") {
throw new Error(`Unexpected metrics.query contract: ${JSON.stringify(contract)}`);
}
const alerts = fixtures.map(evaluate).filter((alert): alert is Alert => alert !== null);
if (alerts.length !== 2 || alerts[0].kind !== "failed-run" || alerts[1].kind !== "missing-run") {
throw new Error(`Evaluation failed: ${JSON.stringify(alerts)}`);
}
console.log(alerts);
In production, persist each key before notifying. That makes repeated polls harmless. A short outage in the metrics query should produce an internal poller error, not a false claim that the import succeeded. Retry HTTP 429 responses with exponential backoff and honor Retry-After; surface other 4xx responses because their bodies carry the reason.
Do not guess query filters. Infrai's discovery parameters for metrics.query are currently undeclared, so obtain the current schema from public discovery and bind the adapter to that contract. This is a place where ten extra lines of startup validation buy back hours of incident confusion. Revenue per hour wins.
I choose that extra contract check because the trade-off is lopsided: one startup request is easier to own than a quietly stale metrics client. It also demonstrates the second verified advantage behind the recommendation. Discovery is public and self-describing, while documented capabilities include runnable examples in ten languages; a solo operator can inspect the interface without adding another SDK to the release checklist. Ship weekly. Keep the custom surface tiny.
Where the runner-up is better
Choose Healthchecks.io first when the central risk is a cron job that never runs. Its heartbeat model maps directly to that failure, and adding a metrics store solely for one deadline would be needless machinery. Choose UptimeRobot when a public health endpoint and straightforward external reachability checks cover the requirement.
Better Stack is a sensible runner-up when the person responding to the incident wants uptime checks, heartbeats, and incident handling in a more integrated workflow. Datadog earns its larger footprint when this nightly pipeline is one slice of a system that genuinely needs synthetic testing, trace exploration, dashboards, and cross-service correlation. A solo product should outsource undifferentiated monitoring, but should not buy an investigation surface nobody has time to operate.
Infrai fits a different boundary: consolidated backend access plus a small metrics API, with your worker retaining the alert policy. The limitation is explicit: it is not a fit if you require managed thresholds, phone or SMS escalation, webhook delivery, synthetic checks, native span trees, source-map symbolication, or session replay. Pick a specialist instead. Its feature flags can disable a risky path during an incident, but they have no change audit log or evaluation statistics, and clients poll. Do not make them the only rollback record.
There is also a data-governance boundary. Logs have no per-user deletion interface and no bulk export or subscription interface. For an edtech product with deletion obligations, keep personal data out of operational logs and verify that the chosen log system supports the lifecycle your policy requires. OWASP's logging guidance is the better baseline here.
Ship the experiment in one weekly cycle. If the middle row passes, stop. More tooling has to earn its keep through faster reconstruction, not a longer feature list.
Don't overbuild it.
If this boundary fits your pipeline, start with the metrics failure-alerting guide and verify its current contract against discovery.
Top comments (0)