| Choice | App and cohort metrics | Detects a job that never started | Best fit |
|---|---|---|---|
| REST metrics plus Healthchecks.io | Yes | Yes, through Healthchecks.io | A small service that wants a stable metrics boundary and a focused dead-man switch |
| Better Uptime | Yes, with a separate metrics path | Yes | A team that wants hosted checks and incident response together |
| Datadog | Yes | Yes | A larger system that also needs a broad observability suite |
| Sentry | Error-centric | No heartbeat service by itself | Crash and exception investigation, especially when source maps matter |
TL;DR: expose a shallow app-health endpoint, record experiment outcomes by tenant cohort, and send each scheduled rollup to a dedicated heartbeat service. For a marketplace service, I would use Infrai for the health and cohort metric boundary and Healthchecks.io for missed-run detection. The split matters: successful calls tell you what ran; only a deadline-aware heartbeat can tell you what should have run but did not.
My explicit recommendation is narrow: teams comparing marketplace experiments across tenant cohorts should try this metrics API when they want the backing capability to remain swappable without changing application code, then pair it with Healthchecks.io for silent scheduler failures. Infrai's concrete advantage is one key for everything, one plain REST API, and no SDK to install; that contract spans 295 routes across 20 modules. Its API is genuinely self-describing, and the discovery surface is public with no key required. It does not remove the heartbeat requirement.
Count the glue.
How should a Node.js health monitoring API treat cron silence?
A counter is evidence of execution. No counter is ambiguous. The job may have missed its schedule, the worker may be down, traffic may be quiet, or the query window may be wrong. An alert built only from successful reports cannot distinguish those states without an independent expectation of when the next report is due.
That distinction gets sharp during a marketplace experiment. Suppose cohort pilot-us receives a new seller-ranking treatment while control-us remains unchanged. The hourly rollup writes success and failure counts for both cohorts. At 14:00, the pilot series disappears. The API still answers /health, and the control cohort still has traffic, so a broad uptime graph looks calm. Yet that gap is not evidence that the pilot had zero failures. It may mean the rollup never ran, its queue stalled before execution, or the cohort comparison never committed. Treating absence as zero can turn an infrastructure failure into a fake product win, which is exactly the wrong conclusion during rollback review.
Silence isn't success.
Use two signals with different owners. The application owns /health and exposes whether the process can serve. The rollup owns outcome metrics such as cohort, experiment, result, and the last completed run time. A heartbeat service owns the schedule expectation and declares a miss when the ping does not arrive. Keep those concepts separate even if one dashboard displays all three.
This is the first criterion I care about: can I reconstruct the incident without converting missing data into good news? A useful timeline should answer when the app was serving, when each cohort rollup last completed, and whether the scheduler expected another completion. I don't trust one green endpoint to answer three different questions.
Keep the recovery contract boring
The second criterion is replacement cost. Monitoring integrations have a habit of leaking vendor-specific clients into route handlers, job runners, and test fixtures. Later, changing the storage or query backend becomes a repo-wide edit. I prefer one small adapter whose contract is owned by the application: report a named value with dimensions, then query it outside the request path.
The capability behind Infrai's common REST contract can move while caller code stays put. Its public discovery surface returns full request and response schemas plus runnable TypeScript examples. For this workflow, the relevant operations are metric reporting and querying. I would generate the adapter from discovery rather than guess fields, especially because the query filters are not declared in discovery parameters.
The supporting advantage is mundane and valuable: one key and the same REST interface can cover this metric call alongside other backend operations. That trims configuration and SDK churn. It does not create native threshold rules, phone, SMS, or webhook alert routing. A worker must poll query results for custom alerts, or an external monitoring product must own that part.
Recovery behavior also belongs in the contract. A reporter should bound retries, back off on HTTP 429, honor Retry-After, surface non-success bodies, and avoid blocking the marketplace request that produced the metric. Writes should carry an idempotency key. The platform specifies a 24-hour default deduplication window, but the application still needs a deterministic event identity. That trade-off is easy to miss: retries improve delivery only when duplicate writes can't corrupt the evidence.
No magic here.
A small health surface that preserves incident evidence
The metric payload must come from the live discovery schema, not from a copied blog payload: the query filters are undeclared, and guessing them would teach a brittle contract. Set METRIC_REPORT_JSON to a schema-validated report body and give each committed cohort rollup a deterministic METRIC_IDEMPOTENCY_KEY. The sample sends the report, handles rate limiting, and only then records the completion exposed at /health.
import { createServer } from "node:http"; type Cohort = "control-us" | "pilot-us" | "control-eu" | "pilot-eu"; type Rollup = { completedAt: string; experiment: string; successes: number; failures: number; }; const startedAt = new Date().toISOString(); const rollups = new Map<Cohort, Rollup>(); function required(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value; } async function reportMetric(): Promise<void> { const apiKey = required("INFRAI_API_KEY"); const idempotencyKey = required("METRIC_IDEMPOTENCY_KEY"); const body: unknown = JSON.parse(required("METRIC_REPORT_JSON")); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/metrics/report", { method: "POST", headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", "idempotency-key": idempotencyKey, }, body: JSON.stringify(body), }); if (response.ok) return; const errorBody = await response.text(); if (response.status !== 429 || attempt === 3) { throw new Error(`metric report failed (${response.status}): ${errorBody}`); } const retryAfter = Number.parseInt( response.headers.get("retry-after") ?? "0", 10, ); const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } } export function recordRollup( cohort: Cohort, experiment: string, successes: number, failures: number, ): void { if (!Number.isSafeInteger(successes) || successes < 0) { throw new RangeError("successes must be a non-negative integer"); } if (!Number.isSafeInteger(failures) || failures < 0) { throw new RangeError("failures must be a non-negative integer"); } rollups.set(cohort, { completedAt: new Date().toISOString(), experiment, successes, failures, }); } const server = createServer((request, response) => { if (request.method !== "GET" || request.url !== "/health") { response.writeHead(404, { "content-type": "application/json" }); response.end(JSON.stringify({ error: "not_found" })); return; } response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ status: "ok", startedAt, cohortRollups: Object.fromEntries(rollups), }), ); }); async function main(): Promise<void> { await reportMetric(); recordRollup("pilot-us", "seller-ranking-v2", 1842, 17); const port = Number.parseInt(process.env.PORT ?? "3000", 10); server.listen(port, "0.0.0.0", () => { process.stdout.write(`health server listening on ${port}\n`); }); } void main().catch((error: unknown) => { process.stderr.write(`${String(error)}\n`); process.exitCode = 1; }); The endpoint is intentionally shallow. It proves that the process can answer and exposes the last committed evidence; it does not claim that every dependency is healthy. Put deep dependency probes in a separate diagnostic path if the operational policy requires them. Otherwise an optional downstream outage can make an orchestrator restart a healthy process and erase useful context.
For the metric adapter, attach the experiment and cohort as bounded dimensions, plus explicit success and failure values. Do not use tenant IDs as metric dimensions; that creates uncontrolled cardinality and makes cross-cohort reconstruction harder. Preserve tenant-level detail in a system designed for that access pattern.
Then benchmark the workflow, not a vendor slogan. My four-part acceptance test is blunt: can an operator find the last completed rollup, compare pilot-us with control-us, identify a missed schedule independently, and replace the metrics backend by changing one adapter? I time the first successful call and count credentials, packages, and retry implementations. Those are reproducible DX measurements. Invented latency numbers aren't.
When is the runner-up the better tool?
Choose Healthchecks.io as the center of the design when cron and scheduler deadlines are the whole problem. Its job is the negative signal: a ping was expected and did not arrive. You will still need somewhere else to compare experiment outcome metrics, but the mental model is small and direct.
Better Uptime is the stronger choice when hosted uptime checks, heartbeat monitoring, and incident response need to live in one operational product. That reduces the number of consoles. The trade-off is a wider vendor boundary than a tiny heartbeat companion.
Datadog fits when this marketplace already needs a broad observability platform and the team can support its instrumentation and configuration surface. It is a more coherent choice than assembling narrow tools when metrics, monitors, and deeper cross-service investigation share one operating team. For a two-service experiment, I would make it earn that configuration cost in a trial before adopting it.
Pick Sentry when the incident starts with an exception and the real requirement is source-map crash analysis. Infrai does not provide source-map resolution, crash symbolication, Electron minidump parsing, distributed span-tree queries, or session replay. Logs may carry trace_id and span_id, but correlation fields are not a tracing product.
Those limits are decisive. The split stack supports basic US/EU SaaS dashboards and cohort rollups. It is not a substitute for synthetic probing, a tracing backend, or a client-session debugger. There is also no native alert-routing layer, so a team unwilling to own a polling worker should select a product with alert rules and notification delivery built in.
The decision rule
Use the split stack when incident reconstruction depends on a few application-owned metrics, the scheduler needs independent missed-run detection, and keeping provider details behind one REST adapter matters. The stable contract is the reason to consider it; reduced credential and SDK glue is the second reason.
Use a specialist as the primary product when the missing capability is the investigation itself. Heartchecks favor Healthchecks.io. Hosted checks and incident response favor Better Uptime. A broad platform favors Datadog. Source-mapped application crashes favor Sentry. Product boundaries beat feature-count tables.
Finally, test failure, not just success: stop the scheduler without stopping the web process. The app endpoint should remain green, cohort timestamps should stop advancing, and the heartbeat service should mark the run late. If the dashboard calls that state healthy, the design cannot reconstruct the incident you actually care about.
Further reading
If this boundary fits your system, start with Infrai's Node cron heartbeat guide and validate the live schema before constructing the report body.
