DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Should Node.js Poll Error Logs APIs for Slack Webhooks in US/EU SaaS?

Short answer: use a native log alert when it can express the policy; use a cursor-based Node.js poller when a logs API is the only portable signal; use a stream when delay and volume make polling the wrong shape.

Pick this path When it fits What you must own
Native log alert The existing log system can filter, group, route, and test the rule Provider-specific configuration and regional controls
Node.js API poller The source exposes stable pagination and the team needs a small, inspectable bridge Cursor state, overlap control, deduplication, retries, and worker health
Stream consumer Events must arrive continuously or search volume is too high Consumer offsets, replay, partition behavior, and another runtime component

The webhook call is the easy line. The hard part is deciding what counts as a new failure after a restart, a timeout, or two overlapping polling cycles. A useful implementation therefore looks less like a timer and more like a tiny ingestion pipeline: read, normalize, classify, group, deliver, then commit.

That ordering is the field guide.

How should Node.js SaaS detect backend failures from error logs across US and EU regions?

Start with the path that creates the least new operational state. If a native alert can select the right services, group equivalent events, send to the right destination, and keep the data in its required region, pick it. The rule stays beside the logs, and the team doesn't inherit a second system whose silence must also be monitored.

Pick a Node.js poller when the logs API is the dependable integration boundary and the policy is narrow. It works especially well as a bridge: normalize one page of events, apply an explicit failure rule, group repeats, and post a compact notification. Keep the provider query syntax inside one adapter. The rest of the code should understand a small internal event type, not a vendor's response body.

Pick a stream consumer when waiting for the next poll is already a product problem, or when repeated searches do too much work. A stream changes the recovery mechanism from a search cursor to a consumer offset, but it doesn't remove the need for classification, grouping, replay tests, and notification control. It adds moving parts. Sometimes those parts are justified.

Here is the diagram in words: regional log source -> regional adapter -> normalized event -> failure policy -> fingerprint -> delivery outbox -> Slack webhook. A cursor store sits beside the adapter. A separate health check watches the poller. The arrows matter because personal data should not drift into every box merely because the original log contains it.

For US and EU SaaS deployments, treat region as an input to the design rather than a label added later. Run each poller against its configured regional endpoint, keep its cursor and dedupe state with that workload, and send only the operational context needed by the responder. GDPR Article 17 defines a right to erasure. Every copied payload creates another place where deletion and retention behavior must be understood.

Pick native alerts or streams when they remove real machinery

A native rule is the first choice when teammates can inspect it, test it, mute it, and see its delivery history without learning a private worker. It is also the cleaner choice when the log platform already owns event arrival and regional placement. The trade-off is coupling: filter language, grouping behavior, and configuration move with that system.

A stream is a stronger fit when the alert path cannot tolerate polling delay, or when scanning pages repeatedly is inherently wasteful for the workload. It gives the consumer a continuous sequence and a replay position. The team still needs to decide when delivery is considered complete and what happens if the process stops after Slack accepts a message but before the offset is committed.

No magic here.

The Node.js poller occupies the middle ground. It is easy to read and deploy, yet it quietly accumulates responsibilities if alert rules multiply. Don't choose it because setInterval() is familiar. Choose it only when owning the state machine is cheaper, clearer, and more testable than configuring the native path or operating a stream.

Build the poller as a commit protocol

The core adapter should accept an opaque cursor and return normalized events plus the next opaque cursor. Do not invent offsets, timestamps, or URL paths in the shared logic. LOGS_API_URL below is the complete endpoint supplied by the chosen logs service, so the example does not pretend every provider exposes the same route or query language.

The interval, page limit, and status policy are example decisions, not universal values. Tune them against ingestion delay, API limits, and the failure modes that actually page your team.

type LogEvent = {
  id: string;
  occurredAt: string;
  service: string;
  level: "debug" | "info" | "warn" | "error";
  message: string;
  statusCode?: number;
  traceId?: string;
};

type LogPage = {
  events: LogEvent[];
  nextCursor?: string;
};

const required = (name: string): string => {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
};

async function readLogPage(cursor?: string): Promise<LogPage> {
  const url = new URL(required("LOGS_API_URL"));
  if (cursor) url.searchParams.set("cursor", cursor);

  const response = await fetch(url, {
    headers: { authorization: `Bearer ${required("LOGS_API_TOKEN")}` },
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    throw new Error(`Log query rejected with status ${response.status}`);
  }

  return (await response.json()) as LogPage;
}

function isBackendFailure(event: LogEvent): boolean {
  if (event.level !== "error") return false;
  return event.statusCode === undefined || event.statusCode >= 500;
}
Enter fullscreen mode Exit fullscreen mode

Notice what the adapter does not do. It doesn't alert, group incidents, or save progress. That separation makes malformed responses and pagination behavior testable without calling Slack, while policy tests can use small event fixtures without calling a logs API.

Grouping deserves its own decision. Sentry's event-grouping documentation describes event fingerprints as the mechanism that determines how events are grouped. The transferable idea is useful even without adopting a particular product: build a stable fingerprint from fields that identify the failure class, and exclude high-cardinality request values that would make every occurrence look unique.

I'm not sure one normalization rule can survive every framework or message format. Your mileage may vary. Version the fingerprint policy, preserve representative fixtures, and compare grouping changes before deployment. A rule that is too broad hides distinct incidents; a rule that is too narrow turns one retry storm into dozens of notifications.

The next block makes delivery and commit order explicit. The store must be durable for the deployment model. One process can use a local transactional store; multiple replicas need shared coordination so two workers do not claim the same page at once.

type PollState = {
  loadCursor(): Promise<string | undefined>;
  saveCursor(cursor: string): Promise<void>;
  wasDelivered(eventId: string): Promise<boolean>;
  markDelivered(eventId: string): Promise<void>;
};

async function sendSlackAlert(event: LogEvent): Promise<void> {
  const lines = [
    `Backend failure in ${event.service}`,
    event.message.slice(0, 400),
    `time=${event.occurredAt}`,
    event.traceId ? `trace=${event.traceId}` : "trace=unavailable",
  ];

  const response = await fetch(required("SLACK_WEBHOOK_URL"), {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text: lines.join("\n") }),
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    throw new Error(`Webhook rejected with status ${response.status}`);
  }
}

async function pollOnce(state: PollState): Promise<void> {
  const page = await readLogPage(await state.loadCursor());

  for (const event of page.events) {
    if (!isBackendFailure(event)) continue;
    if (await state.wasDelivered(event.id)) continue;

    await sendSlackAlert(event);
    await state.markDelivered(event.id);
  }

  if (page.nextCursor) {
    await state.saveCursor(page.nextCursor);
  }
}
Enter fullscreen mode Exit fullscreen mode

Commit the page cursor only after every selected event has been accepted or found in the delivered set. If the process stops after the webhook accepts an event but before markDelivered, the restart may deliver that event again. This is at-least-once behavior, and that tiny gap is the trap. Saving the cursor first would avoid the duplicate, but it would create the worse failure: an accepted page could advance while its notification disappears. A timestamp-only query adds another edge because equal timestamps, ingestion delay, and overlapping windows need a documented tie-breaker. Prefer the source's opaque continuation mechanism when one exists. If it does not, make the adapter own a tested composite checkpoint and a deliberate overlap window; don't hide either choice inside a URL string. Run the same page twice in a fixture, stop once immediately after webhook acceptance, and verify that restart behavior matches the contract. If the destination cannot tolerate duplicates, introduce a durable outbox and an idempotent consumer instead of claiming the two remote writes are atomic. The important promise is precise and modest: selected events are not discarded merely to make the channel look tidy.

Test failure detection, not just the happy-path POST

Use a fake logs adapter and a recording webhook in tests. Stop the worker at each boundary: before reading, after reading, after webhook acceptance, after recording delivery, and after saving the cursor. Restart from the same durable state and assert which events appear. This exposes the delivery contract much faster than a test that merely expects one successful POST.

Then cover an empty page, several pages, a duplicated event ID, an exception without an HTTP status, a rejected query, a rejected webhook call, a timeout, and a response that does not match the adapter schema. The expected result is not always "send an alert." Sometimes it is "preserve the old cursor, record the poll failure, and try later without losing the page."

Observe the observer — yes, really. Record last successful poll time, query duration, cursor age, pages scanned, events classified, grouped repeats, delivery attempts, and rejected adapter responses. Put the last-success check outside the log stream being polled. Otherwise a silent channel cannot distinguish a healthy backend from an expired credential or a stopped worker.

Deployment should be dull. Use read-only log credentials, scope the webhook separately, prevent overlapping cycles, stop gracefully after the current page, and cap the work performed in one run. Exercise regional configuration in staging. A dry-run destination is useful for reviewing classification and message content without waking an on-call channel.

Keep raw stack traces, authorization values, email addresses, prompts, and request bodies out of chat. A service name, normalized failure summary, event time, count, and trace reference are usually enough to begin investigation. The original log remains the detailed record, subject to its own access and retention controls.

Know when the polling bridge has reached its limit

The catch is that this design is not suitable when a few seconds of detection delay is unacceptable, the API lacks a stable way to continue a search, query work grows faster than useful incidents, or nobody owns the worker's state and health. Stick with a native alert when it expresses the complete policy. Move to a stream when continuous delivery and replay justify the extra component.

Also stop extending the poller when it starts becoming an incident platform. Complex escalation, maintenance windows, acknowledgements, cross-service correlation, and many regional policies change the job. The right result may be deleting this code.

Use the bridge while its contract stays narrow: one regional source adapter, one explicit backend-failure policy, stable grouping, durable progress, controlled delivery, and an independent heartbeat. Measure detection delay, cursor age, duplicate delivery, and scanned events per useful incident. If those measures drift, revisit the path rather than shortening the timer and hoping.

References

Top comments (0)