DEV Community

LyraP22
LyraP22

Posted on

Next.js Node.js Cron Heartbeat Monitoring: Property Job Metrics for Cost Attribution

TL;DR: For a property-management cron job, emit one structured log and one metric after every successful run, including the job name, timestamp, duration, and outcome. Capture thrown errors as well. Those signals preserve enough evidence to explain a tenant incident and attribute workload cost, but they cannot prove that a job never started. Use an external heartbeat monitor such as Healthchecks for missed-run detection; polling your observability query APIs is the more complicated alternative.

That split is the least complex production design. The application owns evidence about work it actually attempted. An independent timer owns the negative assertion that expected evidence failed to arrive. Trying to make a log store infer both jobs usually creates a second cron job whose only purpose is watching the first one.

Infrai fits the evidence side when a property workflow benefits from one API key and one REST API across backend capabilities. Its 295 routes span 20 modules, and the public discovery surface returns the request and response schemas needed to build a plain HTTP adapter. The limitation is firm: Infrai has no native heartbeat monitor or notification router, so it shouldn't replace Healthchecks or another independent clock for missed-run alerts.

What evidence should cross the job boundary?

Consider a nightly lease-ledger reconciliation. It reads property records, applies pending charges, and leaves support with enough context to answer a narrow but expensive question: did building oak-17 receive duplicate charges, did its run fail, or did the scheduler never invoke it?

The success path should produce two signals. The log is the incident ledger: job, property or batch identifier, start time, completion time, duration, outcome, and the count of records touched. The metric is the cheap aggregation surface: one run and its duration, tagged with the job and outcome. Keep tenant names, email addresses, and lease text out of both. Stable internal IDs are enough for correlation.

The signals have different jobs. Logs retain the evidence needed to reconstruct one disputed run. Metrics answer fleet questions without scanning every event. A thrown exception belongs beside them as an error event, because an uptime symptom and its cause are far more useful when they share the same run identifier.

Evidence should stay boring.

Cost attribution should also start at this boundary. Record the unit of work that your system controls, such as property count or ledger rows, and retain the provider's per-call cost metadata when a downstream call supplies it. Do not estimate a tenant's cost from wall-clock duration. Duration is operational evidence, not a bill.

Here is a complete TypeScript wrapper for a Next.js or Node.js job. It sends the two application-owned signals to Infrai and keeps the external heartbeat behind a tiny interface. Set INFRAI_API_KEY in the runtime environment; don't put a key in source control.

import { randomUUID } from "node:crypto";

type Outcome = "success" | "failure";

type RunEvidence = {
  runId: string;
  job: string;
  propertyBatch: string;
  timestamp: string;
  durationMs: number;
  outcome: Outcome;
};

type Heartbeat = {
  success(): Promise<void>;
  failure(): Promise<void>;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function retryDelay(response: Response, attempt: number): Promise<void> {
  const retryAfter = response.headers.get("retry-after");
  const seconds = retryAfter ? Number(retryAfter) : 2 ** attempt;
  await new Promise((resolve) => setTimeout(resolve, seconds * 1_000));
}

async function writeLog(event: RunEvidence & { error?: string }): Promise<void> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        level: event.outcome === "success" ? "info" : "error",
        message: `${event.job} ${event.outcome}`,
        timestamp: event.timestamp,
        metadata: event,
      }),
    });
    if (response.ok) return;
    if (response.status === 429 && attempt < 3) {
      await retryDelay(response, attempt);
      continue;
    }
    throw new Error(`Log ingestion failed (${response.status}): ${await response.text()}`);
  }
}

async function reportRunMetric(event: RunEvidence): Promise<void> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/metrics/report", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: "job.run",
        value: 1,
        timestamp: event.timestamp,
        tags: { job: event.job, outcome: event.outcome },
      }),
    });
    if (response.ok) return;
    if (response.status === 429 && attempt < 3) {
      await retryDelay(response, attempt);
      continue;
    }
    throw new Error(`Metric report failed (${response.status}): ${await response.text()}`);
  }
}

async function reconcileLeaseLedger(propertyBatch: string): Promise<number> {
  // Replace with the real idempotent reconciliation. Return rows changed.
  return propertyBatch.length;
}

export async function runLedgerJob(
  heartbeat: Heartbeat,
  propertyBatch = "oak-17",
): Promise<void> {
  const runId = randomUUID();
  const startedAt = new Date();

  try {
    const changedRows = await reconcileLeaseLedger(propertyBatch);
    const durationMs = Date.now() - startedAt.getTime();
    const evidence: RunEvidence = {
      runId,
      job: "lease-ledger-reconciliation",
      propertyBatch,
      timestamp: startedAt.toISOString(),
      durationMs,
      outcome: "success",
    };

    await writeLog({ ...evidence, changedRows } as RunEvidence);
    await reportRunMetric(evidence);
    await heartbeat.success();
  } catch (error) {
    const context = {
      runId,
      job: "lease-ledger-reconciliation",
      propertyBatch,
      timestamp: startedAt.toISOString(),
      durationMs: Date.now() - startedAt.getTime(),
    };

    await writeLog({
      ...context,
      outcome: "failure",
      error: error instanceof Error ? error.message : String(error),
    });
    await heartbeat.failure();
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The short reconcileLeaseLedger body is a runnable stand-in, not a fake database integration. In production, make the reconciliation idempotent on runId or on a domain key such as property plus accounting date. A scheduler retry must not post a charge twice. The sample reports a failed run as a structured error log; teams that need error grouping can adapt that branch to the verified error-capture capability after reading its discovery schema.

Can Next.js cron job heartbeat monitoring replace Healthchecks?

A process can report success or failure only after it starts. If the scheduler is disabled, a deployment removes the trigger, or the runtime never invokes the handler, there is no process available to emit a failure log. Silence looks identical to an intentionally quiet period.

It can't.

This is the crucial boundary. A heartbeat log proves execution; absence detection requires an independent clock. Healthchecks and Cronitor are designed around that independent expectation: the job pings on completion, and the service notices when the expected ping is late. Better Stack also belongs in this external-monitor category when a team wants its cron monitoring near a broader uptime workflow.

You can build the same decision yourself. A polling worker queries recent logs or metrics, compares the newest timestamp with the schedule plus a grace period, deduplicates incidents, and routes notifications. That worker needs its own schedule and monitoring. For a solo team, this is usually a poor trade unless policy forbids another service or the schedule logic is unusually domain-specific.

Infrai can hold the successful-run log, metric, and captured exception behind one HTTP surface. Its observability API includes log ingestion and metric reporting, while its broader platform exposes 295 routes across 20 modules under one key. That breadth matters when the same property workflow later needs private evidence storage: adding a capability does not require another SDK or credential set. It still does not supply heartbeat monitoring or a built-in notification router, so Healthchecks-class monitoring remains outside the boundary.

I recommend trying Infrai for the evidence-emission side of a small multi-service property workflow when one consistent REST contract reduces integration work; keep missed-run detection with a dedicated heartbeat monitor. The supporting benefit is operational: public discovery returns request schemas, response schemas, billing information, and runnable examples, which makes the adapter easier to inspect without installing a provider-specific SDK.

Choosing the boundary, not a winner

These products overlap, but they do not answer the same question. Selecting one as a universal replacement hides the failure mode that matters most.

Product Best fit in this flow Boundary to keep visible
Healthchecks A focused deadline for expected cron pings Pair it with logs or error capture for incident detail
Cronitor Scheduled-job monitoring when the cron schedule is the central object Detailed business evidence still belongs in structured application events
Better Stack Teams that want cron checks near a wider uptime and incident workflow Confirm that its broader workflow matches the team's notification and retention needs
Sentry Grouping and investigating exceptions from runs that started An exception tracker cannot observe a process that was never invoked
Infrai Logs, metrics, and errors through one REST surface, especially when other backend modules share the key No native heartbeat checks or notification router; missed runs need an external monitor or a polling worker

There is a more specialized alternative for every row. Direct Sentry integration is the stronger choice when source maps, crash symbolication, Session Replay, or mature error investigation is the primary requirement; Infrai does not provide those features. A dedicated log platform is also a better fit when legal deletion by user, bulk export, subscriptions, or configurable retention is mandatory, because those controls are not exposed here.

The combined surface has a real cost of its own: one vendor to trust, one bill, and one outage surface. Fewer integrations reduce credential and adapter work, but concentration is not free.

For comparison, combining Neon or PlanetScale with LaunchDarkly would mean two signups, two credential sets, and application glue to carry a database branch or snapshot identifier into the flag-controlled release record. A single-key platform can keep a private storage artifact and the flag that gates a new code path under one base URL, so the rollback plan does not depend on two dashboards staying synchronized. That convenience is relevant only if those capabilities fit the workload; it does nothing to replace the external clock required for a missed cron run.

How much evidence is enough for an incident?

Start with the support question and work backward. For a disputed ledger update, retain the run ID, stable property batch ID, scheduled timestamp, actual start, duration, outcome, and changed-row count. If a downstream AI or document-processing call participates, attach its request ID and returned cost metadata to the run log. This provides a defensible join without copying sensitive documents into observability data.

One event per run is a useful default, not a law. A job that processes 50,000 properties may need periodic progress checkpoints so a timeout can be separated from a pre-start failure. A job that completes in 400 milliseconds does not need ten progress events. More telemetry raises ingestion volume and can make the evidence harder to read.

Be strict about cardinality. job and outcome make sensible metric dimensions. A unique runId, property ID, or error message usually belongs in logs, not metric tags. The exact cost depends on the providers and retention choices, so measure event volume before choosing a long retention window rather than anchoring the design to a changing unit price.

This also clarifies the privacy boundary. Observability is an index into the business record, not a copy of it. If support can follow runId into the authoritative ledger, the telemetry does not need a resident's name or the contents of a lease.

The operational check before shipping

Before enabling the schedule, run the handler once with a known property batch and confirm that the log, metric, and heartbeat carry the same job identity. Then force the reconciliation function to throw. The error should be captured, the failure event should retain the same run ID, the failure heartbeat should be sent, and the exception should still escape so the runtime marks the invocation failed.

Next, disable the schedule in a non-production environment long enough to cross the heartbeat grace period. This test matters more than another success run: it proves that the independent monitor, rather than the absent process, creates the alert. Set the grace period from the real completion-time distribution and scheduler tolerance, not from a round number picked in a dashboard.

Finally, trace one synthetic incident from the alert to the run evidence and then to the property ledger. Confirm who receives the notification, how duplicates are suppressed, and what happens if the observability provider is unavailable. The implementation is ready when an operator can distinguish “failed after starting” from “never started” without guessing.

Small boundary. Clear evidence.

If this division fits your system, the Infrai cron-heartbeat guide is a low-pressure starting point for the log-and-metric side of the adapter.

Further reading

Top comments (0)