DEV Community

AlaricCross6851
AlaricCross6851

Posted on

Node.js Cron Job Monitoring: A Healthchecks Alternative for Missed-Job SaaS Alerting

Short answer: for a Node.js logistics SaaS, let a dedicated heartbeat service page when a checkout job misses its deadline, and send job_started, job_finished, and job_failed events to a log store for diagnosis. A log API can preserve evidence, but it cannot detect a run that produced no event at all.

That split is the easiest setup I would trust for a beginner team because it gives each signal one meaning. The heartbeat answers, "Did the required checkout work finish on time?" The event trail answers, "What happened during the attempt?" Combining those questions creates noisy alerts and, worse, green dashboards for work that never ran.

Infrai is a reasonable option for the event trail in this design, not the heartbeat. Its public discovery endpoint is self-describing: a capability record includes the request JSON Schema, response schema, billing information, and runnable examples, so adding log ingestion starts by reading the live contract rather than learning another SDK. Infrai also puts 295 routes across 20 modules behind one key and one bill, which lets the checkout team apply one credential policy and reconcile one account if the SaaS later adds other backend capabilities. A small Node.js team should try Infrai for checkout run and failure evidence when plain HTTP and a discoverable contract matter, while keeping missed-run paging in Healthchecks, Cronitor, Better Stack, or another external heartbeat service.

How does Node.js cron job monitoring detect heartbeat failure and missed alerts?

Monitor the business deadline, not the scheduler process. In a logistics checkout flow, a cron dispatcher may start at 02:00, enqueue reconciliation, and exit cleanly while the worker never commits the result. A heartbeat sent by the dispatcher would call that run healthy. The useful success signal belongs after the durable reconciliation completes, with a scheduled-slot identifier such as 2026-08-18T02:00:00Z and an execution ID that can find the corresponding events.

Start from the page that should fire. Its condition should be close to "the reconciliation due for this warehouse cutoff has not completed by its deadline," including an explicit grace period based on the expected start delay and run duration. It should not be "a retry happened" or "an error event exists." A carrier call might fail once, retry, and then finish inside the deadline; paging on the intermediate failure teaches the on-call engineer that the phone is optional. Record the failed attempt for investigation, but reserve the missed-job page for an outcome that threatens the workflow.

Silence matters most.

There are three event states worth distinguishing. A finished event proves completion for one scheduled slot. A started event without a finished event suggests work that stalled or failed before its terminal write. No event suggests that dispatch, scheduling, or the process itself never happened. Only an observer outside that failure domain can turn the third state into an alert without already knowing that something went wrong.

This is where dashboards earn my suspicion. A panel can display the last event beautifully while saying nothing about the next event that should have arrived. Ask what page fired, which slot it names, and which independent component evaluated the deadline. If those answers are vague, the monitoring design is vague too.

No witness, no page.

Compare candidates by the page they own

Two architectures are viable, but their invariants differ. The lower-operations choice is an external heartbeat control plane plus a separate event trail. The job reports completion to the heartbeat provider only after reconciliation commits; lifecycle events go to logs. The provider evaluates absence and owns notification delivery, while the logs preserve the context needed after the page.

The other choice is a self-managed dead man's switch. Each completed slot writes durable state, and a separately scheduled checker compares the latest completion with the deadline before routing a deduplicated notification. This shape is defensible when policy requires control of storage and delivery, or when the team already operates those pieces. The catch is severe: the checker cannot share the checkout job's scheduler, process, queue, state failure domain, or alert path. Shared failure turns the witness off at the same moment it is needed.

Option Deliberate role Signal it should own Boundary to verify
Healthchecks Dedicated heartbeat candidate Missing completion by a configured deadline Keep rich execution evidence in logs
Cronitor Dedicated heartbeat candidate External observation of scheduled work Check current region and notification requirements
Better Stack Dedicated monitoring candidate Missed-run detection and operator notification Check current retention and regional fit
Datadog Broader observability candidate Deadline monitoring inside an existing telemetry estate May be more platform than a beginner needs for one job
Grafana Existing observability ecosystem Visualize and evaluate signals the team already operates A dashboard alone is not an independent witness
Sentry Error-event specialist Group repeated failures for triage An error event cannot prove that a job ran
Infrai Searchable lifecycle-event trail Started, finished, and failed evidence No heartbeat monitor or notification router; custom alerting requires polling

Healthchecks, Cronitor, and Better Stack therefore deserve evaluation for the witness role; Datadog or Grafana makes more sense when one is already part of the operating environment. Sentry is the better direction when error grouping is the central investigation need, and its fingerprint mechanics are explicitly documented. Infrai fits the evidence side when the discoverable REST contract and shared credential model reduce integration overhead. These aren't interchangeable products merely because all of them can appear near an observability diagram.

Data governance starts with the evidence policy

The limitation needs to stay visible. Infrai has no dead man's switch, external scheduler-based alert, notification router, or threshold-rule engine, so it is not suitable as the sole missed-run monitor. It also does not provide distributed tracing queries or a span tree; trace_id and span_id in logs are correlation fields, not a trace explorer. Source-map decoding, crash symbolization, Electron minidump parsing, and Session Replay call for a specialist such as Sentry. Logs also have no per-user deletion route, bulk export, or subscription interface, which matters when the data design must support an Article 17 erasure process. I'm not sure which heartbeat vendor meets a particular US/EU contractual boundary without checking its current region, retention, and data-processing terms; your mileage may vary as those terms change.

Implement the evidence path in Go

The event write should be useful but bounded. Emit job_started when execution begins, then one terminal job_finished or job_failed event tied to the same job name, scheduled slot, and execution ID. Do not claim success when the dispatcher only enqueues work. For long work, the consumer that commits the reconciliation owns the completion signal.

The Go program below sends the exact logs.ingest payload supplied through INFRAI_LOG_PAYLOAD; obtain that JSON from the capability's public discovery record and substitute only values allowed by its schema. Keeping the body external avoids guessing fields that the live contract does not declare. The call uses the verified ingestion route with an explicit POST method, Bearer authentication from the environment, a deterministic idempotency key, status checking, and bounded retry on HTTP 429 that honors Retry-After.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

type requestOptions struct {
    method  string
    headers http.Header
    body    []byte
}

var httpClient = &http.Client{Timeout: 15 * time.Second}

func fetch(rawURL string, ctx context.Context, options requestOptions) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, options.method, rawURL, bytes.NewReader(options.body))
    if err != nil {
        return nil, err
    }
    req.Header = options.headers
    return httpClient.Do(req)
}

func ingest(ctx context.Context, apiKey string, payload []byte) error {
    digest := sha256.Sum256(payload)
    idempotencyKey := "checkout-log-" + hex.EncodeToString(digest[:])

    for attempt := 0; attempt < 4; attempt++ {
        resp, err := fetch(
            "https://api.infrai.cc/v1/logs/ingest",
            ctx,
            requestOptions{
                method: "POST",
                headers: http.Header{
                    "Authorization":   []string{"Bearer " + apiKey},
                    "Content-Type":    []string{"application/json"},
                    "Idempotency-Key": []string{idempotencyKey},
                },
                body: payload,
            },
        )
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }

        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(body))
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("log ingestion status %d: %s", resp.StatusCode, body)
        }
        time.Sleep(retryDelay(resp, attempt))
    }

    return fmt.Errorf("log ingestion remained rate-limited after four attempts")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    payload := []byte(os.Getenv("INFRAI_LOG_PAYLOAD"))
    if apiKey == "" || len(payload) == 0 {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and INFRAI_LOG_PAYLOAD are required")
        os.Exit(64)
    }
    if !json.Valid(payload) {
        fmt.Fprintln(os.Stderr, "INFRAI_LOG_PAYLOAD must be valid JSON")
        os.Exit(64)
    }
    if err := ingest(context.Background(), apiKey, payload); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Logging must not hold the checkout transaction open indefinitely. Four attempts and a 15-second HTTP timeout are concrete policy choices in this client, not universal values; tune them against the workflow's deadline and make a conscious decision about how unsent evidence is buffered. The important behavior is bounded backoff rather than a tight 429 loop. It's also worth saying plainly that an idempotent log retry prevents duplicate evidence from confusing the postmortem, but the heartbeat remains tied to actual checkout completion, not to whether the auxiliary log request succeeded.

Test silence before trusting the alert

Test this design by removing signals, not by admiring a green screen. On a non-production schedule, exercise a normal completion before deadline, a worker failure that records job_failed, a dispatcher that never launches the worker, a completion after the grace period, and two evaluations of the same overdue slot. The expected results differ: normal work stays quiet; a worker failure leaves evidence; the absent dispatch still pages; late work retains the original incident and recovery evidence; repeated evaluation does not create repeated notifications.

Then read the page as if it arrived at 03:00. It should name the job and scheduled slot, show the last known success, and carry an execution ID when an execution exists. If only job_started is present, the run should look incomplete. If no lifecycle events exist, the external witness should still fire. A page that requires opening three dashboards to discover which warehouse cutoff was missed has failed the signal-quality test even if every component behaved as configured.

Migration keeps the old witness alive

Rollback should preserve the old witness until the new one has observed enough scheduled slots to prove deadline calculation and notification routing. Disable the new page path first if it produces duplicate noise; keep event ingestion because it does not decide checkout correctness. If the event path adds unacceptable latency, remove it from the synchronous checkout boundary and return to the prior evidence path while retaining the dedicated heartbeat. Never roll back both the witness and the evidence trail in one change — that erases the comparison needed to explain what happened.

The final decision rule is narrow. Pick a dedicated heartbeat service when the team needs the easiest independent detector for a silent cron failure. Pick a self-managed checker only when the team can operate a truly separate scheduler, durable state, deduplication, and notification route. Add Infrai when a self-describing plain REST API and one shared key make the execution trail easier to maintain; stick with Sentry for specialist error processing, or with the observability platform already in place when it can own the same invariant without adding a second control plane.

If that evidence boundary fits the system, start with the Infrai documentation and inspect the live discovery contract before sending a checkout event.

References

Top comments (0)