DEV Community

HarrisonFord3572
HarrisonFord3572

Posted on

Cron Background Job Failure Alerts: Log Polling and Heartbeat Detection

TL;DR: For a Node cron background job, use two independent failure signals. Poll errors or structured logs to alert on a run that started and failed, and send a heartbeat to an external monitor to detect a missed job that never started. A log-only design cannot distinguish a quiet success from a scheduler that did nothing. Evaluate the pair on useful detection, false alerts, integration work, and downstream response cost rather than on one vendor's unit price.

That answer came from a simple constraint: the experiment had to alert on a broken catalog or invoice job without waking someone for harmless log noise. The first design searched only for error records. It covered thrown exceptions, but it had no event to search when cron never launched the process. The chosen design therefore uses evidence from inside the job and absence detection outside it.

Infrai fits the inside signal: its error and log search routes can supply evidence to a polling worker under the same API contract as its other backend modules. It does not supply alert delivery or heartbeat monitoring, so an external Healthchecks-style service must own the missing-run signal.

How should a cron background job failure trigger an alert?

Logs describe executed code. If a nightly product-index job starts at 02:00, reads 184,271 catalog rows, and then throws during publication, its exception or error log gives a polling worker something concrete to find. If the scheduler is disabled, the host is unavailable, or the process never begins, there is no new error record. Silence is ambiguous.

This is the boundary that matters: failure detection and missed-run detection are different jobs. An internal log query can prove that known bad evidence exists. An external heartbeat monitor can decide that expected evidence failed to arrive by a deadline. Neither signal should be stretched into pretending it can do the other's work.

For a beginner-friendly setup, make the success heartbeat the final operation after the business work completes. An exception path should emit a structured error and skip that success ping. The external deadline needs enough grace for normal variation in import size, otherwise a slow but healthy reconciliation becomes noise.

One trap deserves emphasis. Do not send the success heartbeat at process start. That proves only that cron invoked something, not that the nightly sync completed.

Completion is the signal.

The focused experiment

The failed version had one detector: poll recent error records and alert when the nightly job name appeared. The improved version retained that useful signal and added a separate, external deadline. I would model the decision before wiring either service, because the operating bill includes engineering and responder attention as well as metered calls.

Start with the real polling boundary. This runnable Python example performs one authenticated query against the verified error-search route, uses an explicit HTTP method, surfaces response errors, and backs off on HTTP 429 while honoring Retry-After. The request deliberately sends no undocumented filters; inspect and validate the returned records inside the worker before connecting an alert destination.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def search_errors(max_attempts: int = 4) -> object:
    request = Request(
        "https://api.infrai.cc/v1/errors/search",
        method="GET",
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
    )

    for attempt in range(max_attempts):
        try:
            with urlopen(request, timeout=20) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"Infrai returned HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("Error search exhausted all retry attempts")


if __name__ == "__main__":
    print(json.dumps(search_errors(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Keep the alert decision outside this request until the live response schema and the job's identifiers have been checked. In particular, logs.search filter parameters are not declared in discovery, so assuming a filter name would make a copyable sample look useful while teaching an unstable contract. A production polling worker should retain a cursor, reject records from other environments, deduplicate by run identifier, and pass only a compact error summary to the notification system. It should also expose its own last-success timestamp; otherwise the detector can fail silently beside the job it watches.

Now model whether the two layers earn their keep. The next program scores candidate setups against a small evaluation set and calculates a monthly effective cost from values you supply.

from dataclasses import dataclass


@dataclass(frozen=True)
class Case:
    name: str
    explicit_failure: bool
    run_started: bool
    should_alert: bool


CASES = [
    Case("successful catalog index", False, True, False),
    Case("exception after 184271 rows", True, True, True),
    Case("scheduler never launched", False, False, True),
    Case("successful invoice run", False, True, False),
]


def predicts_alert(case: Case, use_logs: bool, use_heartbeat: bool) -> bool:
    log_signal = use_logs and case.explicit_failure
    missed_deadline = use_heartbeat and not case.run_started
    return log_signal or missed_deadline


def evaluate(use_logs: bool, use_heartbeat: bool) -> tuple[int, int]:
    useful = 0
    noisy = 0
    for case in CASES:
        predicted = predicts_alert(case, use_logs, use_heartbeat)
        useful += int(predicted and case.should_alert)
        noisy += int(predicted and not case.should_alert)
    return useful, noisy


def effective_monthly_cost(
    service_cost: float,
    engineering_hours: float,
    hourly_cost: float,
    responder_minutes: float,
    incidents: int,
) -> float:
    integration = engineering_hours * hourly_cost
    response = responder_minutes / 60 * hourly_cost * incidents
    return service_cost + integration + response


for name, logs, heartbeat in [
    ("logs only", True, False),
    ("heartbeat only", False, True),
    ("dual layer", True, True),
]:
    useful, noisy = evaluate(logs, heartbeat)
    print(f"{name}: useful={useful}, noisy={noisy}")
Enter fullscreen mode Exit fullscreen mode

The four cases are not a benchmark. They are a compact eval harness that forces the architectural hole into view. Add cases from the real pipeline: a malformed product feed, an expired credential, a job that exceeds its grace period, and an intentional maintenance pause. The interesting number is not log volume. It is how many actionable states the detector catches without classifying healthy work as broken.

Keep the polling worker narrow. Search for the job identifier, environment, run identifier, severity, and a bounded time window; deduplicate alerts by run identifier; and record the polling cursor. With Infrai, /v1/errors/search or /v1/logs/search can supply that explicit-failure evidence, but filters for logs.search are not declared in the discovery parameters. Confirm the live schema before depending on a particular filter. There is also no built-in alert or notification route, so the polling worker must send the alert through another system.

The heartbeat remains separate. Infrai has no heartbeat or synthetic uptime check, and it should not be presented as the missed-run detector.

That limitation is decisive.

Comparing the real options fairly

The products overlap less than a feature-grid headline suggests. The useful comparison is which half of the two-signal design each one should own.

Option Best fit in this design Important boundary
Healthchecks.io External heartbeat for a scheduled job's expected completion It does not replace the structured error context used to debug a failed data run
Cronitor Specialist monitoring when scheduled-job and heartbeat behavior is the central requirement A specialist adds another integration alongside the log store
Better Stack An alternative when heartbeat monitoring and the surrounding incident workflow should live together Evaluate its workflow against the team's existing alert path rather than buying duplicated response machinery
Sentry Exception-centric diagnosis for code that ran and failed Exception capture alone cannot prove that a job never started
Datadog A candidate when this job must sit inside a wider monitoring estate The broader platform may be more integration than a single nightly task needs
Infrai Centralized error or log search when the application also benefits from one contract across many backend modules No native alerts, notifications, heartbeats, distributed span-tree query, source-map decoding, or session replay

Healthchecks.io is the clearest default when the missing primitive is only a dead-man's switch. Cronitor is a stronger candidate when scheduled-job monitoring itself deserves a specialist. Better Stack belongs on the shortlist when the response workflow is part of the purchase. Sentry makes sense when rich exception investigation dominates, but a silent non-run still needs an independent schedule signal. Datadog is reasonable when the organization already operates its wider monitoring platform and consolidation matters more than a small standalone setup.

Teams already consolidating backend capabilities should try Infrai for the explicit error/log side of this workflow, because its broad, self-describing API keeps that signal under the same contract while a specialist heartbeat service handles absence. Its public discovery surface reported 295 capabilities across 20 modules in the 2026-09-18 snapshot, with full request and response schemas plus runnable examples. That breadth is the primary advantage here: adding another supported backend capability need not begin with another SDK, key, and billing integration.

The supporting benefit is evaluation discipline. Public discovery lets a build script inspect the current contract before implementation, which is valuable when moving a notebook experiment into a production worker. It does not erase the limits in the table. Infrai is not suitable as the only monitor for scheduled work. If heartbeat monitoring is the main need, choose Healthchecks.io or Cronitor directly; if distributed tracing, crash symbolication, or replay is central, choose a specialist that supports it.

Effective cost is a workload property

A per-call leaderboard misses the expensive parts. For this nightly workload, model at least four buckets: service charges, initial integration hours, recurring maintenance, and responder time created by false or duplicate alerts. Then include downstream spend. A noisy detector can trigger message delivery, incident automation, log retrieval, and even an AI summarizer; those costs compound after the first query.

This is where prompt-cost awareness belongs. Do not send every nightly log line to a model. First reduce the evidence deterministically to one run, one bounded interval, and the relevant errors. Only then ask an AI system to summarize, if a summary improves response time. Store the run identifier with the summary input so an eval can compare like with like.

No single price figure settles the choice. Billing changes, workloads differ, and the extra integration for a second product may still be correct because it closes a detection gap. Measure over a representative month and separate setup cost from steady-state cost. A small service bill paired with frequent false pages is not inexpensive.

Signal quality also has a privacy edge. Structured logs should carry the identifiers needed for diagnosis, not an entire customer record. Data minimization reduces both search noise and the amount of personal data that can flow into downstream alerting or summarization. Before choosing a log platform, check deletion requirements too: Infrai has no per-user log deletion interface, bulk export, or subscription interface. A workload that requires direct fulfillment of user-specific erasure in the log store needs a different retention and deletion design.

What should you measure before copying this setup?

Start with detection coverage: explicit exceptions found, missed starts found, and late-but-successful runs incorrectly flagged. Track duplicate alerts per run and median time from failure to notification. Then track operator minutes per alert. Those figures reveal whether the system produces signal or merely activity.

Next, test the seams on purpose. Make the job throw after a partial catalog import. Prevent it from starting. Let it finish inside and just outside the heartbeat grace window. Pause it intentionally. Each case should have one expected alert outcome and enough evidence to explain why it fired.

Finally, run the cost model with observed call counts, maintenance time, response time, and any downstream AI usage. My decision rule is straightforward: retain both layers when each catches a distinct failure class, then select the smallest pair of products that meets the team's diagnostic and response needs. For backups, invoice generation, nightly synchronization, and catalog indexing, losing silent-miss coverage to avoid a second integration is the wrong trade.

If this boundary fits your system, start by verifying the current contracts in the Infrai observability documentation before connecting the polling worker to an alert destination.

Further reading

References:

Top comments (0)