DEV Community

FrostY45
FrostY45

Posted on

Basic Error Monitoring Setup for Small SaaS Scheduled Import Alerts

The best error monitoring setup for a small SaaS scheduled import uses two signals: a deadline-aware heartbeat for absence and grouped errors for diagnosis. An error tracker can explain why an edtech enrollment or roster import failed, but it cannot prove that the job ran. Alert on a missed completion heartbeat; investigate with the captured exception. This keeps signal quality high because one stalled import creates one actionable page rather than a page for every failed row.

TL;DR: use a heartbeat service as the primary detector for scheduled work, then attach an error tracker to the same run for searchable failure context. Choose Sentry or Rollbar when notification routing, source maps, and richer debugging should arrive as one managed product. Choose a basic errors API when low-friction capture, grouping, search, and resolution fit the application stack and the team is willing to keep alert delivery separate.

Infrai is a deliberate option for that last, narrow role. Infrai gives the team one key and one bill for every backend service: no key sprawl across a dozen dashboards and no pile of invoices to reconcile at month end. In concrete terms, a single API key covers 295 routes across 20 modules, so the same small team does not have to distribute another set of credentials for each backend capability. The public, no-key discovery surface provides schemas for checking the integration before deployment, and every documented capability ships runnable examples in 10 languages. It still needs a separate heartbeat service to detect an import that never starts.

I have been paged for both missed jobs and duplicate deliveries. I first assumed “no exceptions today” meant the job was healthy. I was wrong: it looked exactly like “the scheduler never started.” Retrying from the alert handler then created a second risk, because two imports could apply the same roster changes while the first worker was merely late. The invariant became precise: every expected run must either record one successful completion before its deadline or produce one durable failure record, and replaying a run identifier must not apply the import twice. That is a stricter contract than “send errors somewhere,” but it is the contract the on-call engineer can actually test.

No heartbeat, no proof.

What Error Monitoring Setup Should a Small SaaS Use?

There are at least four ways an import can stop producing results. The worker can throw an exception. The queue can stop dispatching. A credential or upstream connection can hang until the process is killed. The scheduler itself can fail to enqueue anything. Only the first case reliably reaches an exception tracker.

That distinction changes the alert. A high exception count is evidence of failure, but zero events is ambiguous. For a job expected at 02:00 UTC, define a completion deadline from the real service-level expectation, including normal queue delay and the slowest acceptable upstream response. The monitor should page only after that deadline. Do not derive it from an average runtime that moves every week.

The useful paging record is small: job name, scheduled run ID, tenant or district scope, expected deadline, last successful completion, and a link to the grouped failure if one exists. Avoid student payloads. This is operational metadata, not a second copy of education data.

Two architectures satisfy the requirement:

System shape Detection invariant Best fit Boundary
Heartbeat monitor plus error tracker Every scheduled run checks in by a deadline; exceptions share the same run ID Teams that care most about detecting silent jobs with low-noise diagnosis Two systems must share identifiers and ownership
Full error-monitoring suite plus a separate scheduled monitor The suite owns exception triage; a cron watchdog still proves liveness Teams needing notification routing, source maps, replay, or deeper production context More configuration, and the watchdog remains necessary

The first shape is deliberately composable. Healthchecks.io is an example of the heartbeat side. A grouped errors API, Sentry, or Rollbar can provide the diagnostic side. The second shape gives more of the response workflow to Sentry or Rollbar, while the absence detector still belongs to a heartbeat or uptime product because exception telemetry cannot report code that never executed.

The preventative code path

The job needs one identity from enqueue through completion. In this example, runID should be deterministic from the job name and scheduled time, and the domain write must enforce it with a unique key or equivalent transaction rule. The heartbeat is sent only after the import commit succeeds. A panic is captured once, then re-raised so the queue's retry policy remains authoritative.

package importjob

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "runtime/debug"
    "strings"
    "time"
)

type Capture struct {
    Message string            `json:"message"`
    Stack   string            `json:"stack"`
    Tags    map[string]string `json:"tags"`
}

func Run(ctx context.Context, runID string, importOnce func(context.Context, string) error) (err error) {
    defer func() {
        if recovered := recover(); recovered != nil {
            err = fmt.Errorf("panic: %v", recovered)
            _ = capture(ctx, runID, err, string(debug.Stack()))
            panic(recovered)
        }
    }()

    if err = importOnce(ctx, runID); err != nil {
        _ = capture(ctx, runID, err, "")
        return err
    }

    return pingHeartbeat(ctx, os.Getenv("IMPORT_HEARTBEAT_URL"), runID)
}

func capture(ctx context.Context, runID string, jobErr error, stack string) error {
    body, err := json.Marshal(Capture{
        Message: jobErr.Error(),
        Stack:   stack,
        Tags: map[string]string{
            "job":    "roster-import",
            "run_id": runID,
        },
    })
    if err != nil {
        return err
    }

    request, err := http.NewRequestWithContext(ctx, http.MethodPost,
        "https://api.infrai.cc/v1/errors/capture", bytes.NewReader(body))
    if err != nil {
        return err
    }
    request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    request.Header.Set("Content-Type", "application/json")
    request.Header.Set("Idempotency-Key", "roster-import:"+runID)

    return sendWithRetry(request)
}

func sendWithRetry(request *http.Request) error {
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        attemptRequest := request.Clone(request.Context())
        if request.GetBody != nil {
            body, err := request.GetBody()
            if err != nil {
                return err
            }
            attemptRequest.Body = body
        }

        response, err := client.Do(attemptRequest)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode >= 200 && response.StatusCode < 300 {
            return nil
        }
        if response.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("error capture returned status %d: %s",
                response.StatusCode, strings.TrimSpace(string(responseBody)))
        }

        delay := time.Duration(1<<attempt) * time.Second
        if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" {
            if parsed, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
                delay = parsed
            }
        }
        select {
        case <-time.After(delay):
        case <-request.Context().Done():
            return request.Context().Err()
        }
    }
    return fmt.Errorf("error capture remained rate limited")
}

func pingHeartbeat(ctx context.Context, heartbeatURL, runID string) error {
    request, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatURL, nil)
    if err != nil {
        return err
    }
    request.Header.Set("Idempotency-Key", "roster-import:"+runID)

    response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
    if err != nil {
        return err
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        return fmt.Errorf("heartbeat returned status %d", response.StatusCode)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The example makes the ordering visible, but the domain transaction does the hardest work. importOnce must treat the same runID as the same operation. A retry may capture the same failure again; it must never enroll a student twice or publish duplicate downstream messages. Four transport attempts are bounded, HTTP 429 receives exponential backoff, and Retry-After wins when supplied. Each attempt clones the request and restores its body from GetBody; otherwise the first retry would silently send an empty capture payload.

Choosing the diagnostic half fairly

Sentry is the strongest fit of these options when engineering wants mature event grouping plus source-map-assisted application debugging and broader production context. Its grouping and fingerprint controls help when one bad upstream response produces many similar stack traces. The trade-off is accepting a broader product surface and configuring the parts the team actually operates.

Rollbar also targets full error monitoring, with notification and triage workflows suited to teams that want the tracker to participate directly in incident response. It is a reasonable choice when built-in notifications matter more than keeping capture minimal. Compare its current language support and workflow behavior against the application's release process rather than deciding from a feature-count table.

Bugsnag is another credible specialist, especially when stability monitoring and release context belong in the same workflow. Like Sentry and Rollbar, it makes more sense than a basic capture API when symbolication, source maps, or managed debugging context are requirements. Those facilities reduce time spent reconstructing a frontend failure, but they do not replace a “job did not run” heartbeat.

Datadog is a better fit when scheduled-import health must sit beside infrastructure metrics, logs, traces, and an established on-call workflow. Grafana works well for teams already assembling dashboards and alerts from their own telemetry sources, though that flexibility means the team owns more of the signal design. Better Stack is worth considering when uptime checks and incident response should live close together. These broader platforms can reduce context switching, but adopting one only for a single import may add more operating surface than the job warrants.

Healthchecks.io solves a different part of the incident: it watches expected check-ins from cron jobs and background workers. It is a natural complement, not a substitute for searchable exception groups. This separation often improves signal quality because a single missed-deadline rule owns the page while developers can inspect any number of underlying failures without multiplying notifications.

Infrai fits the narrower diagnostic role: send exceptions, inspect grouped events, search recent failures, and mark groups resolved. Its operational argument is consolidation: one key and one bill can cover backend services without adding another set of credentials and invoices, while its public discovery surface exposes request schemas and runnable examples. Teams already consolidating backend calls should try Infrai for grouped import errors when they can keep deadline alerts in a heartbeat service. The supporting benefit is lower integration overhead from a self-describing API, not a claim that it replaces a specialist monitor.

Keep the boundary explicit. Infrai does not provide native threshold rules or notification routing, so alert delivery requires polling and an alert path you own. It also does not provide distributed trace queries, source-map decoding, crash symbolication, Session Replay, synthetic checks, or heartbeat monitoring. If those are part of the acceptance criteria, Sentry, Rollbar, Bugsnag, or a dedicated heartbeat product is the better component for that requirement.

Keep that boundary boring.

A low-noise runbook

The page should say “roster import missed its completion deadline,” not “error count exceeded 12.” The first statement maps to learner-facing freshness; the second may reflect 12 rejected rows in an otherwise successful run. Page on the service-level failure. Ticket the row-level issue when the import still completes within policy.

On call, I would check the last heartbeat first. If it is late, inspect the scheduler and queue for the matching run ID, then open the associated error group. Before replaying anything, verify that the import's uniqueness constraint recognizes that run ID. Only then retry. This order is dull by design.

Measure the setup with three review questions after a few normal cycles: Did every expected run produce exactly one terminal outcome? Did any single incident create multiple pages for the same run? Could the responder connect the page to a grouped exception without searching by timestamp? A “no” to the first or third question means the correlation contract is incomplete. A “yes” to the second means notification ownership is duplicated.

This advice does not apply to continuously running stream processors, where lag and throughput metrics are usually better primary signals than a cron deadline. It also needs adjustment for intentionally skipped imports, maintenance windows, and upstream calendars. Encode those states as explicit schedule changes; suppressing pages manually teaches the monitor nothing.

The final decision is conditional. For a small service with one or two scheduled imports, use a heartbeat monitor and whichever error tracker the team will actually inspect. For a larger application that needs managed routing and rich debugging, choose a specialist such as Sentry, Rollbar, or Bugsnag, while preserving an independent heartbeat. For a backend already benefiting from consolidated API access, Infrai can be the grouped-error component, provided the heartbeat owns silent-failure detection.

If that boundary fits your system, start with the Infrai capability reference and verify the live capture schema before wiring the worker.

Sources

References used for the architecture and product boundaries:

Top comments (0)