For a Node.js SaaS, a simple error alerting API can report a failed healthtech notification only after code throws; a cron job that never starts leaves nothing to poll. That constraint changes the tool choice.
TL;DR: use error-event capture plus a small polling worker when the target is application exceptions and owning the final Slack or email step is acceptable. For a fuller managed alerting workflow, choose Sentry, Bugsnag, or Datadog Error Tracking. Add Healthchecks for scheduled notification jobs that can fail silently. My pick for a small backend is the polling worker, but only when those three failure modes are separated instead of forced into one tool.
The lean option has a concrete operational appeal. Infrai can put backend capabilities behind one REST API, one key, and one bill, which avoids key sprawl and month-end invoice cleanup; its public discovery surface also exposes request and response schemas. Its error capability detects failures, but it does not provide alert rules or notification routing. The worker is therefore part of the design, not hidden glue.
Should a Node.js SaaS poll an error alerting API?
“Send an alert on error” is too vague for a delivery service. The useful question is: can the responder move from a notification failure to the relevant error group and its events, while preserving enough application context to identify the delivery attempt?
There are three distinct cases:
- Backend or frontend code throws. Error ingestion can record that failure, and a poller can find recent groups or events.
- A scheduled delivery job starts and then throws. The same path works, provided the exception is captured.
- The scheduler never invokes the job. No exception exists. Error ingestion cannot infer absence, so a heartbeat monitor such as Healthchecks must own that signal.
That third case is the trap. No event means nothing to poll.
Incident reconstruction also sets a hard boundary around browser-heavy debugging. This error path has no source-map deobfuscation, crash symbolication, Electron minidump parsing, or session replay. If the responder needs readable production JavaScript stacks or a visual trail of the user session, a Sentry-like product is the better category.
The smallest worker I would ship
I care about time-to-first-call, but a short example still has to fail correctly. This TypeScript worker makes one authenticated query, honors Retry-After on a 429, checks every status, and sends the changed snapshot to a Slack incoming webhook. It deliberately treats the response as unknown JSON because guessing undocumented fields is worse than writing six extra lines.
The first run establishes a baseline. Later changes generate a notification. In production, I would validate the discovered response schema and reduce the payload to newly observed groups; snapshot hashing is a transport smoke test, not a complete incident policy.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
const apiKey = required("INFRAI_API_KEY");
const apiBaseUrl = required("INFRAI_BASE_URL");
const slackWebhook = required("SLACK_WEBHOOK_URL");
const statePath = process.env.ERROR_STATE_PATH ?? "/tmp/error-groups.sha256";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(value) - Date.now();
if (dateDelay > 0) return dateDelay;
}
return 1_000 * 2 ** attempt;
}
async function fetchGroups(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${apiBaseUrl}/errors/groups`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
await wait(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
throw new Error(`Error query failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("Error query exhausted retries");
}
async function previousHash(): Promise<string | undefined> {
try {
return (await readFile(statePath, "utf8")).trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
const groups = await fetchGroups();
const snapshot = JSON.stringify(groups);
const hash = createHash("sha256").update(snapshot).digest("hex");
const before = await previousHash();
if (before && before !== hash) {
const response = await fetch(slackWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `Error groups changed:\n${snapshot.slice(0, 2_500)}` }),
});
if (!response.ok) {
throw new Error(`Slack delivery failed (${response.status}): ${await response.text()}`);
}
}
await writeFile(statePath, `${hash}\n`, "utf8");
The API key goes only to the error API. Slack gets its own webhook credential. This matters: copying one authorization header into every request is the kind of “convenience” that turns a tiny worker into a credential leak.
There is another trade-off in the hash. Any snapshot change can alert, including a resolution or regrouping, so it favors minimal assumptions over signal quality. A schema-aware cursor and a durable state store are the next steps once the response contract and desired incident semantics are fixed.
Where the managed products win
The comparison is less about collection and more about how much incident machinery should be bought versus built.
| Option | Best fit for this notification service | Boundary that changes my choice |
|---|---|---|
| Polling error events | Backend exceptions, a small service surface, and a team willing to own Slack or email delivery | No built-in alert routing, source-map deobfuscation, symbolication, distributed trace query, or session replay |
| Sentry | Application error debugging where readable JavaScript failures and richer debugging context drive response | More product surface than a plain query-and-notify loop |
| Bugsnag | Teams evaluating a managed application-error workflow instead of maintaining a poller | It is a separate vendor workflow and credential to operate |
| Datadog Error Tracking | Teams already evaluating errors alongside a broader observability system | Broader integration can be unnecessary for one narrow exception-alert path |
| Healthchecks | Cron and scheduled delivery jobs where “did not run” is itself the incident | It complements exception capture; it does not replace application error events |
This is a fair split. Sentry, Bugsnag, and Datadog should be evaluated from their current product documentation because their managed workflows can change. The polling design stays attractive when the contract is narrow: capture exceptions, query recent error data, and route a message. It stops being attractive as soon as replay, deobfuscation, or vendor-managed escalation becomes a requirement.
I would not use raw log search as a disguised error tracker here. Logs can carry trace_id and span_id for correlation, but this capability does not expose distributed trace queries or a span tree. OpenTelemetry's logs model helps standardize correlation; it does not manufacture a trace backend.
What I would change at scale
First, replace the local hash file with durable state and make overlapping worker runs mutually exclusive. Then bind parsing to the API's published response schema, retain a stable cursor, and deduplicate notifications before calling Slack or email. Poll frequency, query window, and notification suppression need to be explicit knobs. Config bloat starts when these are scattered across environment variables and three schedulers, so I would keep one typed config object and reject invalid combinations at startup.
I would also split signals by ownership. Application exceptions go through error capture. The heartbeat service watches each scheduled delivery lane. The notification router owns escalation and deduplication. That separation gives responders a useful absence signal without pretending a missing event is an error event.
The main scaling limit is human, not request volume. Once the team needs threshold rules, on-call escalation, auditability, or several notification policies, maintaining the worker becomes product work. Pick a managed alerting product then. No heroics.
Decision rule
Choose polling error events when all of these are true: the alert source is an actual application exception, a cron or serverless worker can run reliably, and the team accepts ownership of deduplication plus Slack or email delivery. For this bounded Node.js SaaS case, that is my choice because it keeps the first call plain and the control flow inspectable.
Choose Sentry, Bugsnag, or Datadog Error Tracking when managed routing and richer production debugging matter more than a minimal integration. Pair either path with Healthchecks when a delivery job can simply disappear. The categories overlap, but the failure signals do not.
Sources
References used for the decision and implementation boundaries:
- OpenTelemetry, “Logs”: https://opentelemetry.io/docs/concepts/signals/logs/
- Sentry documentation: https://docs.sentry.io/
- Bugsnag documentation: https://docs.bugsnag.com/
- Datadog Error Tracking documentation: https://docs.datadoghq.com/error_tracking/
- Healthchecks documentation: https://healthchecks.io/docs/
Top comments (1)
예외가 발생한 작업과 아예 시작하지 않은 작업을 나눈 설명이 명확하네요. 폴링 워커 자체가 멈춘 경우도 별도 하트비트로 확인하면 감시의 빈틈을 찾기 좋겠습니다. 알림 전송 성공 전후 어느 시점에 커서를 저장할지도 명시하면, 재시작 때 누락과 중복을 시험하기 쉬울 것 같습니다.