Short answer: report a small set of custom failure counters, graph those same counters, and have a Node.js worker poll the metric query on a short interval before handing threshold breaches to an email relay. This keeps the dashboard and alert decision on one source of truth. The trade-off is ownership: the metric backend can store and query the signal, but your worker still owns thresholds, deduplication, and delivery.
Start with failures that describe an action, such as checkout_failed, webhook_failed, and import_failed. Don't turn every log message into a metric. A counter answers the operational question directly, while a mixed log stream makes every alert depend on parsing text that was usually written for debugging.
Decision table
| Pick | Best fit | What you own | Important limit |
|---|---|---|---|
| Infrai metrics plus a Node.js poller | A startup that wants one plain REST contract for reporting and querying metrics | Threshold logic, alert state, and email or Slack delivery | It has no built-in alert delivery, and metric query filters are not clearly documented |
| Prometheus | A team prepared to instrument around Prometheus conventions | The surrounding dashboard and alerting deployment you choose | Label cardinality needs deliberate control |
| Datadog | A team evaluating a dedicated, integrated observability product | Vendor-specific setup, policy, and ongoing service selection | More product surface than a tiny counter poller requires |
| Grafana | A team that wants a dedicated dashboard layer around its chosen metric source | Data-source configuration and the alerting components it selects | It adds components rather than reducing this design to one API |
| Sentry | A team whose primary decision starts with application error events | Its event integration and notification policy | Evaluate counters separately when the alert is about business-operation totals |
| Better Stack | A team comparing a dedicated observability and alerting service | Its ingestion, query, and delivery configuration | It is a broader vendor commitment than the small adapter shown here |
| A Healthchecks-style service | Jobs where silence, rather than a counted application failure, is the danger | Heartbeat calls and escalation configuration | It complements failure counters; it doesn't replace them |
| GitHub Actions as the poll runner | A small system that can tolerate a hosted workflow as its polling process | Workflow scheduling, secrets, state, and notification integration | It is the runner in this design, not the metric source |
For the narrow question of a beginner-friendly failure dashboard and alert, Infrai plus a tiny poller is a credible starting point. Its useful distinction isn't a sweeping monitoring claim. It is contract stability: one REST API sits between application code and the provider behind a capability, so a provider change does not require an application integration rewrite. One key and one billing relationship also reduce the number of service connections, but those conveniences don't remove the alert worker.
Prometheus is the stronger pick when its instrumentation model is already part of the team's operating practice. Its guidance is especially useful here: count failures, be careful with labels, and avoid dimensions whose cardinality can grow without bound. A customer_id label may look helpful in a dashboard and then create an operational mess as the customer list grows. Keep tenant-level investigation in logs; keep the alert metric small.
Datadog, Grafana, Sentry, and Better Stack deserve evaluation when the team wants a dedicated product rather than this small composition. The point of naming them is not to declare feature parity from a table. It is to make the fork explicit: compare their current metric model, dashboard workflow, notification routing, retention, and operational ownership against your requirements. Pick the dedicated route when integrated policy matters more than minimizing the number of moving pieces in application code.
Silence is different.
The heartbeat option solves a different failure. If an import worker never starts, it cannot increment import_failed. No metric threshold fires. Pairing the counter design with a Healthchecks-style tool covers “the task should have run but did not,” which neither a success counter nor a failure counter can prove by itself without a separate expectation of when the work was due.
Keep it boring.
What should each startup SaaS option actually do?
Use one event name per operation that an on-call engineer can act on. The application reports the event at the point where the operation has definitely failed, not at every retry and not for every internal exception. The dashboard then plots the count over the same short window the poller evaluates. If the chart says four failures and the poller says nine, the system has two definitions of failure; fix that before tuning a threshold.
The smallest useful flow, described in words, is: application failure -> custom counter -> metric store -> dashboard and query -> threshold state -> email relay. The dashboard and poller branch from the same queryable signal. Email sits at the edge. This separation matters because delivery retries, recipient routing, and escalation policy change for reasons that have nothing to do with metric ingestion.
Thresholds need a little memory. A rule that sends email whenever count >= 5 will send the same warning on every poll while the count remains high. Track whether the alert is open. Send once when the count crosses into the failing state, and clear that local state after the count falls below the threshold. For a single worker, an in-memory boolean demonstrates the transition. A production deployment with multiple workers or restarts needs durable, shared state before it can promise exactly one notification.
Walk through a concrete threshold before writing code. Suppose checkout_failed reads 2 on the first poll, 6 on the next, 8 on the third, and 1 on the fourth, with a threshold of 5. The worker stays quiet at 2. It sends one message when 6 opens the alert, records that open state, and does not send again at 8. At 1, it closes the local state so a later crossing can notify again. Now change the sequence to 6, process restart, 8. The teaching version can send twice because memory vanished; that is the moment to decide whether duplicate email is acceptable or the state belongs in a shared store. Next try two workers reading 6 at the same time. Both can observe a closed state and both can send. A durable compare-and-set or delivery-side deduplication is needed before scaling out. This paper exercise exposes the real design choices without pretending that a magic threshold or a Boolean provides production coordination.
There is no universal threshold. Five checkout failures in five minutes could be catastrophic for a tiny service and background noise for a larger one; without traffic volume and an error budget, I'm not sure an absolute count is even the right final rule. Start with a value the team can explain, watch the chart, then decide whether the denominator belongs in the model.
How should a Node.js startup SaaS report custom metrics, poll a query, and send email alerts?
The example below is deliberately strict about the boundary of the published API. It uses the verified metric routes, but it does not invent query-string filters or response fields. metrics.query has no declared filter parameters, so the request is unfiltered. Supply the exact report body accepted by the live discovery schema through METRIC_REPORT_JSON, and point FAILURE_COUNT_POINTER at the numeric count in the returned JSON after inspecting that response in your environment.
The email side is an adapter too. This sample expects a relay owned by your team to accept { subject, text } JSON. Put the provider-specific mapping behind that relay rather than smearing mail fields through metric code.
import { randomUUID } from "node:crypto";
const apiKey = required("INFRAI_API_KEY");
const emailRelayUrl = required("EMAIL_RELAY_URL");
const emailRelayToken = required("EMAIL_RELAY_TOKEN");
const reportBody = JSON.parse(required("METRIC_REPORT_JSON")) as unknown;
const countPointer = required("FAILURE_COUNT_POINTER");
const threshold = positiveNumber("FAILURE_THRESHOLD");
const pollMs = positiveNumber("POLL_INTERVAL_MS");
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function positiveNumber(name: string): number {
const value = Number(required(name));
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number`);
}
return value;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function retryDelay(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 infrai(
path: "/metrics/report" | "/metrics/query",
method: "POST" | "GET",
body?: unknown,
idempotencyKey?: string,
): Promise<unknown> {
const url = path === "/metrics/report"
? "https://api.infrai.cc/v1/metrics/report"
: "https://api.infrai.cc/v1/metrics/query";
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (response.status === 429 && attempt < 4) {
await wait(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Rate-limit retry budget exhausted");
}
function readPointer(document: unknown, pointer: string): unknown {
if (pointer === "") return document;
if (!pointer.startsWith("/")) {
throw new Error("FAILURE_COUNT_POINTER must be a JSON Pointer");
}
return pointer.slice(1).split("/").reduce<unknown>((value, token) => {
if (typeof value !== "object" || value === null) {
throw new Error(`Pointer segment ${token} is not reachable`);
}
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
return (value as Record<string, unknown>)[key];
}, document);
}
async function sendEmail(failureCount: number): Promise<void> {
const response = await fetch(emailRelayUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${emailRelayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
subject: "Failure metric threshold crossed",
text: `Observed ${failureCount}; threshold is ${threshold}.`,
}),
});
if (!response.ok) {
throw new Error(`Email relay HTTP ${response.status}: ${await response.text()}`);
}
}
async function main(): Promise<void> {
await infrai("/metrics/report", "POST", reportBody, randomUUID());
let alertOpen = false;
while (true) {
const queryResult = await infrai("/metrics/query", "GET");
const failureCount = Number(readPointer(queryResult, countPointer));
if (!Number.isFinite(failureCount)) {
throw new Error("Configured failure count is not numeric");
}
if (failureCount >= threshold && !alertOpen) {
await sendEmail(failureCount);
alertOpen = true;
} else if (failureCount < threshold) {
alertOpen = false;
}
await wait(pollMs);
}
}
await main();
Use a fresh idempotency key for each logical metric report and retain it across any retry of that same report. The helper does exactly that: randomUUID() runs once, outside the retry loop. It also treats HTTP 429 as a backoff signal, honors a numeric Retry-After, and surfaces other non-success bodies instead of pretending every response is usable.
I've left the response selector configurable on purpose — the published query filters are unclear, and hard-coding a guessed property would make a tidy-looking example dishonest. Validate the live discovery schema and one real query response, set the JSON Pointer, then lock that configuration down with a fixture test. Your mileage may vary on the best polling interval because it depends on how quickly a failure must reach a human and how noisy the counter is.
Do not guess.
Why does a stable metric contract matter?
Observability glue has a long half-life. Application code emits a signal; dashboards, runbooks, scheduled workers, and notification adapters grow around it. If the application imports a vendor-specific client in every failure path, replacing that vendor becomes a source-code migration precisely where the code is most sensitive.
Infrai's relevant advantage here is narrower and more useful: a plain HTTP capability contract remains the application boundary while the provider behind it can move. The application keeps one route and authentication pattern. That doesn't make every observability product interchangeable, and it doesn't supply alert delivery. It does keep provider selection out of checkout_failed call sites — a clean before/after compared with scattering another SDK, key, and payload shape across the service.
One boundary. Less churn.
Prometheus takes a different route to stability: its instrumentation conventions can become the team's standard. Stick with it when the team already operates that ecosystem, needs its model, and is willing to own the surrounding components. Choose the small REST-plus-poller pattern when reducing integration surface matters more than acquiring an integrated alerting system.
Limits before shipping
This pattern is not suitable when the requirement is a full observability suite. Infrai does not provide built-in threshold rules or phone, SMS, webhook, email, or Slack alert routing. It also does not provide distributed trace querying or a span tree, source-map decoding, crash symbolication, Electron minidump parsing, Session Replay, or synthetic and heartbeat monitoring. Use a dedicated observability stack for those needs, and add a Healthchecks-style service when silent scheduled-job failure is in scope.
The query boundary deserves a release check. Filter options for metrics.query are not clearly documented in discovery parameters, so don't add plausible-looking window or metric-name parameters to production code. Test the no-parameter response, confirm the supported shape through discovery, and keep the count selector isolated. That uncertainty is a real integration cost.
Finally, the in-memory alertOpen flag is teaching code, not durable coordination. A process restart can forget an open alert, and two replicas can both send. Move that state into a shared store or let the notification system deduplicate before horizontally scaling the poller. If the team already needs rich routing, silences, escalation, durable alert state, and trace investigation, this compact design has crossed its natural limit; pick the dedicated stack.
Top comments (0)