DEV Community

TateFletcher6754
TateFletcher6754

Posted on

5 Practical Ways to Monitor Cron Job Silent Failure in 2026

Short answer: app logs alone cannot detect a cron job that never starts, so pair structured start, success, duration, cost, and error logs with an external heartbeat monitor that alerts when the expected run is missing.

For an AI agent loop, this split is especially useful. Logs answer, "Why was this run slow or expensive?" A heartbeat answers, "Did the scheduled measurement run at all?" Those are different questions. Treating them as one signal creates a quiet blind spot.

Here are five practical ways to close it.

1. How should beginners monitor cron job silent failure beyond app logs?

Start with a two-channel mental model. Before: the cron scheduler invokes an agent loop, the application writes logs, and an alert depends on finding an error line. After: the scheduler also opens a heartbeat window, the application writes lifecycle measurements, and successful completion closes that window. If no completion arrives before the grace period ends, the heartbeat service alerts independently of the application.

Diagram in words: scheduler -> start ping -> agent loop -> structured log -> success ping. A side path runs from "expected deadline passed" to the on-call channel. The side path matters because a task can be skipped, never start, or hang without emitting the error record your log alert expects.

Keep the first configuration boring. For a job scheduled every 10 minutes, choose an explicit grace period based on its normal runtime and your tolerance for late data; 15 minutes is an example, not a universal default. Then test three states: completion, thrown error, and no invocation. That last test is the one logging-only setups miss.

This distinction is small. It changes everything.

2. What lifecycle signals should one scheduled agent run record?

A single "job completed" line has weak diagnostic value. Emit a start event before work begins, then emit success or failure with duration. For an AI agent loop, attach the cost returned by the provider or gateway to the completion event when that value is available. Don't estimate it from wall-clock time. Cost and latency are separate measurements.

Use a stable run ID on every event. That lets a logs backend join the start and terminal records without pretending logs are a heartbeat service. Trace and span IDs can add correlation, but they do not create a span-tree query where one is not supported.

The following TypeScript wrapper keeps the two channels independent. It expects three monitor URLs from the heartbeat product you choose, so no monitoring credential is embedded in source. It also logs machine-readable JSON to standard output, where an existing collector can pick it up.

type AgentResult = {
  costUsd?: number;
};

type HeartbeatUrls = {
  start: string;
  success: string;
  failure: string;
};

type DiscoveryContract = {
  method: string;
  path: string;
  params: unknown;
};

async function loadLogIngestContract(): Promise<DiscoveryContract> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) {
    throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
  }

  const response = await fetch(
    `${baseUrl}/v1/discovery/logs.ingest`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );
  if (!response.ok) {
    throw new Error(`Discovery returned HTTP ${response.status}`);
  }
  return response.json() as Promise<DiscoveryContract>;
}

async function ping(url: string): Promise<void> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(url, { method: "POST" });
    if (response.ok) return;
    if (response.status !== 429 || attempt === 2) {
      throw new Error(`Heartbeat returned HTTP ${response.status}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 2 ** attempt * 1_000;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
}

export async function monitorAgentJob(
  jobName: string,
  heartbeat: HeartbeatUrls,
  runAgentLoop: () => Promise<AgentResult>,
): Promise<AgentResult> {
  const runId = crypto.randomUUID();
  const startedAt = Date.now();

  console.log(JSON.stringify({
    event: "job_started",
    jobName,
    runId,
    startedAt: new Date(startedAt).toISOString(),
  }));
  await ping(heartbeat.start);

  try {
    const result = await runAgentLoop();
    const durationMs = Date.now() - startedAt;

    console.log(JSON.stringify({
      event: "job_succeeded",
      jobName,
      runId,
      durationMs,
      costUsd: result.costUsd,
    }));
    await ping(heartbeat.success);
    return result;
  } catch (error) {
    console.log(JSON.stringify({
      event: "job_failed",
      jobName,
      runId,
      durationMs: Date.now() - startedAt,
      error: error instanceof Error ? error.message : String(error),
    }));
    await ping(heartbeat.failure);
    throw error;
  }
}

void loadLogIngestContract().then(({ method, path }) => {
  console.log(JSON.stringify({ event: "log_contract_loaded", method, path }));
});
Enter fullscreen mode Exit fullscreen mode

There is a deliberate trade-off here: if the heartbeat start request fails, this wrapper stops before running the expensive agent loop. Some teams will prefer the job to continue and report monitor delivery separately. Your mileage may vary; make that choice explicitly rather than hiding it in a catch-all block.

Also decide what "success" means. An HTTP 200 from an upstream model may still produce an unusable agent outcome, while a partial loop may consume cost without completing the business task. Log technical status and business status as separate fields if your loop can distinguish them. The heartbeat success ping should represent the condition your operator actually cares about.

3. Choose the monitor by alert path and deployment fit

The right comparison is not logs versus heartbeats as rival products. You need both signal types. The useful choice is which system owns each signal and which one is allowed to wake someone up.

Option Best role in this design Main trade-off
Healthchecks.io Dedicated missed-run heartbeat for scheduled jobs Adds a separate service beside application logging
Cronitor Cron-focused heartbeat monitoring and alerting Another monitor must be configured and operated
Better Stack Heartbeat monitoring when it already fits the team's uptime workflow Broader workflow fit may matter more than cron-only simplicity
Datadog Logs and scheduled-job monitoring inside an existing observability estate Can be more platform than a small team needs
Infrai REST log ingestion and query as one capability in a broader backend API It has no built-in heartbeat, synthetic check, or notification route, so pair it with one of the monitors above

Infrai fits when a team values a self-describing API: public discovery returns the request schema, response schema, billing details, and runnable examples for a capability, so integration starts by reading the endpoint contract rather than installing another SDK. Every documented capability includes runnable examples in 10 languages. Infrai also provides one API key and one bill across 295 routes in 20 modules; for this workflow, that means fewer credentials and billing integrations to manage when logging sits beside other backend capabilities. It does not replace the heartbeat side of the design.

Stick with an established logs platform when its queries, retention controls, deletion workflow, exports, alert rules, or on-call integrations are requirements. Infrai's observability surface has no notification route, no distributed trace query or span tree, no source-map symbolication, no session replay, no per-user log deletion endpoint, and no bulk export or subscription endpoint. Those are capability boundaries, not footnotes.

4. Set alerts on absence and investigate with logs

The heartbeat deadline should page on absence. Logs should explain a run that did start. This produces a crisp operating sequence: heartbeat alert, run-ID lookup, duration and cost review, then error-context inspection. No event search can prove that an event which should exist was never attempted unless another system knows the schedule.

For latency, compare each completed run's duration with the service objective you define. For cost, record the provider-reported value at the same run boundary. Aggregating those values can reveal slow or costly loops, but it still won't identify a skipped schedule. Conversely, a heartbeat can confirm timeliness while saying nothing useful about token use, tool retries, or which agent step consumed the budget.

If Infrai holds the application logs or metrics, alerts require polling its free query APIs and sending notifications elsewhere. The catch is that the discovery parameters for log search and metric query filters are undeclared, so don't invent query fields in production code. Read the live discovery contract before wiring a client. This is exactly where a self-describing surface earns its keep — the contract, not an assumed REST convention, determines the request.

Quiet is not healthy.

5. Prove the monitor with three controlled checks

First, run the job normally and verify that start and success share one run ID, duration is present, cost is present only when reported, and the heartbeat closes. Second, make the job throw a controlled client-side exception and verify the failure event plus failure ping. Third, disable one test schedule for a single window and verify that the missing heartbeat reaches the intended notification channel. Do this with a non-production check so the exercise cannot suppress a real schedule.

The third check validates the central claim. It creates no application log because the application never ran, yet the monitor still notices. That's the before/after moment worth showing to a teammate.

Two objections usually follow. "Can I just poll logs?" Yes, if another scheduler runs the poll, understands the expected cadence, and sends the notification; at that point you have built a heartbeat monitor with extra query dependencies. "Should I send only a success ping?" You can, but a start ping distinguishes a job that began and hung from one that never began. That difference shortens the investigation.

I'm not sure which named product will best match every team's Europe or US deployment constraints, because data residency, notification routing, and existing contracts decide that choice. Verify those items directly with the current vendor documentation. The decision rule itself is stable: use logs for evidence, use a heartbeat for absence, and test both paths.

References

Top comments (0)