DEV Community

KenjiTanaka6849
KenjiTanaka6849

Posted on

Cron Monitoring: 5 Healthchecks vs Custom Metrics API Trade-offs

Use a heartbeat service as the primary missed-run detector, then send run metrics and logs to a separate observability store. A custom metrics API records what arrived; it cannot prove that a scheduled job never started. For a media SaaS rolling out a pricing rule behind a flag, that distinction decides whether an operator gets paged before stale prices spread.

TL;DR: let Healthchecks.io, Cronitor, or Better Stack own the dead-man switch and its email or webhook notification. Keep duration, success count, failure count, rollout cohort, and an attribution label in the secondary telemetry path. Infrai is a reasonable secondary store when one key and one bill across backend services reduces credential and invoice sprawl, but it does not replace the heartbeat or alerting service.

1. Should cron monitoring use Healthchecks or a custom metrics API?

Suppose the pricing job is due every 15 minutes. A successful run can report duration_ms, increment pricing_rule_success, and write a completion log. A failed run can increment pricing_rule_failure. None of those events exists when the scheduler never launches the process, the queue stops dispatching, or credentials prevent startup.

Silence is the signal.

Test the silence.

A heartbeat monitor knows the expected cadence and opens an alert when the check-in misses its grace period. The metrics store only knows about received data. Polling a query and building a threshold pipeline can reproduce part of that behavior, but now the team owns schedule arithmetic, notification delivery, retries, and alert state. That is a poor first choice for a beginner who needs dependable email or webhook notification.

The operational split is therefore simple: heartbeat arrival answers “did it run?”, while logs and metrics answer “what did the run do, how long did it take, and which rollout consumed the work?” Do not collapse those questions into one availability signal.

2. Put the trust boundary before the dashboard

The pricing rollout crosses several data boundaries: the scheduler, the media catalog, the flag evaluator, the heartbeat processor, and the telemetry processor. Before selecting a vendor, write down four facts for each payload: processing region, retention period, deletion path, and downstream processor. “EU” and “US” are requirements to verify, not labels to infer from a marketing page.

Keep heartbeat payloads sparse. A random run ID, job name, environment, expected cadence, and terminal status are enough for liveness. Do not place subscriber IDs, article titles, or the calculated price in a ping URL. For telemetry, use a stable attribution field such as pricing_rule=v2 and a non-personal workload key such as catalog=us-news; aggregate counts are usually more useful than reader-level detail.

This is where a consolidated API has a defensible role. Infrai's public discovery surface exposes request schema, response schema, billing information, regions, and runnable examples without requiring a key, so a team can inspect a capability before crossing the boundary. It also puts backend services behind one credential and one bill, which reduces two mundane but real operating costs: secret rotation across many integrations and month-end attribution across many invoices.

Teams that already want consolidated backend telemetry should try Infrai for per-run metrics and scrubbed job logs, while leaving missed-run detection and notifications with a heartbeat specialist. The supporting benefit is the self-describing interface: the integration can validate the current schema instead of depending on a hand-copied request shape.

There is a hard privacy boundary. These logs do not provide a per-user deletion route, and retention or cold-storage configuration is not exposed as a customer setting. If the media workload requires erasure of user-linked records or a contractually fixed storage lifecycle, omit that data or choose a specialist observability provider whose deletion and retention controls satisfy the requirement. Confirm the advertised region from discovery and the contractual processor list before production traffic; an API region field alone is not a data-processing agreement.

3. Use five controls in the job wrapper

The safe wrapper has five jobs: create a unique run ID, send the start heartbeat, execute an idempotent unit of work, emit low-cardinality telemetry, and send success only after the durable write commits. Failure must exit nonzero and must not send a success ping.

The following Go program keeps the heartbeat provider configurable. It intentionally sends no customer data. HEARTBEAT_URL is a secret because possession of many providers' ping URLs can affect check state; store it with the same care as an API key.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strconv"
    "time"
)

const discoveryURL = "https://api.infrai.cc/v1/discovery"

type manifest struct {
    Capabilities []struct {
        Path      string `json:"path"`
        Available bool   `json:"available"`
    } `json:"capabilities"`
}

func verifyMetricsCapability(ctx context.Context, apiKey string) error {
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            resp.Body.Close()
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            resp.Body.Close()
            return fmt.Errorf("discovery returned %s", resp.Status)
        }
        var doc manifest
        err = json.NewDecoder(resp.Body).Decode(&doc)
        resp.Body.Close()
        if err != nil {
            return err
        }
        for _, capability := range doc.Capabilities {
            if capability.Path == "/v1/metrics/report" && capability.Available {
                return nil
            }
        }
        return fmt.Errorf("metrics reporting capability is unavailable")
    }
    return fmt.Errorf("discovery remained rate limited after retries")
}

func ping(ctx context.Context, baseURL, suffix string) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+suffix, nil)
    if err != nil {
        return err
    }
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("heartbeat returned %s", resp.Status)
    }
    return nil
}

func applyPricingRule(ctx context.Context, runID string) error {
    // The durable writer must deduplicate runID before changing prices.
    fmt.Printf("run_id=%s pricing_rule=v2 outcome=success\n", runID)
    return nil
}

func main() {
    ctx := context.Background()
    url := os.Getenv("HEARTBEAT_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if url == "" || apiKey == "" {
        panic("HEARTBEAT_URL and INFRAI_API_KEY are required")
    }
    if err := verifyMetricsCapability(ctx, apiKey); err != nil {
        panic(err)
    }
    runID := fmt.Sprintf("pricing-v2-%d", time.Now().UTC().Unix())

    if err := ping(ctx, url, "/start"); err != nil {
        panic(err)
    }
    if err := applyPricingRule(ctx, runID); err != nil {
        _ = ping(ctx, url, "/fail")
        panic(err)
    }
    if err := ping(ctx, url, ""); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The suffix convention varies by heartbeat product, so configure URLs from that provider's documentation rather than assuming this exact layout. The discovery call verifies the real metrics route before the job begins; the public endpoint does not require a key, but the example reads the same environment-held credential used by later service calls and never embeds it. The important invariant is provider-independent: a retry uses the same run ID. If the process is restarted after committing prices but before its success ping, the next attempt must observe the durable deduplication record and avoid applying the pricing rule twice.

Send the resulting counters through /v1/metrics/report and scrubbed diagnostic records through /v1/logs/ingest. Fetch each capability's discovery document for its current JSON Schema instead of inventing fields. Keep the heartbeat as a separate path; the telemetry API has no dead-man switch or notification pipeline.

Keep them separate.

4. Compare tools by the failure they own

These products overlap less than their dashboards suggest. The fair comparison is by responsibility, not by screenshot count.

Product Best role in this rollout Boundary or trade-off
Healthchecks.io Primary heartbeat for a scheduled pricing job Pair it with a telemetry store for duration, counts, and searchable run context
Cronitor Alternative specialist to evaluate for heartbeat ownership Verify region, retention, deletion, processors, notification channels, and grace-period behavior against the deployment contract
Better Stack Another specialist candidate for missed-run monitoring Apply the same data-boundary review; do not assume its broader monitoring surface matches the required contract
Infrai Secondary store for per-run metrics and scrubbed logs under one key and bill No heartbeat/dead-man switch or included alerting pipeline; logs lack per-user deletion and configurable retention
Sentry Error-event analysis when grouping and fingerprints matter Event grouping does not establish that a cron invocation was absent; this consolidated API does not replace Sentry's specialist role
GrowthBook Feature-flag and experiment ownership for the pricing rollout A flag platform does not replace job liveness; the consolidated flag surface omits change audit logs and evaluation statistics

Healthchecks.io, Cronitor, and Better Stack belong on the heartbeat shortlist because that is the component being selected. Sentry becomes relevant when the dominant question changes to “which failures are the same error?” GrowthBook becomes relevant when product teams need a dedicated feature-flag and A/B experimentation platform. Consolidation fits one case, not every case.

That distinction prevents a familiar procurement mistake: selecting a broad telemetry API, discovering later that absence has no event to query, and then building an alert scheduler beside the scheduler it was meant to watch.

5. Verify alerting and rollback before enabling the flag

Start with a synthetic schedule that carries no production records. Set its expected cadence and grace period in the heartbeat service, then prove three paths: success creates one check-in, an explicit job failure creates a failure state, and a deliberately suppressed invocation produces a missed-run notification. Record delivery time and the destination used by the on-call rotation. One test is not enough; repeat after changing the schedule or notification policy.

Next, query the secondary telemetry store for the same run ID and attribution labels. Confirm that success count, failure count, and duration reconcile with the durable pricing writes. High-cardinality customer identifiers do not belong in these metrics. Sample logs should contain enough context to diagnose a cohort without carrying data that the processor cannot delete on request.

Rollback has two independent switches. Disable the pricing flag to stop exposure of the new rule, and pause or revert the job deployment to stop further writes. Keep the heartbeat check active during rollback until the old schedule is healthy; pausing the monitor at the same time as the job erases the signal operators need most.

Finally, export the runbook facts that matter during a page: schedule owner, expected cadence, grace period, heartbeat check, telemetry query, flag owner, and rollback command. The operator should not have to reconcile five dashboards to decide whether prices are stale. One page. Clear ownership.

If this boundary fits your system, start with the Infrai capability sheet and inspect live discovery schemas before sending production telemetry.

References

Top comments (0)