Short answer: use an external uptime service with probes in the US and EU, a hosted status page, and a separate cron heartbeat; keep logs and metrics beside it to explain which B2B SaaS tenant, dependency, and AI agent step drove latency and cost after an alert.
The smallest credible setup has two layers. The outside layer asks, "Can a customer reach the app, did the scheduled job run, and should we publish an incident?" The inside layer asks, "Which dependency slowed down, and which tenant or agent run consumed the money?" Trying to make one telemetry store answer both sets of questions leaves a blind spot exactly where an outage begins: outside your process.
How do US/EU health checks compare with a status page and cron heartbeat?
Start with the decision table. Pick by failure mode, not by the length of a feature list.
| Option | Best role in this stack | Pick it when | Do not treat it as |
|---|---|---|---|
| Better Stack | External probes plus customer-facing incident communication | One service should cover endpoint checks and a hosted status surface | The source of tenant-level AI cost attribution |
| UptimeRobot | Straightforward regional endpoint checks and a public status surface | Easy setup matters more than programmable test logic | Proof that a cron worker actually executed |
| Checkly | Programmable API or browser checks | A shallow 200 OK check cannot represent the customer journey |
A complete incident communication plan by itself |
| Healthchecks.io | Dead-man monitoring for cron and queue workers | Silence is the failure signal: an expected ping never arrives | A replacement for regional HTTP probes |
This is a shortlist, not a claim that every plan exposes every region or status-page control. Product packaging changes. Confirm the current probe locations, check frequency, subscriber notifications, retention, and status-page limits before paying. I'm not sure which current plan will be the cheapest for a particular request volume without those live plan details, and a one-week trial with the actual endpoints would resolve more than a static price grid.
Diagram in words: US probe + EU probe -> alerting service -> on-call and hosted incident page. In parallel, the Node.js app emits agent-step latency, cost, tenant ID, dependency outcome, and a shared request ID into logs and metrics. The alert tells you to look. The telemetry tells you where.
Consider a hypothetical 02:00 UTC failure in a B2B agent workflow. The EU probe cannot complete the paid API action, the US probe remains green, and the cron heartbeat arrives on schedule. That combination argues against declaring the entire service down. The incident owner can mark the affected regional component, inspect events for failed agent steps, group them by dependency and tenant, and compare cost only among completed calls; failed calls without returned cost metadata should remain unknown rather than being silently recorded as zero. If both regional probes fail while the heartbeat continues, the serving path deserves attention before the scheduler. If the probes stay green but the heartbeat disappears, the public API is the wrong place to look. This small matrix turns three independent signals into a useful first move without pretending they establish root cause. It also keeps cost attribution honest: availability is measured outside the process, while spend is attached to the exact agent boundary that reports it.
One green dot proves very little.
Two probes matter because a single location can confuse a regional network path with a global outage. Still, two green probes don't prove that every customer workflow works. Put a cheap /health check on the critical serving path, then add one deeper API check for the action customers actually pay for. Keep both bounded; a health check that launches a full AI agent loop can create load and spend while masking the simpler question of reachability.
Failure ownership across probes, pages, and heartbeats
For the easiest general setup, begin with Better Stack or UptimeRobot and publish the status page from the same operational workflow as the alert. The important mechanism is external execution. If the app process, cloud account, or telemetry pipeline is unavailable, the probe must still be able to notice and the incident page must still be reachable. Status communication is a customer surface, so give components customer-readable names such as "Agent runs" and "Dashboard API," not internal deployment labels.
Pick Checkly when the meaningful check needs code: authenticate, create a harmless test request, verify a response field, and clean up. The catch is that programmable checks demand maintenance. A brittle synthetic script can page the team because a selector changed even though the paid workflow still works. For a tiny startup with one JSON endpoint, stick with a simple HTTP monitor until a deeper check catches a failure that the shallow check misses.
Use Healthchecks.io for cron and queue workers. A log line appears only after code starts; it cannot reliably report code that never ran. A heartbeat reverses the logic — the monitor expects a ping inside a window and alerts on silence. Give each production schedule its own heartbeat, include a grace period longer than normal scheduling jitter, and ping only after the useful work commits. Fast. Clear.
Silence is data.
None of those choices produces good cost attribution on its own. For a multi-tenant AI agent, record tenant_id, agent_run_id, step, model, latency_ms, cost_usd, outcome, and request_id at the boundary where each model or tool call returns. Aggregate cost by tenant and run; chart latency by step and outcome. Do not put prompts, access tokens, or customer payloads into a health endpoint or a public incident page.
On the telemetry side, Infrai can fit when a team wants logs and metrics behind one plain REST API and one key: its public discovery response describes method, path, JSON schemas, billing, and runnable examples, so wiring a capability starts by reading the endpoint rather than installing another SDK. Its observability surface stores and queries telemetry, but it has no synthetic probes, hosted status page, notification routing, or heartbeat monitoring. That boundary is the reason it belongs behind an external monitor, not in place of one.
Implementation: wire the evidence path in Node.js
The application needs a health endpoint that says only what an external probe needs, plus structured events that preserve the dimensions needed for cost analysis. It also needs a disciplined way to inspect stored evidence after the external alert. The next TypeScript snippet calls the verified log-search route without inventing filter keys, requires the API origin and key from environment variables, and treats rate limiting as a normal retry condition. The response stays unknown because no response fields are needed for this example.
const apiOrigin = process.env.TELEMETRY_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiOrigin || !apiKey) {
throw new Error("TELEMETRY_API_ORIGIN and INFRAI_API_KEY are required");
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
}
return 500 * 2 ** attempt;
}
async function searchLogs(): Promise<unknown> {
const url = new URL("/v1/logs/search", apiOrigin);
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Log search failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Log search retry budget exhausted");
}
console.log(JSON.stringify(await searchLogs()));
No query string is deliberate. The discovery parameters do not declare log-search filters, so adding plausible names would turn a copy-pasteable example into a guess. Inspect the returned shape in a non-production environment, confirm supported filters against the current discovery surface, and only then narrow the query used by an incident tool.
The following application code uses Node's built-in server and fetch, so there is no framework-specific magic. Set DEPENDENCY_HEALTH_URLS to comma-separated private dependency health URLs; leave it empty when the app has no dependency that should gate readiness.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
type AgentCall = {
tenantId: string;
runId: string;
step: string;
model: string;
execute: () => Promise<{ costUsd: number }>;
};
const dependencyUrls = (process.env.DEPENDENCY_HEALTH_URLS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
async function dependencyIsHealthy(url: string): Promise<boolean> {
try {
const response = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(2_000),
});
return response.ok;
} catch {
return false;
}
}
async function healthSnapshot() {
const checks = await Promise.all(
dependencyUrls.map(async (url) => ({
dependency: new URL(url).hostname,
healthy: await dependencyIsHealthy(url),
})),
);
return {
status: checks.every((check) => check.healthy) ? "ok" : "degraded",
checks,
};
}
async function observeAgentCall(call: AgentCall): Promise<void> {
const requestId = randomUUID();
const startedAt = performance.now();
try {
const result = await call.execute();
console.log(JSON.stringify({
event: "agent_step_completed",
tenant_id: call.tenantId,
agent_run_id: call.runId,
request_id: requestId,
step: call.step,
model: call.model,
outcome: "success",
latency_ms: Math.round(performance.now() - startedAt),
cost_usd: result.costUsd,
}));
} catch (error) {
console.error(JSON.stringify({
event: "agent_step_completed",
tenant_id: call.tenantId,
agent_run_id: call.runId,
request_id: requestId,
step: call.step,
model: call.model,
outcome: "error",
latency_ms: Math.round(performance.now() - startedAt),
error_name: error instanceof Error ? error.name : "UnknownError",
}));
throw error;
}
}
const server = createServer(async (request, response) => {
if (request.method !== "GET" || request.url !== "/health") {
response.writeHead(404).end();
return;
}
const snapshot = await healthSnapshot();
const statusCode = snapshot.status === "ok" ? 200 : 503;
response.writeHead(statusCode, { "content-type": "application/json" });
response.end(JSON.stringify(snapshot));
});
server.listen(Number(process.env.PORT ?? 3000));
export { observeAgentCall };
The before/after is crisp. Before, an alert says "API down," and the team searches undifferentiated output. After, the external service reports that the EU probe failed at a specific time while the US probe stayed green; the application data can then group failed agent_step_completed events by dependency, tenant, model, and run. That does not magically prove root cause, but it sharply narrows the first useful query.
Keep the public response small. Dependency hostnames are included above for an authenticated or privately addressed monitor; remove them from a public endpoint if they disclose architecture. Don't return stack traces, model prompts, raw exception messages, account IDs, or cost totals. The status page should say what customers experience and what the team is doing, while detailed evidence stays in access-controlled telemetry.
For the scheduled worker, add a heartbeat call after the transaction commits. The URL comes from the heartbeat provider and stays in an environment variable. A non-success response must fail the job so the scheduler records the notification failure rather than quietly declaring the whole run healthy.
export async function reportCronSuccess(): Promise<void> {
const heartbeatUrl = process.env.CRON_HEARTBEAT_URL;
if (!heartbeatUrl) {
throw new Error("CRON_HEARTBEAT_URL is required");
}
const response = await fetch(heartbeatUrl, { method: "POST" });
if (!response.ok) {
throw new Error(`Heartbeat rejected with HTTP ${response.status}`);
}
}
Call that function once, after successful work. If a job can be retried, make the business operation idempotent independently of the heartbeat. The heartbeat proves execution timing; it does not prevent a queue consumer from applying the same update twice.
Migrate without monitoring gaps
Don't replace an existing monitor in one edit. Run old and new probes in parallel, keep the old paging route active, and move the public status link only after both systems agree on a planned test. Add heartbeats one worker at a time, because a missing heartbeat can mean bad monitor configuration as easily as missing work during the first deployment.
Choose a hosted uptime product when customers need a status page and the team needs alert delivery without building a polling service. Choose programmable checks when a real transaction is the only trustworthy availability signal. Add dead-man monitoring for every cron or queue task whose absence matters. Then keep internal logs and metrics for investigation and cost attribution. During a seven-day overlap, pause a disposable worker to prove the heartbeat alert fires, return a controlled unhealthy response to prove both regions observe it, post and resolve a test incident, and trace one synthetic agent run from request ID to step latency and cost. Never run those drills against customer work.
The limitations are real. External checks can miss failures between intervals, a green health route can hide a broken customer journey, and a synthetic transaction can produce false alarms or unwanted data. Hosted incident pages also require an editorial process: ownership, component names, update cadence, and a clear rule for resolution. This setup is not suitable when regulation requires self-hosting, when probe locations do not match the user base, or when incident communication must run inside an existing enterprise platform; in those cases, keep that platform and add only the missing regional probes or heartbeats.
There is also a telemetry boundary. Logs may carry trace_id and span_id, but that is not a distributed trace explorer or a span tree. There is no source-map decoding, crash symbolication, Electron minidump parsing, or session replay in the described observability layer. Logs also lack a per-user deletion route and bulk export or subscription interfaces. If those are requirements, choose a dedicated observability product that explicitly supports them.
Query configuration deserves one last warning. The discovery parameters for log search and metric query filters are not fully declared, so don't publish guessed filter names in production snippets. I'm not sure which filters are accepted from discovery alone; inspecting the current schema and validating a non-production query is the honest next step. Until then, preserve the fields in emitted events and avoid making the alert path depend on an undocumented query.
The decision rule is short: detect from outside, communicate from outside, explain from inside. That separation costs one more integration, but it keeps a dead application from being responsible for announcing its own death.
Top comments (0)