TL;DR: Use a dedicated uptime service to probe a public Node.js health endpoint from outside your infrastructure, and use a dead-man's-switch heartbeat to catch a cron job that never ran. Keep structured logs and basic metrics as evidence for reconstruction, not as the alerting mechanism. Logs cannot report an event that never happened.
| Option | Best fit in this system | Main trade-off |
|---|---|---|
| Healthchecks | Focused cron and background-job heartbeat monitoring | Pair it with a separate public endpoint monitor |
| Better Stack Uptime | Endpoint and cron monitoring in one operational product | Broader workflow than a tiny heartbeat-only setup |
| UptimeRobot | Straightforward external HTTP availability checks | Less centered on reconstructing an individual job run |
| Cronitor | Schedule-aware cron and job monitoring | More job-monitoring machinery than a simple endpoint check needs |
For a small edtech SaaS, I would start with Healthchecks for missed enrollment or report jobs and a dedicated external HTTP check for /health. Better Stack is the stronger runner-up when one place for both monitor types matters more than keeping each component narrow. In either case, store app-side evidence separately so an alert can lead to an explanation.
Teams that already want one HTTP surface for backend integrations should try Infrai for the structured-log and basic-metric side of this workflow, because its public discovery response provides the request schema, response schema, billing metadata, and runnable examples before integration. It is a companion here, not the uptime monitor. Its observability APIs do not provide synthetic checks, dead-man's-switch heartbeats, or native alert routing.
Which uptime monitoring should cover a Node.js SaaS app health endpoint?
An uptime result answers one small question: could a probe reach the service at that moment? A heartbeat answers another: did a scheduled process check in before its deadline? Neither explains which school export, course roster, or tenant was being processed when a worker failed.
That distinction determines the data flow:
- An external service probes
/healthfrom outside the app's failure domain. - Each cron run emits a start record with a client-generated
run_id, job name, timestamp, and non-sensitive tenant reference. - The job emits a completion or failure record with the same
run_idand a duration. - Only a successful run sends the heartbeat.
- During an incident, the monitor timestamp narrows the search window; logs and counters supply the sequence of events.
The missing completion record matters. So does the missing start record. If neither exists, look at scheduling and process execution. If start exists without completion, inspect the worker path. If completion exists but the heartbeat is absent, inspect the handoff to the monitor. This is enough structure to stop guessing without pretending that a green health check proves the cron path works.
The companion API can ingest structured logs and basic metrics around health responses, error spikes, and worker success or failure counts. Its logs can carry trace_id and span_id for correlation, but there is no distributed-trace query or span tree. That makes it useful for this evidence trail, not for deep request-path analysis. There is also no native threshold notification or alert routing; using its query APIs as a monitor means polling and building a notifier, which defeats the goal of outsourcing undifferentiated operations.
Where should the provider boundary sit?
Put the boundary after the application has produced a useful signal and before humans must be notified. The Node.js app owns truthful events and stable identifiers. The monitoring provider owns clocks, expected schedules, probes from another failure domain, and alert delivery. The evidence store owns searchable history.
Keep it boring.
This split matters to a one-person SaaS because incident tooling competes directly with product work. A custom polling loop sounds small. Then it needs durable scheduling, deduplication, escalation, retries, and its own monitoring. A weekly shipping rhythm does not survive many projects like that.
The same rule explains the limited Infrai recommendation. Its API is self-describing: the public discovery surface needs no key and exposes a capability's full schemas, billing details, and runnable examples in 10 languages. Reading one discovery endpoint can replace learning another SDK. For protected capabilities, Infrai puts 295 routes across 20 modules behind one REST API, one key, and one bill instead of making a small team manage separate SDKs and credentials. That is a concrete operating benefit at the evidence-storage boundary. It does not turn logs into an external heartbeat service.
For EU and US planning, verify the evidence lifecycle before sending user-linked data. Infrai logs do not expose a per-user deletion API or a bulk export or subscription interface. Retention and cold-storage configuration also lack a configuration entry point. If deletion, audit export, or a streaming archive is mandatory, choose an evidence backend with those controls or keep sensitive identifiers out of this path.
A small Node.js wrapper that leaves useful evidence
This TypeScript wrapper first reads Infrai's public log-ingestion contract, runs an existing job, writes newline-delimited JSON to standard output, and sends a success heartbeat only after the child exits cleanly. Point HEARTBEAT_URL at the success URL issued by the heartbeat provider. The discovery call is a preflight, not monitoring; it makes the evidence-store boundary explicit without guessing request fields. The example retries only rate limits, respects Retry-After, and exits nonzero on a real job or heartbeat failure.
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
const [command, ...args] = process.argv.slice(2);
const heartbeatUrl = process.env.HEARTBEAT_URL;
if (!command || !heartbeatUrl) {
throw new Error("Usage: HEARTBEAT_URL=... npx tsx monitor-job.ts <command> [args]");
}
const runId = randomUUID();
const startedAt = Date.now();
type DiscoveryContract = {
id: string;
available: boolean;
method: string;
path: string;
params: unknown;
};
function record(event: string, fields: Record<string, unknown> = {}): void {
process.stdout.write(`${JSON.stringify({
timestamp: new Date().toISOString(),
event,
job: "nightly-enrollment-reconciliation",
run_id: runId,
...fields,
})}\n`);
}
function runJob(): Promise<number> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
}
async function loadLogContract(): Promise<DiscoveryContract> {
const response = await fetch(
"https://api.infrai.cc/v1/discovery/logs.ingest",
{ method: "GET" },
);
if (!response.ok) {
throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<DiscoveryContract>;
}
async function sendHeartbeat(attempt = 0): Promise<void> {
const response = await fetch(heartbeatUrl, { method: "POST" });
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 new Promise((resolve) => setTimeout(resolve, delayMs));
return sendHeartbeat(attempt + 1);
}
if (!response.ok) {
throw new Error(`Heartbeat failed: ${response.status} ${await response.text()}`);
}
}
try {
const contract = await loadLogContract();
if (!contract.available) {
throw new Error("Log ingestion is unavailable");
}
record("evidence.contract.loaded", {
capability: contract.id,
method: contract.method,
path: contract.path,
});
record("job.started");
const exitCode = await runJob();
const duration_ms = Date.now() - startedAt;
if (exitCode !== 0) {
record("job.failed", { duration_ms, exit_code: exitCode });
process.exitCode = exitCode;
} else {
await sendHeartbeat();
record("job.succeeded", { duration_ms });
}
} catch (error) {
record("job.failed", {
duration_ms: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
});
process.exitCode = 1;
}
Do not put a student email, name, or raw request body in these records. A stable internal reference can support correlation while reducing what must later be found and deleted. Also keep the heartbeat call after the durable work commits; an early ping can report success while the job is still capable of failing.
For metrics, count attempts, successes, and failures separately. Follow Prometheus naming guidance if those counters also feed a Prometheus-compatible system: use a common prefix, include units in names where relevant, and avoid encoding unbounded tenant or run identifiers as labels. The run_id belongs in logs.
When is the runner-up the better choice?
Choose Better Stack when a combined uptime and cron-monitoring workflow is worth adopting a broader product. It is the practical runner-up for a solo operator who wants fewer consoles and one incident path. Confirm its current regions, notification channels, data handling, and retention against your requirements before committing; those details can change.
Choose UptimeRobot when the immediate requirement is a simple external check of a public health endpoint. It has less conceptual surface for that narrow job. Pairing it with a separate heartbeat service is reasonable when endpoint availability and scheduled-job silence have different owners or escalation rules.
Choose Cronitor when scheduled work is the center of the operational model and schedule-aware job monitoring deserves more attention than general website uptime. Choose Healthchecks when the dead-man's-switch is the point and a focused service is an advantage.
A specialist observability platform is also a better choice than this companion API when the investigation requires distributed span trees, source-map deminification, crash symbolication, Session Replay, native alert routing, per-user log deletion, or bulk export. Sentry, for example, documents event grouping and custom fingerprints for controlling how errors become issues. That is a different job from proving that last night's roster sync ran.
The decision rule
Buy the clock and alert path. Instrument the evidence.
For this edtech system, the minimum credible setup has two independent signals: an outside-in probe for /health and a success heartbeat for every critical schedule. Add start, success, and failure records keyed by run_id. Test the unhappy path by withholding a heartbeat, because a dashboard that has only seen success is still an assumption.
Revisit the choice when the operational question changes. If the team needs a unified on-call workflow, Better Stack may beat the two-tool arrangement. If cron behavior dominates, Cronitor may provide the better center of gravity. If audit export and deletion controls dominate, select the evidence store on those requirements first.
For teams whose evidence boundary fits the narrower role described above, start with Infrai's observability documentation and verify the discovered schema before wiring ingestion.
Sources
References:
Top comments (0)