Build an internal uptime page in Node from a 0/1 gauge and two API calls

Uptime is the mean of a boolean gauge. Here's a prober, a status page server and the day-bucketing trick that gives you history without a time-range query.

Uptime is an average, not a special data type. Write a 1 when a probe succeeds and a 0 when it fails, ask for the mean, and the number that comes back is your availability — 0.98 means 98%. That single observation collapses an internal status page down to a prober, a page, and two Infrai routes: POST /v1/metrics/batch on the write side, GET /v1/metrics/query on the read side.

The result isn’t a public status page with subscriber emails and incident timelines. It’s the admin screen your team actually opens at 09:00 to see whether anything is limping, and it takes an afternoon.

The probe writer

One script, run from cron or a small always-on box, checks each service and reports two numbers per service: whether it answered, and how long it took. Both ride in one batch call.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const TARGETS = [
  { service: "api", url: "https://api.example.com/healthz" },
  { service: "web", url: "https://www.example.com/healthz" },
  { service: "worker", url: "https://worker.example.com/healthz" },
];

const day = new Date().toISOString().slice(0, 10);

async function probe({ service, url }) {
  const started = performance.now();
  let up = 0;
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    up = res.ok ? 1 : 0;
  } catch (err) {
    console.error(`${service} probe threw: ${err.message}`);
  }
  const ms = Math.round(performance.now() - started);
  return [
    { name: "service.up", value: up, type: "gauge", tags: { service, day } },
    { name: "service.latency_ms", value: ms, type: "gauge", tags: { service, day } },
  ];
}

const points = (await Promise.all(TARGETS.map(probe))).flat();

const res = await fetch(`${API}/v1/metrics/batch`, {
  method: "POST",
  headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ points }),
});
if (!res.ok) {
  console.error(`batch write failed: HTTP ${res.status} ${await res.text()}`);
  process.exit(1);
}
const { data } = await res.json();
console.log(`accepted ${data.accepted} of ${points.length} points for ${day}`);

The day tag is doing quiet but important work; more on that shortly.

If you’d rather see the wire format first, one probe result looks like this:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/metrics/batch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "points": [
      {"name": "service.up", "value": 1, "type": "gauge", "tags": {"service": "api", "day": "2026-07-26"}},
      {"name": "service.latency_ms", "value": 148, "type": "gauge", "tags": {"service": "api", "day": "2026-07-26"}}
    ]
  }'
{
  "ok": true,
  "data": { "accepted": 2 },
  "metadata": { "request_id": "req_2a416e47a3034b1eb8b91871", "latency_ms": 32 }
}

Reading availability back

Mean of the 0/1 gauge, filtered to one service and one day:

curl -sS "https://api.infrai.cc/v1/metrics/query?name=service.up&agg=avg&tag.service=api&tag.day=2026-07-26" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "service.up",
    "agg": "avg",
    "points": [{ "ts": "2026-07-26T00:26:24.134546Z", "value": 0.8 }]
  }
}

Four successes and one failure gives 0.8. Swap agg=avg for agg=p99 on the latency metric and you have the second column of the page. Reads don’t cost anything, so a page that fires six queries on every render is fine.

The page itself

No framework, no build step — a Node 22 HTTP server that queries on demand and returns HTML:

import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const SERVICES = ["api", "web", "worker"];

async function metric(name, agg, service, day) {
  const url = new URL(`${API}/v1/metrics/query`);
  url.searchParams.set("name", name);
  url.searchParams.set("agg", agg);
  url.searchParams.set("tag.service", service);
  url.searchParams.set("tag.day", day);
  const res = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } });
  if (!res.ok) throw new Error(`${name} query: HTTP ${res.status}`);
  const { data } = await res.json();
  return data.points[0]?.value ?? null;
}

function row(service, uptime, p99) {
  const pct = uptime === null ? "no data" : `${(uptime * 100).toFixed(2)}%`;
  const state = uptime === null ? "unknown" : uptime >= 0.99 ? "ok" : "degraded";
  return `<tr><td>${service}</td><td>${state}</td><td>${pct}</td><td>${p99 ?? "-"} ms</td></tr>`;
}

createServer(async (req, res) => {
  const day = new Date().toISOString().slice(0, 10);
  try {
    const rows = await Promise.all(SERVICES.map(async (s) => {
      const [uptime, p99] = await Promise.all([
        metric("service.up", "avg", s, day),
        metric("service.latency_ms", "p99", s, day),
      ]);
      return row(s, uptime, p99);
    }));
    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
    res.end(`<h1>Status ${day}</h1><table><tr><th>Service</th><th>State</th>` +
      `<th>Uptime</th><th>p99</th></tr>${rows.join("")}</table>`);
  } catch (err) {
    console.error(err);
    res.writeHead(502, { "content-type": "text/plain" });
    res.end(`status page could not read metrics: ${err.message}`);
  }
}).listen(8080, () => console.log("status page on http://localhost:8080"));

Put it behind your existing admin auth. It reads an API key from the environment and renders whatever that key can see, which is not something to leave on the open internet.

Why the day tag exists

Here’s the constraint that shapes the whole design: the query route has no from, to or step parameter. One call returns one aggregate over everything retained under that name and tag set. Without a bucket tag, “uptime today” and “uptime since we started” are the same query, and the number only ever drifts toward the long-run average.

Tagging with day at write time turns one un-windowed aggregate into as many windows as you want, at the cost of one query per day you display. A 30-day strip means 30 free queries, which renders in well under a second in practice, though it’s clearly a workaround rather than a range query.

What it costs to keep running

Probe writes are billable at $0.001 per call, verified 2026-07-26, and the price is per call rather than per point — so probing ten services in one batch costs the same as probing one. A 60-second probe interval is 43,200 calls a month, near $43; at five minutes it’s about $8.60. Queries are free and rate-limited. New accounts start with $2 in credit.

Read the current numbers instead of trusting a snapshot, and check what you’ve spent so far:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.namespace == "metrics") | {id, price: .billing.price_usd}]'

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.breakdown[] | select(.key | startswith("metrics."))'

Build it or buy it

OptionWhat you get out of the boxWhat it costs youBest when
This, on the metrics APIuptime %, p99, your own HTMLper probe write; reads freeyou want an internal admin view and already have an admin app
Grafana Cloud with synthetic checksprobes from many regions, panels, alert rulesper-series and per-check plansyou want charts and multi-region probing without writing them
Datadog syntheticsprobing, APM correlation, on-call routingseat and host pricingthe monitoring stack is already Datadog
A hosted status page productpublic page, subscribers, incident commsper page, monthlycustomers, not just your team, need to see it

The drawback list is short but real. There’s no built-in alerting, so nothing pages you when a row turns red — you’d pair this with a watcher that queries the same metric on a schedule. There’s no multi-region probing, so a probe from one box tells you about that box’s view of the world. And with no range query, dense historical charts mean one call per bucket; if you want a year of per-minute data on a graph, Prometheus behind Grafana is the right tool and this isn’t close.

Where it earns its place is the second question. The status page is rarely the end of the story — you also want the nightly digest emailed, the incident artefact stored, the failing job requeued. All of that sits on the same key and the same bill, which is a different kind of saving from a cheaper rate.

References

Browse more metrics developer guides