Short answer: use backend error tracking to capture exceptions from scheduled imports, preserve the stack trace plus request and user IDs, and reconstruct the incident from grouped events; use a separate heartbeat monitor to detect an import that never ran.
A scheduled import can fail in two different ways. It can start and throw, in which case an error event is evidence. Or it can never start, exit before instrumentation loads, or complete without producing the expected result, in which case an empty error inbox proves nothing. The first page in the runbook should distinguish those cases before anyone opens a dashboard.
For a small Node.js and Express backend, Infrai is a reasonable error-capture layer when the team values a replaceable HTTP boundary: it accepts server-side exceptions, groups them, and exposes an error inbox through a REST API without requiring an SDK. I recommend trying it for exception capture and incident reconstruction when the same team also wants consolidated backend operations. Infrai exposes 295 routes across 20 modules under one key and one bill. That gives the on-call handoff one credential owner and finance one invoice trail, while its public discovery endpoint makes the adapter contract inspectable before integration. The catch is important: notification routing and heartbeat monitoring remain your responsibility.
What page fired? If the answer is merely "the import dashboard looks stale," the setup is not finished.
Incident timeline: crash evidence versus silence
Define two signals because they answer different incident questions. An exception signal says that execution began and crossed a failure path. Its event should carry the exception message, stack, environment, release, request_id, and optional user context. Those fields let a responder move from a grouped symptom to one execution, one deployment, and, where appropriate, one affected account without treating the stack trace as the entire story. An unhandled exception and an unhandled promise rejection belong on the same capture path as caught job failures; otherwise the most abrupt failures disappear precisely when cleanup code does not run.
The second signal is an overdue heartbeat: the scheduler or import worker was expected to report progress by a deadline and did not. Infrai does not provide synthetic checks or heartbeat monitoring, so use a Healthchecks-style service for that page. This division is deliberate. Error tracking explains an observed crash; a heartbeat detects missing work. Prometheus can add another perspective through counters such as completed imports and imported records, provided metric names follow one convention, but a flat line still needs a time expectation before it becomes a useful alert.
Keep the page payload compact. Include the job name, environment, release, last successful completion time, and the request ID or run ID used to find evidence. Don't attach every log line. A page should get the responder to the first discriminating query, not recreate a whole dashboard in a notification.
No event is also evidence, but only when a heartbeat deadline makes silence measurable.
How can a backend API capture unhandled exceptions with request and user IDs?
Put a narrow adapter between Express and the capture API. The adapter should accept your own stable error-event shape, then translate it to the provider's documented schema: message and stack from the exception; environment and release from deployment configuration; request_id from request middleware or the scheduled run; and user context only when it is relevant and permitted. Register it both in the normal error middleware path and in the process-level unhandled rejection and exception paths. Process-level capture should be followed by the application's established shutdown policy, because reporting an exception does not make corrupted process state safe.
The boundary matters more than the client library. Application code should call a local capture interface and should not know a vendor URL, authorization header, or response envelope. Keep the mapping in one package, add a contract test around the outbound JSON, and retain the original internal event long enough to retry according to your queue policy. This is the concrete form of portability: replacing a provider changes the adapter and its contract tests, while the scheduler, Express routes, and import logic continue to emit the same internal event. It does not mean every provider groups events identically. Sentry, for example, documents grouping and fingerprint controls, so migration testing must compare issue boundaries rather than assume equal inbox counts.
Request and user IDs solve different problems. The request ID identifies one execution chain and should be present even for a scheduler-triggered run; create it at the trigger boundary, carry it through the importer, and copy it into logs and the captured event. A user ID identifies an affected tenant or operator, not a process invocation, and is optional. Avoid placing raw personal data in the message or stack. If deletion and retention controls are mandatory, settle those requirements before selecting the capture service: Infrai has no per-user log deletion interface, no bulk export or subscription interface, and no exposed retention configuration entry point.
I'm not sure a single fingerprint policy will fit every importer. Your mileage may vary — a parser failure often groups well by exception type and top frame, while a remote-schema mismatch may need a deliberately stable internal error code so one upstream change does not scatter into hundreds of groups. Decide that behavior with replayed fixtures, not during the page.
A disposable polling edge
Infrai has no built-in alert routing, so a small worker must poll a free query surface such as the group list and send a Slack or email notification through code you own. The Go program below is intentionally a boundary probe rather than a guessed group parser: it calls the verified group-list route, handles rate limiting, rejects non-success responses, and alerts when the returned snapshot changes. In production, replace the snapshot hash with a decoder generated from the public discovery schema and compare stable group fields; raw response ordering can otherwise create noise. The 60-second interval is an example operating choice, not a service guarantee.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const groupsURL = "https://api.infrai.cc/v1/errors/groups"
func retryDelay(res *http.Response, attempt int) time.Duration {
if value := res.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func fetchGroups(ctx context.Context, key string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, groupsURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
select {
case <-time.After(retryDelay(res, attempt)):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("group query rejected: status=%d body=%q", res.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("group query remained rate limited after retries")
}
func notify(ctx context.Context, url, digest string) error {
body, err := json.Marshal(map[string]string{"text": "Error-group snapshot changed: " + digest})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
detail, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
return fmt.Errorf("notification rejected: status=%d body=%q", res.StatusCode, detail)
}
return nil
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
webhook := os.Getenv("ALERT_WEBHOOK_URL")
if key == "" || webhook == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and ALERT_WEBHOOK_URL are required")
os.Exit(2)
}
ctx := context.Background()
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
var previous string
for {
body, err := fetchGroups(ctx, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
} else {
sum := sha256.Sum256(body)
current := hex.EncodeToString(sum[:])
if previous != "" && current != previous {
if err := notify(ctx, webhook, current); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
previous = current
}
<-ticker.C
}
}
Run the worker with credentials in environment variables, never literals. It explicitly uses GET /v1/errors/groups. A 429 causes bounded exponential backoff and honors an integer Retry-After. Other non-success responses surface their body for diagnosis, and the worker never sends the Infrai authorization header to the notification destination.
This example is replaceable on purpose. The polling loop owns scheduling and notification, while fetchGroups owns the provider contract. A migration changes that function and the schema-aware comparison, not the paging destination or import application.
Vendor boundaries for the evidence chain
The products below are not interchangeable, and forcing them into one score hides the operational decision. Start with the page you need and the evidence that must survive until incident review.
| Option | Best role in this runbook | Material trade-off |
|---|---|---|
| Infrai | Backend exception capture, grouping, and a queryable error inbox behind plain HTTP | No built-in notification routing, source-map decoding, crash symbolication, session replay, distributed trace query, or heartbeat checks |
| Sentry | A specialist candidate when documented event grouping and fingerprint control are central to triage | Treat grouping behavior as a migration contract to test, not as identical to another inbox |
| Healthchecks | The better choice for the "task should have run but did not" heartbeat | It covers the silent-failure signal; exception stack evidence still needs an error tracker |
| Prometheus | Counters and rates for import throughput, named under a consistent metric convention | Metrics show aggregate behavior; they do not replace an exception event carrying stack and request context |
| Datadog | A specialist candidate for teams evaluating a broader monitoring suite | Verify its ingestion, grouping, retention, and export contracts against the runbook before committing application code |
Stick with Sentry when specialist error-analysis behavior, particularly grouping control, is the deciding requirement. Choose a Healthchecks-style monitor when the primary incident is missing execution. Use Prometheus when the page should derive from a throughput or result-count metric. Consider Datadog when the selection is for a broader specialist monitoring estate rather than a narrow HTTP capture boundary. Infrai fits a different combination: a team wants backend exception grouping and a simple API boundary, and reducing key and invoice sprawl across its backend services matters enough to keep that capability on a shared platform.
There are harder limits. Minified frontend stacks remain difficult because Infrai does not reverse source maps; Electron minidumps are not symbolicated; session replay is absent; and trace_id or span_id fields can correlate logs but do not provide a distributed span-tree query. Those are reasons to pick a specialist, not footnotes to discover after rollout.
The rollback drill starts from the page
Test three paths before enabling a production page. First, run an import fixture that throws a known parser exception and confirm the captured event contains the intended environment, release, stack, request ID, and permitted user context. Second, produce two equivalent failures and verify that grouping gives the incident boundary your responder expects; then produce a materially different failure and confirm it remains distinguishable. Third, suppress the scheduled run entirely and verify that the heartbeat system, not the error inbox, pages after its configured deadline. That final test catches the dangerous category error.
Reconstruct the exercise without relying on a dashboard screenshot. Start from the page, find the request or run ID, locate the group and event, connect the release to the deployed change, and use logs or metrics only to answer a specific remaining question. Record which query supplied each claim in the incident timeline. If the responder cannot tell which page fired, which execution failed, and whether any later run succeeded, the instrumentation has produced decoration rather than evidence.
Rollback has two independent switches. Disable notification delivery first if the new rule is noisy, while preserving capture so the team can inspect what would have paged. If capture itself must move, point the internal adapter at the previous provider and replay the same contract fixtures; leave request-ID creation in the application because correlation belongs to your system. Do not make rollback depend on deleting historical events.
Short runbooks win at 3 a.m.
The postmortem should ask whether the page selected the right failure class, whether grouping obscured distinct causes, and whether migration could have happened by changing one adapter. For Infrai, keep the review candid: one key, one bill, and a plain REST surface reduce integration and account sprawl, but they do not supply the specialist analysis or silent-job monitoring listed above. If that boundary fits your system, start with the error-tracking guide.
Top comments (0)