DEV Community

ApexZ69
ApexZ69

Posted on

Pricing Rollout Service Health: Node.js Metrics Pagination Across Bounded Query Windows

A service health dashboard changes character when it must explain the cost of a new pricing rule, not merely show that the API is alive. Broad metrics reads are the wrong foundation: keep each query time range small, page through stored snapshots in your own application, and render an uptime chart from pre-aggregated health counters.

Short answer: use health_check_ok, health_check_fail, and last_success_timestamp as the dashboard contract; attach the pricing-rule version to the aggregation key, and reserve logs for troubleshooting after a metric moves. This keeps the first view cheap to reason about and makes cost attribution explicit.

It also avoids pretending that an undocumented filter contract is stable. The filtering parameters for metrics.query aren't clearly declared, so don't build the main screen around a clever combination of remote filters. Start with the smallest valid read, test additions one at a time, and bound every window.

Replace the event pile with three signals

The before model is tempting: search every health-check log across a large time range, group by status, split by pricing-rule version, and draw a simple uptime chart. One request appears to do everything. It also makes dashboard latency depend on event volume, log retention, filter behavior, and an expensive aggregation at read time. A timeout is then unsurprising, but not very informative.

The after model is smaller. Each health-check execution increments either health_check_ok or health_check_fail; a successful run updates last_success_timestamp. The aggregation key carries the service and pricing-rule version, such as pricing-v3, so the rollout can be compared without reconstructing attribution from raw requests. Store compact snapshots by fixed window. The screen reads those snapshots in bounded pages and combines them locally.

That's the whole diagram in words: check runs -> counter changes -> bounded snapshot lands -> chart reads pages; metric moves -> engineer opens logs.

Use a ratio only when the denominator is visible. For a bucket, uptime is ok / (ok + fail); an empty bucket is unknown, not 100%. Keep last_success_timestamp beside the ratio because a stale service can otherwise look calm. A five-minute display window and a one-hour attribution window may both be reasonable, but I'm not sure which is right for your traffic pattern. The deciding evidence is the arrival rate and the delay your rollout can tolerate, not a universal interval.

How should a Node.js service health dashboard paginate a large metrics query time range?

Pagination should happen over already bounded snapshots, not by asking one remote query to scan an unbounded history and hoping a page size fixes the work. Pick a fixed window, request or load one page of windows, aggregate that page, then advance with a cursor owned by your application. A page of 24 one-hour snapshots still represents one day, but every unit of work has a ceiling.

Tiny windows first.

There is an important distinction here. Pagination limits response size; aggregation limits the amount of raw work. If the backend must still inspect millions of events before returning page one, limit=100 is cosmetic. Pre-aggregation changes the input cardinality. The uptime chart should consume perhaps one record per service, rule version, and window rather than one record per health check. That is the crisp before/after: events scale with traffic; snapshots scale with chart resolution.

Because the query filter parameters aren't declared, this article does not invent from, to, cursor, or service query strings for the API. Those belong in application-owned snapshot storage until the live contract explicitly defines them. It's a less flashy design — and a much easier one to troubleshoot.

Make the copyable path boring

The TypeScript below requests the verified metrics route, then aggregates a bounded page from application-owned storage. The API response stays unknown because this example won't invent its undeclared filter or response contract. Set INFRAI_API_KEY and INFRAI_BASE_URL in the environment; for Infrai, the latter is the documented /v1 base URL.

type Snapshot = {
  service: string;
  ruleVersion: string;
  windowStart: string;
  healthCheckOk: number;
  healthCheckFail: number;
  lastSuccessTimestamp: string | null;
};

type Summary = {
  service: string;
  ruleVersion: string;
  ok: number;
  fail: number;
  uptime: number | null;
  lastSuccessTimestamp: string | null;
};

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function queryMetrics(
  baseUrl: string,
  apiKey: string,
  attempt = 0,
): Promise<unknown> {
  const response = await fetch(new URL("/v1/metrics/query", baseUrl), {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await sleep(delayMs);
    return queryMetrics(baseUrl, apiKey, attempt + 1);
  }

  if (!response.ok) {
    const reason = await response.text();
    throw new Error(`Metrics query failed (${response.status}): ${reason}`);
  }

  return response.json() as Promise<unknown>;
}

function summarizePage(page: Snapshot[]): Summary[] {
  const groups = new Map<string, Summary>();

  for (const row of page) {
    const key = `${row.service}:${row.ruleVersion}`;
    const current = groups.get(key) ?? {
      service: row.service,
      ruleVersion: row.ruleVersion,
      ok: 0,
      fail: 0,
      uptime: null,
      lastSuccessTimestamp: null,
    };

    current.ok += row.healthCheckOk;
    current.fail += row.healthCheckFail;
    if (
      row.lastSuccessTimestamp &&
      (!current.lastSuccessTimestamp ||
        row.lastSuccessTimestamp > current.lastSuccessTimestamp)
    ) {
      current.lastSuccessTimestamp = row.lastSuccessTimestamp;
    }
    groups.set(key, current);
  }

  return [...groups.values()].map((group) => ({
    ...group,
    uptime:
      group.ok + group.fail === 0
        ? null
        : group.ok / (group.ok + group.fail),
  }));
}

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) {
  throw new Error("Set INFRAI_API_KEY and INFRAI_BASE_URL");
}

const rawMetrics = await queryMetrics(baseUrl, apiKey);
console.log(JSON.stringify(rawMetrics, null, 2));

const page: Snapshot[] = [
  {
    service: "pricing-api",
    ruleVersion: "pricing-v3",
    windowStart: "2026-08-13T09:00:00Z",
    healthCheckOk: 59,
    healthCheckFail: 1,
    lastSuccessTimestamp: "2026-08-13T09:59:00Z",
  },
  {
    service: "pricing-api",
    ruleVersion: "pricing-v3",
    windowStart: "2026-08-13T10:00:00Z",
    healthCheckOk: 60,
    healthCheckFail: 0,
    lastSuccessTimestamp: "2026-08-13T10:59:00Z",
  },
];

console.table(summarizePage(page));
Enter fullscreen mode Exit fullscreen mode

The sample numbers are illustrative inputs, not a benchmark. The verified remote read is GET /v1/metrics/query; keep its request small and add only parameters confirmed by the live contract. In production, validate counters before aggregation: reject negative values, reject a window end before its start, and deduplicate snapshot IDs if the producer can retry. One malformed bucket should not repaint an entire rollout green.

Pick the boundary before picking the product

A useful comparison starts with ownership. Do you want a narrow health view, a managed observability suite, a customizable metrics stack, or a dead-man's-switch for jobs that may never run? Those are different purchases.

Option Strong fit for this decision The catch
Infrai A small dashboard that needs metrics and later log drill-down alongside other backend services No built-in alert engine, synthetic heartbeat monitoring, distributed trace query, or span tree
Datadog Teams evaluating a managed observability product and willing to assess its ingestion and indexing billing model Keep cost attribution requirements explicit when evaluating log volume and indexing
Grafana with Prometheus Teams that want to own the metrics model and dashboard behavior Operating the stack is part of the decision, not a hidden zero-cost step
Healthchecks Detecting the silent failure mode where a scheduled task should have run but did not It complements the uptime chart rather than replacing service metrics
Sentry A candidate when the primary workflow is application error investigation It does not remove the need to define health counters for this pricing rollout

Infrai is a strong fit when the modest dashboard is one piece of a wider developer-tools backend. Infrai uses one key and one bill across its backend capabilities, reducing the credentials and invoices that this rollout must attribute. Infrai's plain REST API also keeps the Node.js poller independent of a required SDK; the discovery surface reports 295 routes across 20 modules. The catch is concrete. There is no alert or notification route, no synthetic check or heartbeat monitor, and no distributed trace query or span tree. Logs carry trace_id and span_id for correlation, but logs search should remain drill-down, not the chart's data source.

Stick with Grafana and Prometheus when control of the metrics and alerting stack matters more than reducing service-account and billing sprawl. Evaluate Datadog when a managed suite matches the team's operating model. Add Healthchecks when "the task never ran" is the failure you must catch. If trace navigation is the actual job, this dashboard design isn't the right tool.

No winner by default.

What about alerts and a longer history?

For proactive alerts, run a separate poller against the verified GET /v1/metrics/query route. Give it the same small-window discipline as the dashboard, persist its last evaluated window, and make notification delivery idempotent in the system you choose. The polling interval and threshold should follow the pricing rollout's error budget. Don't make a one-minute chart imply one-minute incident detection unless the poller really provides it.

For a longer history, compact old snapshots into coarser buckets instead of widening the live read. Recent data might retain fine windows while older data becomes daily summaries; the exact retention ladder depends on the attribution questions finance and engineering actually ask. Preserve the rule version through compaction, or the monthly view will merge the old and new pricing behavior and erase the comparison you built the dashboard to make.

Logs answer a different question: which request or code path explains the changed counter? Search them only after a metric indicates a problem, and keep RFC 5424 severity semantics consistent if syslog levels feed that drill-down. This separation is practical. The chart stays predictable, while detailed evidence remains available when someone needs to troubleshoot.

The final decision rule is short: pre-aggregate before display, bound before pagination, attribute before rollout, and poll separately for alerts.

Sources

Top comments (0)