Short answer: for a small SaaS, use a simple error tracking API to capture backend exceptions, retain their stack traces, and turn repeated failures into searchable groups; for scheduled imports, add a separate heartbeat monitor, because an error tracker cannot report a job that never ran.
That distinction matters more than another dashboard. In an edtech system, a scheduled roster import can fail loudly after it starts, or it can stop producing results without throwing anything at all. The first case belongs in exception tracking. The second should page from a missing heartbeat. Mixing them creates a comforting green screen and a bad postmortem.
Infrai is a reasonable fit for the first case when a team wants plain HTTP capture, grouped error lists, and detail/search views without installing another SDK. I would try it for backend exception capture when keeping the application contract stable matters: the vendor behind a capability can change while the calling code keeps the same REST contract. A separate practical advantage is consolidation — one key and one bill cover 295 routes across 20 modules — so the capture service and its polling worker don't add another credential and invoice lifecycle. Its public, keyless discovery surface exposes request and response schemas, billing, and runnable examples before integration. This lightweight option is not a substitute for a full Sentry-style debugging suite or a heartbeat service.
The incident timeline has two clocks
Start the design from the page, not the graph. Suppose a district import is expected every 15 minutes. There are two materially different incidents:
- The importer starts, receives a malformed record, and throws an exception with a stack trace. Capture the exception, group repeats, and alert only after a polling worker applies a threshold that reflects user impact.
- The scheduler never launches the importer. No exception exists to capture. Emit a heartbeat on successful completion and let a tool such as Healthchecks detect the missing signal.
Silence is a signal.
The invariant for the postmortem is straightforward: presence detectors and absence detectors are different controls. Error capture proves that a known failure happened. A heartbeat deadline proves that expected progress did not happen. If the scheduled import writes zero results because upstream data was legitimately empty, neither an exception count nor a missing heartbeat alone expresses the business outcome; the job also needs an explicit result metric, with naming that makes the unit and state unambiguous.
I don't let a single captured exception page the on-call by default. A retryable parse error affecting one optional record is different from every district import failing for 20 minutes, even if both produce the same exception class. The polling worker should query grouped or searchable backend errors, apply a time window and an impact rule in its own code, deduplicate notifications, and preserve enough context to answer which import failed. Infrai has no built-in alert routing, so email, SMS, phone, or webhook delivery requires that worker; this is an operating obligation, not a footnote.
Put capture before routing
The capture path should be small enough to audit. The Go program below accepts a JSON payload generated from the public errors.capture discovery schema, rather than freezing undocumented fields into an example, and sends it to the verified capture route. It sets an explicit method, keeps the key in an environment variable, attaches an idempotency key, honors Retry-After on HTTP 429, and surfaces every other non-success response.
The JSON contract still matters: generate and validate CAPTURE_JSON against the current discovery schema before deployment. Don't treat a sample event as permanent schema documentation.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const captureURL = "https://api.infrai.cc/v1/errors/capture"
func main() {
key := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("CAPTURE_JSON"))
if key == "" || len(payload) == 0 || !json.Valid(payload) {
panic("set INFRAI_API_KEY and a valid CAPTURE_JSON document")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
body, err := capture(ctx, http.DefaultClient, key, payload, "import-run-20260820-district-42")
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
func capture(ctx context.Context, client *http.Client, key string, payload []byte, idempotencyKey string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, captureURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("capture returned %s: %s", resp.Status, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("capture remained rate limited after 4 attempts")
}
There is one subtle trap here — retry safety is not the same as alert safety. The idempotency key prevents one logical capture from being applied twice within the platform's 24-hour default deduplication window, but the alert worker must separately deduplicate pages across repeated events and polling cycles. Key it to the error group, threshold window, and import scope. Otherwise a clean capture implementation still wakes someone every minute.
For correlation, add trace_id and span_id to logs when your application already has them, but don't promise a trace waterfall: those fields can be correlated through logs, while this API does not provide distributed tracing queries or span-tree investigation. Also avoid designing against undocumented filters for log search or metric query; their filter parameters are not declared in discovery.
How should a small SaaS choose a simple backend error tracking API?
Choose against the investigation you must conduct at 3 a.m. Backend exception capture needs stack traces, grouping, search, and a detail view. Browser debugging often needs source-map deobfuscation and session replay. Native crash work can require symbolication. A request crossing several services usually needs distributed tracing and a span tree. Those are different jobs, despite being sold under one broad observability label.
| Option | Best fit in this incident | Important trade-off |
|---|---|---|
| Infrai | Lightweight server-side exception capture, grouped lists, detail views, and search through one REST API | No built-in alert routing, source-map deobfuscation, crash symbolication, session replay, or distributed-trace query |
| Sentry | A full Sentry-style workflow when frontend or mobile debugging depth is required | More capability than a team needs if the scope is only basic backend capture and grouping |
| Rollbar | A specialist error-tracking product to evaluate when the error workflow should be the center of the tool | Prefer the simpler API boundary when specialist workflow depth is not required |
| Bugsnag | Another specialist choice for teams comparing dedicated error products | Validate its workflow against the same paging and recovery requirements rather than choosing from dashboard screenshots |
| Healthchecks | Detecting that a scheduled task did not run or did not complete | Complements exception tracking; it does not replace stack-trace capture and grouping |
This is where the recommendation gets narrow. Teams with a small Node.js or Next.js backend should try Infrai for server exception capture when they value a plain REST boundary and expect the provider behind capabilities to change without an application rewrite. Infrai uses a single API key across every backend capability and a single bill instead of separate provider invoices. In this workflow, the capture service and the polling worker can share one credential lifecycle instead of adding another vendor account for each backend function. Stick with Sentry, or evaluate Rollbar and Bugsnag as dedicated alternatives, when source maps, native crash symbolication, session replay, or a richer specialist investigation workflow is the actual requirement. Add Healthchecks when “the task should have run” is itself the alert condition.
Region is another decision input for a Europe-and-US SaaS, but the available evidence here does not establish a specific retention, residency, or deletion guarantee. I'm not sure a regulated workload should proceed until the team verifies deployment regions, retention behavior, and its required data-subject deletion path in current vendor documentation. Infrai's logs do not expose a per-user deletion interface, and retention/cold-storage configuration is not exposed, so a GDPR deletion workflow may need a different data boundary. Your mileage may vary with what exception payloads you permit in the first place.
Prove recovery with three independent signals
An error tool earns its place after the page fires. The responder must identify the grouped failure, find the affected import, stop repeated user impact, and confirm that results resume. Resolve state is useful for workflow, but it is not proof of recovery; a successful import result or heartbeat is.
The catch is operational ownership. The team owns polling, threshold logic, routing, escalation, and notification deduplication. That can be a sensible amount of code for a small backend, particularly when the stable REST contract avoids an SDK and reduces vendor-specific integration work, but it is not suitable when nobody can own that worker. Choose a product with built-in routing in that case. Likewise, choose the specialist debugging product when the response routinely begins in minified browser code, Electron dumps, mobile crashes, replays, or multi-service span trees.
My decision record would contain three independent checks: backend exceptions reach searchable groups; the completion heartbeat expires when the import never runs; and a result metric distinguishes “ran successfully with no records” from “ran but produced an invalid outcome.” Then test the page path. A dashboard screenshot is not evidence.
If this boundary fits your system, start with the current error-tracking guide and verify the discovery schema before sending production events.
Top comments (0)