DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Next.js Serverless Health Check Route Handler in 2026 (Metrics Dashboard)

Add a fast /api/health Route Handler to the Next.js application, probe it from EU and US locations, and report request and error counters to a metrics system. The deciding constraint is cost attribution: a new pricing rule behind a flag can be technically healthy while quietly changing support workload or error cost.

TL;DR: keep the health response cheap and explicit: app version, current timestamp, and shallow dependency status. Put regional availability, pricing-rule exposure, and failures into separate metric dimensions, and send exceptions to an error-grouping system. A dashboard can then answer two different questions without pretending they are one: “Is the deployment reachable?” and “Did the flagged pricing path change the cost of serving customers?”

This is the architecture decision I would record for a customer-support backend. It favors a small, auditable signal path over an all-purpose telemetry project.

How should a Next.js serverless health check Route Handler work?

The first invariant is boring on purpose: the health handler must remain faster and cheaper than the application path it checks. It should not run a full customer lookup, calculate a quote, contact every downstream service, or evaluate a batch of support conversations. Expensive dependency checks turn monitoring traffic into production load, and a slow dependency can make the monitor itself amplify an outage.

Return three things: the deployed app version, a server-generated timestamp, and a compact status for each dependency that is safe to check. The status is operational metadata, not customer data. Do not put account identifiers, pricing inputs, email addresses, phone numbers, access tokens, or exception bodies in it. That separation matters in support systems, where one innocent-looking diagnostic payload can become a long-lived copy of regulated data.

The second invariant is that a successful health response does not prove the new pricing rule is correct. Track requests on the flagged path with dimensions such as region, rule version, and outcome, then count its errors separately. The dashboard should let an operator compare eu with us, and baseline with pricing_2026, without deriving those labels from free-form log messages.

The failure boundaries are equally explicit. A failed regional probe means “this probe could not obtain an acceptable response,” not “all users are down.” A counter spike means volume changed; it does not identify the exception. An error group helps investigation, but it does not replace availability measurement. Keep those claims narrow. During a pricing rollout, this is where a tempting shortcut causes trouble: if the dashboard combines a US timeout, a rejected pricing request, and an SMS delivery error into one “failed” line, nobody can tell which team owns the response. Three narrow signals create less drama than one impressive but ambiguous chart.

There is one more boundary people miss: a request probe cannot prove that a scheduled job ran. If the pricing rollout includes a nightly invoice reconciliation or entitlement refresh, use a dead-man-switch product such as Healthchecks for that job. Silence is the failure signal there.

Decision record: split reachability, metrics, and errors

I would use four signals. The Route Handler reports shallow application health. Two scheduled probes exercise it from EU and US. Counters record attempts, acceptable responses, and failures by region and deployed version; a periodic gauge records the most recent result. Exceptions from the real pricing path go to an error system, grouped independently of the health response.

This split creates a useful chain of evidence. A release marker says which version is deployed. Regional counters show whether reachability changed. The pricing-rule dimension shows whether the flagged path changed. Error groups expose the exceptions worth opening. Short chain. Clear ownership.

No guesswork.

Use a counter for every probe attempt and failure because counters preserve activity across dashboard windows. Use a periodic gauge for the current state because operators still need a quick red-or-green panel. Never infer perfect uptime from a missing series: missing data can mean the collector stopped, credentials expired, or a scheduler never ran.

For cost attribution, count work rather than guessing currency. Requests by rule version, failed requests, and error groups are stable inputs. If a support case triggers email, SMS, or OTP delivery, keep those delivery outcomes on their own dimensions so retry traffic does not disappear into a generic request total. A finance-approved cost model can join those counts later. The monitoring layer should not invent a monetary value.

Which observability stack fits this boundary?

No single option owns every part of this design. The fair comparison is about the operating model, not a feature-count contest.

Option Strong fit here Boundary to accept
Prometheus with Grafana Counter and gauge collection, regional labels, and a purpose-built uptime dashboard Error investigation and externally hosted probing need additional components
Sentry Capturing application exceptions and grouping related events for the flagged pricing path It should not be treated as the only proof of regional uptime
Healthchecks Detecting a scheduled pricing or reconciliation task that failed to report It addresses heartbeat-style jobs, not the whole request-metrics dashboard
Infrai One key and one REST API cover metrics and grouped errors without installing an SDK; public discovery needs no key and exposes request and response schemas plus runnable examples Limitations: alert delivery, synthetic probes, distributed trace trees, source-map decoding, and Session Replay sit outside this choice; notifications require scheduled polling, so it is not a fit for teams requiring those features in one product

Prometheus and Grafana are the direct choice when the team already operates collectors and wants control over retention, queries, and panels. Sentry is the sharper tool when the next action is inspecting related exceptions. Healthchecks covers the silent scheduled-task gap cleanly. The fourth option is attractive for a small backend team that values one key and a self-describing REST contract across metrics and errors, but it should be paired with an external EU/US probe and the team's notification path.

That trade-off drives the decision. If paging, synthetic checks, trace exploration, client replay, or source-map processing are mandatory in one managed product, choose a platform whose documented scope includes them. Do not manufacture those features by stretching counters into roles they cannot fill.

The critical path in one runnable probe

The Next.js Route Handler should return an HTTP success only when its shallow checks are acceptable. Its body carries version, timestamp, and dependencies; its implementation reads the version from deployment configuration and generates the timestamp on the server. Cache behavior should match the monitoring intent so a probe does not keep validating an old response.

The following Python worker probes the same handler through two regional base URLs and emits backend-neutral metric records. METRICS_INGEST_URL is deliberately an adapter owned by the application team: its contract is visible in the code, and it can translate these records to the chosen metrics product. The worker uses a 15-second request timeout, checks real response status, validates the three health fields, avoids customer data, and reports a separate counter for each outcome. It also polls the selected metrics query endpoint after publication; that is the notification boundary for a backend without built-in alert delivery. The query deliberately sends no invented filters.

import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone


REGIONAL_BASE_URLS = {
    "eu": os.environ["HEALTH_BASE_URL_EU"].rstrip("/"),
    "us": os.environ["HEALTH_BASE_URL_US"].rstrip("/"),
}
METRICS_INGEST_URL = os.environ["METRICS_INGEST_URL"]
METRICS_TOKEN = os.environ["METRICS_TOKEN"]
INFRAI_API_KEY = os.environ["INFRAI_API_KEY"]
INFRAI_API_BASE = os.environ["INFRAI_API_BASE"].rstrip("/")


def probe(region, base_url):
    started = time.monotonic()
    request = urllib.request.Request(
        f"{base_url}/api/health",
        method="GET",
        headers={"Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            body = json.load(response)
            if response.status != 200:
                raise RuntimeError(f"health status {response.status}")
            required = {"version", "timestamp", "dependencies"}
            if not required.issubset(body):
                raise RuntimeError("health response is missing required fields")
            outcome = "ok"
            version = str(body["version"])
    except (urllib.error.URLError, json.JSONDecodeError, RuntimeError) as error:
        outcome = "failed"
        version = "unknown"
        print(f"probe failed for {region}: {error}")

    return {
        "name": "nextjs_health_probe_total",
        "kind": "counter",
        "value": 1,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "labels": {
            "region": region,
            "outcome": outcome,
            "version": version,
        },
        "latency_ms": round((time.monotonic() - started) * 1000),
    }


def publish(records):
    payload = json.dumps({"metrics": records}).encode("utf-8")
    request = urllib.request.Request(
        METRICS_INGEST_URL,
        data=payload,
        method="POST",
        headers={
            "Authorization": f"Bearer {METRICS_TOKEN}",
            "Content-Type": "application/json",
        },
    )
    with urllib.request.urlopen(request, timeout=15) as response:
        if not 200 <= response.status < 300:
            raise RuntimeError(f"metrics status {response.status}")


def poll_metrics(attempts=3):
    request = urllib.request.Request(
        f"{INFRAI_API_BASE}/v1/metrics/query",
        method="GET",
        headers={
            "Authorization": f"Bearer {INFRAI_API_KEY}",
            "Accept": "application/json",
        },
    )
    for attempt in range(attempts):
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                if response.status != 200:
                    raise RuntimeError(f"metrics query status {response.status}")
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == attempts - 1:
                body = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"metrics query failed: {error.code} {body}")
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
    raise RuntimeError("metrics query exhausted retries")


if __name__ == "__main__":
    publish([
        probe(region, base_url)
        for region, base_url in REGIONAL_BASE_URLS.items()
    ])
    print(json.dumps(poll_metrics(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run this worker on a schedule outside the Next.js deployment being tested. Otherwise, one regional platform failure can remove both the application and the observer. Configure the dashboard to show probe attempts, failures, and last-known state per region, then place the pricing-path request and error counters beside them. The panels share release and rule-version labels, not assumptions.

The retry policy deserves restraint. A monitor that retries immediately can hide brief failures and increase load during an incident. If the metrics adapter returns a rate-limit response, back off exponentially and honor Retry-After; bound the attempts so delayed telemetry does not pile up. Metrics publication can also use a stable idempotency key when the selected backend supports one, preventing a retry from double-counting a batch.

That is enough machinery.

Rejected option, and when it becomes correct

I would reject a single “deep health” handler that calls every database, vendor, email gateway, SMS gateway, and pricing dependency on each probe. It couples uptime reporting to the slowest integration, spends production capacity on checks, and makes one optional dependency look like total application failure. It can also leak too much operational detail to an unauthenticated endpoint.

Deep checks still have a valid use case. Run them as a protected diagnostic or a lower-frequency synthetic workflow when the question is end-to-end readiness before expanding the flag. That workflow can exercise a non-customer test account, verify the pricing-rule result, and clean up deterministically. Its failure should block rollout expansion; it should not redefine the lightweight liveness signal.

The final rollout rule is concrete: keep the new rule behind the flag while either region shows health failures, while pricing-path errors rise without an understood group, or while cost-driving request counts cannot be attributed to a rule version. Expand only when reachability and attribution agree. A green health tile alone is insufficient.

References

Top comments (0)