A simple SaaS reliability dashboard in Node, built from the errors API

The failure feed of a small dashboard, straight from Infrai's error groups in Node 22 — plus where product events, retention and latency belong, and when Grafana Cloud wins.

A dashboard for a small SaaS carries three feeds, and they come from three different kinds of store: failures, product events, and latency percentiles. Infrai’s errors namespace serves the first one properly and for free on the read side — GET /v1/errors/groups returns pre-aggregated failure rows with counts, affected-user counts, environments and releases, so the “what’s broken” half of the dashboard is one HTTP call, not a query language.

The other two live on different routes under the same key, and mixing them up is how teams end up with an error store full of signup_completed events. This page shows the failure feed done well in Node 22, then names the routes the other feeds belong on.

What each feed needs, and what serves it

FeedShapeCardinalityServed by
FailuresGrouped occurrences with first/last seenLow — dozens of groupsGET /v1/errors/groups, pre-aggregated
Product eventsNamed events with properties, per userMediumPOST /v1/analytics/track, read back windowed
Cohort retention and pathsCohort matrix, sequence treeMediumPOST /v1/analytics/query/retention and query/path
Latency percentilesHistograms over time bucketsMediumGET /v1/metrics/query with agg=p99
Session replay and funnel buildersRecorded sessions, drag-built funnelsHighPostHog, Amplitude and friends

The reason the errors API can hand you the first row cheaply is that it aggregates on write. Each event carries a fingerprint, the server hashes it, and everything with that hash collapses into one group with a running count. No rollup job, no retention tier, no query cost.

That’s also its limit: it can count things that went wrong, and nothing else.

The failure feed in one call

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "groups": [
      {
        "error_group_id": "errgrp_17E05u607XMVSasuMpsrMX7w",
        "title": "voiceops worker loop error",
        "count": 19,
        "user_count": 0,
        "level": "error",
        "is_resolved": false,
        "first_seen_at": "2026-07-06T08:57:58.564303Z",
        "last_seen_at": "2026-07-06T09:00:23.307062Z",
        "environments": ["prod"],
        "releases": []
      }
    ],
    "next_cursor": null,
    "total": 28
  }
}

status takes unresolved or resolved. Facet filtering — by environment, by release — lives on the event listing rather than on groups, so treat this route as “give me the open groups” and slice elsewhere.

Tiles from groups

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 headers = { Authorization: `Bearer ${KEY}` };

async function fetchGroups(status = "unresolved") {
  const out = [];
  let cursor = null;
  do {
    const qs = new URLSearchParams({ status, limit: "100" });
    if (cursor) qs.set("cursor", cursor);
    const res = await fetch(`${API}/v1/errors/groups?${qs}`, { headers });
    if (!res.ok) throw new Error(`groups -> HTTP ${res.status}`);
    const { data } = await res.json();
    out.push(...data.groups);
    cursor = data.next_cursor;
  } while (cursor);
  return out;
}

export async function dashboardFeed({ windowHours = 24 } = {}) {
  const groups = await fetchGroups();
  const since = Date.now() - windowHours * 3600_000;
  const active = groups.filter((g) => Date.parse(g.last_seen_at) >= since);

  const byRelease = {};
  for (const g of active) {
    for (const release of g.releases.length ? g.releases : ["unversioned"]) {
      byRelease[release] = (byRelease[release] ?? 0) + g.count;
    }
  }

  return {
    generated_at: new Date().toISOString(),
    open_groups: groups.length,
    active_groups: active.length,
    events_in_window: active.reduce((n, g) => n + g.count, 0),
    users_affected: active.reduce((n, g) => n + g.user_count, 0),
    new_in_window: active.filter((g) => Date.parse(g.first_seen_at) >= since).length,
    by_release: byRelease,
    worst: active
      .sort((a, b) => b.count - a.count)
      .slice(0, 5)
      .map((g) => ({ id: g.error_group_id, title: g.title.split("\n")[0], count: g.count })),
  };
}

console.log(JSON.stringify(await dashboardFeed({ windowHours: 24 }), null, 2));

Five tiles fall out of that object without further work: open groups, events in the last day, users affected, regressions introduced in the window, and a top-five table. new_in_window is the one that earns its place — a group whose first_seen_at is inside the window is a failure mode that didn’t exist yesterday, and on a Tuesday afternoon that usually means the morning’s deploy.

Cache the response for 60 seconds and point your admin page at it. Reads are free, but a dashboard that polls every second is still a rate limit waiting to happen.

Slicing by environment and release

The event listing does support facets, and it’s the right route when you want a number rather than a group:

curl -sS "https://api.infrai.cc/v1/errors/list?environment=production&level=error&limit=1" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The total in that response is the count you want; limit=1 keeps the payload small because you’re reading the counter, not the rows. Swap environment=staging or add release=2026.07.5 and you have a per-release error count for a deploy-health tile. Free-text lookups run through GET /v1/errors/search with a required q. Two routes, two jobs.

The product feed, on the same key

Signups, exports and plan upgrades belong in the analytics namespace, and its three query routes are free, windowed and genuinely useful. Raw events first:

curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/events" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "since": "2026-07-19T00:00:00Z",
    "until": "2026-07-26T00:00:00Z",
    "filter": {"plan": "pro"},
    "limit": 500
  }'

filter matches event properties, not the event name, and pagination is the returned next_cursor. Retention is its own query rather than something you assemble from those rows:

curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/retention" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "born_event": "signup_completed",
    "return_event": "export_created",
    "since": "2026-06-01T00:00:00Z",
    "until": "2026-07-01T00:00:00Z",
    "interval": "week"
  }'
{
  "ok": true,
  "data": {
    "cohorts": [
      { "date": "2026-06-09", "total": 42, "returning": [{ "interval": 1, "n": 18 }, { "interval": 2, "n": 11 }] },
      { "date": "2026-06-16", "total": 37, "returning": [{ "interval": 1, "n": 16 }] }
    ]
  }
}

That’s the matrix a retention chart is drawn from: one row per cohort date, one n per elapsed interval. POST /v1/analytics/query/path covers the other half, returning a tree rooted at start_event with a user count on every branch down to max_depth. Writing events costs $0.00005 apiece; all three reads are free, which is what makes them safe behind a dashboard refresh.

Latency percentiles are the third store — GET /v1/metrics/query with agg=p99 and a window. Ask the manifest which routes exist today rather than trusting a transcription:

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

What the failure feed costs

Every read route in the errors namespace is free and rate-limited: list, search, get, groups, group_detail and events. Only capture is billable, at $0.00005 per event — verified 2026-07-26. A dashboard is pure reads, so the dashboard itself is free; you pay for the errors your app produces and the product events you track, and new accounts get $2 of credit before that starts. Confirm against your own account:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | jq '{total_cost: .data.total_cost, breakdown: .data.breakdown}'

Prices in this catalogue drift downward and discount campaigns run, so the figure you read may be lower than the one printed here. The structure is the durable part: reads free, writes metered, one bill covering the queue, cron, storage and email that the same admin page probably also needs.

When to buy a dashboard product instead

If you want a UI you didn’t build, alert rules with schedules, or time-series maths over arbitrary dimensions, you’d be better off with a product designed for it. Grafana Cloud gives you panels, alerting and a generous free tier over Prometheus-shaped data. Datadog’s dashboards are the reference implementation if you’re already paying for its agent. PostHog earns its price on the things these routes deliberately don’t ship — session replay, a funnel builder your PM can drive without a deploy, and experiment tooling; buy it when someone who doesn’t write TypeScript owns the questions. Sentry, if error tracking is your main concern, ships release health and issue dashboards that this API doesn’t.

The case for building the feed yourself is narrower and worth stating precisely: you already need an admin page, the read routes cost nothing, the JSON is five fields deep, and adding a second observability vendor to a two-person team is a real ongoing cost — accounts, keys, invoices, and one more place to look during an incident.

References

Browse more errors developer guides