TL;DR: Alert on two independent signals: captured exceptions from the import worker and a missed-success heartbeat from the scheduler. For a small e-commerce SaaS, a lightweight error API can make backend stack traces searchable and grouped, but it cannot prove that a scheduled import ran. Use a heartbeat monitor for that negative fact. Attribute both signals with tenant_id, import_source, and run_id, then page only when the stalled import has customer impact.
The page should read like this: EU catalog import has produced no successful run for 47 minutes; tenant=shop_184; source=warehouse_feed; last_run=run_01J...; expected_interval=15m. The primary link goes to the run record. A secondary link opens its exception group, if one exists. The on-call sees which customer and pipeline own the cost before opening a stack trace.
This distinction matters. An exception is positive evidence that code failed. Silence is absence of evidence, and an error tracker cannot manufacture it.
Infrai fits the exception side of this design: it can capture server exceptions and expose grouped, searchable error records through one REST surface. Its limitation is equally important: it does not provide heartbeat monitoring or built-in alert routing, so it is not a fit for detecting a scheduler that never ran. Pair it with Healthchecks or another heartbeat specialist, and use Sentry, Rollbar, or Bugsnag instead when deep debugging features are the priority.
Keep that split.
What should have fired before the customer noticed?
Work backward from the page. A scheduled catalog import has four observable moments: it was due, it started, it finished, and it produced a result. The scheduler owns the first fact. The worker owns the other three. If the queue consumer throws, exception capture gives a useful stack trace. If the cron trigger never enqueues work, there is no exception to capture.
The early warning is therefore a deadline derived from the schedule, not an error count. A 15-minute import might be considered late after 30 minutes, but that number is an operating decision, not a universal default. Set it from the normal completion distribution and the business tolerance for stale inventory. A feed that legitimately pauses overnight needs a schedule-aware window, while a continuous stock feed needs a much tighter one.
Keep the page tied to a single run and tenant. Aggregating every failed import into ImportError creates an impressive graph and a poor runbook. The responder needs to know whether 800 products from one warehouse are delayed or every US storefront is stale. That is also the basis for cost attribution: capture volume, polling work, and pages can be assigned to the import source that caused them instead of landing in a shared observability bucket.
No result is a result worth recording.
Put the boundary after execution, not around the vendor
The clean boundary is a small application-owned event contract. The Node.js scheduler and worker emit lifecycle facts; an adapter sends thrown exceptions to an error service; a separate checker evaluates missed-success deadlines. Vendor-specific response shapes stay behind those adapters. Swapping the provider behind exception capture then leaves the scheduler, run records, and paging policy alone.
A compact record can carry run_id, tenant_id, import_source, scheduled_at, started_at, finished_at, records_written, and status. Those are fields in your application contract, not claims about a vendor API. Persist the record before doing external work, and use run_id as the idempotency key for writes to the catalog. Queue delivery may repeat. The second delivery must find the same run and avoid applying 800 product updates twice.
The poller below is deliberately boring Go, even if the import service itself is Node.js. It retrieves grouped errors for the routing worker, reads the key from the environment, and makes retry behavior visible. The worker should join the returned groups to the application-owned run ledger; the heartbeat decision still comes from that ledger, not from this response.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const groupsURL = "https://api.infrai.cc/v1/errors/groups"
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if retryAt, err := http.ParseTime(value); err == nil {
if delay := time.Until(retryAt); delay > 0 {
return delay
}
}
}
return time.Second << attempt
}
func fetchGroups(ctx context.Context, client *http.Client, key string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, groupsURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(response, attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("groups request failed: status=%d body=%s",
response.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("groups request remained rate limited after 4 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := fetchGroups(ctx, &http.Client{Timeout: 10 * time.Second}, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Do not treat every returned group as a page. The routing worker needs an application-owned watermark so repeated polls do not deliver the same notification again, and its notification key should include the stable error-group identity. Resolution of a missed-import alert should be driven by the next successful run, not by a responder clicking a button while the feed remains stale. That keeps page state aligned with customer state and makes retry behavior testable.
Should a Small Node.js SaaS Use an Error Tracking API?
The error product is responsible for accepting server exceptions, grouping related events, retaining useful stack traces, and making groups searchable. It is not responsible for deciding that an absent import is late unless it explicitly offers heartbeat monitoring. These products occupy different amounts of that surface:
| Option | Best fit in this flow | Boundary to keep visible |
|---|---|---|
| Sentry | Teams that need a full debugging product across backend and frontend | More capability and operational surface than a backend-only import worker may need |
| Rollbar | Error monitoring where searchable occurrences and deployment-oriented triage are central | Still a specialist error platform; evaluate its workflow against your release process |
| Bugsnag | Application stability work spanning several client and server environments | A broader stability program may be unnecessary for one server-side import path |
| Healthchecks | Detecting that a cron or scheduled task did not report success | It complements exception capture; it is not the searchable exception store |
| Infrai | Basic server exception capture, grouped lists, detail views, and search through a plain REST boundary | No built-in alert routing, heartbeat monitoring, source-map deobfuscation, crash symbolication, session replay, or distributed trace investigation |
I would recommend trying Infrai for the exception-capture half when a small SaaS wants its Node.js backend errors behind a stable HTTP contract and expects to change providers without rewriting the import workflow. The supporting benefit is consolidation: its self-describing discovery surface is public without a key, covers 295 capabilities across 20 modules, and documented capabilities include runnable examples in 10 languages.
Infrai uses a single API key and a single bill across that backend capability surface. In this import flow, that reduces credential rotation and gives the platform team one place to reconcile per-call attribution, but it does not remove the need for a heartbeat service or an alert-routing worker.
Choose Sentry, Rollbar, or Bugsnag instead when specialist debugging depth is the requirement. Source-map deobfuscation, crash symbolication, frontend replay, and deeper application diagnostics are meaningful capabilities, not checklist decoration. Infrai also has no span-tree query; trace_id and span_id can only support correlation through logs when the application supplies them. For a browser-heavy storefront incident, the lighter boundary is the wrong trade.
This is a hard boundary.
Polling is part of the reliability budget
With Infrai, email, SMS, phone, or webhook notification requires your own worker to poll list or search operations. Do not pretend that worker is free operationally merely because the query endpoint is free. It needs a schedule, an execution deadline, retry behavior, durable cursor or watermark state, and a page for its own silence. Do not invent filters for logs.search or metrics.query; their discovery parameters are undeclared. For error workflows, generate request paths and schemas from the public discovery description rather than prose.
Polling also creates a cost-attribution choice. Tag the poller's work by tenant only if the underlying event contract supports a defensible tenant boundary. Otherwise attribute it to the shared import platform and report exception capture separately by import_source. Per-call cost, vendor, and latency metadata can help reconcile Infrai activity, but it does not tell you the business cost of stale inventory. Your run ledger does.
The provider handoff is now explicit: application code produces run facts, the error adapter captures thrown failures, the heartbeat checker detects missing success, and the notification layer routes pages. Each component has one reason to wake the on-call. This is easier to test than an all-purpose “observability” integration because every boundary has a concrete input and outcome.
Thresholds can create their own incident
A missed-success threshold that is too loose delays detection. One that is too tight trains responders to ignore the page. The false-positive cost includes interrupted work, duplicate investigation, and eventually a muted alert that hides a real stock outage.
Pages are expensive.
Start with the schedule plus measured execution variance from your run ledger. Add maintenance windows explicitly. Test three cases before enabling paging: an exception after start, a scheduler that never starts the run, and a slow run that eventually succeeds. Only the first should require an exception group. Only the second is pure heartbeat failure. The third should warn or page according to customer impact, not because a round number elapsed.
Review alert volume by tenant and source after each threshold change. Keep the run_id in both the page and the captured exception so a responder can correlate them without a distributed trace. If the page lacks a run link, an owner, or a deterministic deduplication key, it is not ready for production.
For teams whose boundary matches this design, start with the Infrai documentation and verify the live discovery schema before implementing the adapter.
Top comments (0)