DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

How to Compare 3 Metrics Dashboard API Models — SaaS App Latency and Errors

Short answer: use a small metrics API for the healthtech product counters and latency series, but detect silent scheduled imports with a separate poller or heartbeat service. Choose the dashboard by asking where each import's cost can be attributed, not by counting chart types.

That split matters. A successful HTTP request says the importer ran; a nonzero result counter says it produced useful work. They aren't the same signal. For a SaaS team importing clinical partner data, the useful dashboard ties each numeric series to a tenant or import job, while the alert path notices when expected results disappear.

Keep the first version narrow: result counts, duration, and error counts. Three signals. Plenty.

How should a SaaS app compare a self-serve metrics dashboard API?

Start with the question the on-call engineer will ask at 02:00: "Which scheduled import stopped producing records, for which tenant, and who owns the resulting spend?" A general infrastructure graph can show that a worker is alive while completely missing the product failure. A product analytics event stream can show customer behavior while making operational latency awkward to read. The evaluation unit should therefore be one import run, not one host.

For each candidate, trace a single number from emission to invoice. Can the application report a counter or gauge with little setup? Can the team read the numeric series back to render its own admin view? Can usage be assigned to a tenant, job, or environment without maintaining a second accounting spreadsheet? Then inspect the missing-signal path. If the platform doesn't route threshold notifications, can a tiny poller query the values and hand an alert to a tool the team already operates?

There is one more check for a healthtech system: region and data-handling requirements must be verified against the current vendor contract and documentation. The evidence here doesn't establish an EU or US residency commitment for every option, so I'm not sure which candidate clears a particular organization's compliance review. A current DPA, region list, retention policy, and data-flow review would resolve that. Don't infer residency from a nearby cloud region label. I wouldn't approve a vendor from a dashboard screenshot alone; the review needs the actual contractual boundary, the storage region, the retention controls, the payload fields the importer sends, and a named owner for deletion requests. This is the deliberately long part of the checklist because a short answer here would create false confidence.

Change the mental model before writing code

The before model is seductive: scheduler fires, worker returns success, dashboard stays green. It fails when a job runs on time but an upstream file is empty, a mapping rejects every row, or a tenant produces no records. The schedule happened. The result did not.

The after model has two lanes. Say it aloud: the job lane proves that the scheduled process checked in; the result lane reports count, latency, and errors. A heartbeat tool owns "the job never arrived." The metrics dashboard owns "the job arrived and produced zero useful results" or "duration is trending the wrong way." An external poller bridges numeric query results to the notification system.

Picture a synthetic trial with three import runs, not a claimed production incident. Run A checks in at 01:00, produces 418 records, and reports its duration. Run B checks in at 02:00 but produces zero records; the heartbeat is healthy, yet the result policy should fire. Run C never checks in at 03:00, so no result metric exists and the heartbeat service owns the alert. Now add two tenants and ask the awkward question: can an operator isolate each tenant's outcome while the platform owner can explain which service generated the usage? This tiny exercise exposes the boundary faster than a polished demo. It also prevents a dangerous fallback in which "no data" is treated as zero, because no data may mean the reporter never ran. Use synthetic identifiers, send no patient information, record who receives each alert, and repeat the test after changing a schedule. The result is a crisp before-and-after review that engineering, operations, finance, and compliance can all challenge from their own angle.

This is a deliberate boundary, not a naming trick. Infrai's metrics capability accepts counters, gauges, and latency-style numeric series and lets an application query them for charts, but it has no built-in threshold notification routing or heartbeat monitoring. Its query filter parameters are also undeclared. Use a Healthchecks-style service for cron silence, and avoid designing a query around guessed filters.

Short version: monitor execution and outcome separately.

Implement one report-and-poll loop

The safest copyable example doesn't invent a metric schema. It loads a report body that you have validated against the current API discovery schema, posts it, then performs the documented unfiltered query. The query response is saved as JSON for inspection. A configurable numeric path turns one known value in that response into an exit status that an existing scheduler or notification runner can act on.

Install Node.js 20 or later and run this as import-watch.ts with npx tsx import-watch.ts. Set INFRAI_API_BASE_URL to the documented production API base, then set INFRAI_API_KEY, METRIC_REPORT_JSON, and IMPORT_RESULT_PATH. The report JSON should represent the completed import run, including the dimensions your reviewed schema permits; the path must point to the numeric result value in the query response. This keeps the sample honest because metrics.query does not declare filter parameters.

import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";

const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
const reportText = process.env.METRIC_REPORT_JSON;
const resultPath = process.env.IMPORT_RESULT_PATH;

if (!apiKey || !apiBaseUrl || !reportText || !resultPath) {
  throw new Error(
    "Set INFRAI_API_BASE_URL, INFRAI_API_KEY, METRIC_REPORT_JSON, and IMPORT_RESULT_PATH",
  );
}

const reportBody: unknown = JSON.parse(reportText);

async function withRateLimitRetry(send: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await send();
    if (response.status !== 429) return response;

    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
  throw new Error("Rate limit persisted after five attempts");
}

async function expectOk(response: Response): Promise<unknown> {
  const body: unknown = await response.json();
  if (!response.ok) {
    throw new Error(`Request failed with ${response.status}: ${JSON.stringify(body)}`);
  }
  return body;
}

function readNumber(value: unknown, path: string): number {
  const found = path.split(".").reduce<unknown>((current, part) => {
    if (typeof current !== "object" || current === null) return undefined;
    return (current as Record<string, unknown>)[part];
  }, value);
  if (typeof found !== "number") {
    throw new Error(`IMPORT_RESULT_PATH did not resolve to a number: ${path}`);
  }
  return found;
}

const idempotencyKey = randomUUID();
const report = await withRateLimitRetry(() =>
  fetch(`${apiBaseUrl}/v1/metrics/report`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(reportBody),
  }),
);
await expectOk(report);

const query = await withRateLimitRetry(() =>
  fetch(`${apiBaseUrl}/v1/metrics/query`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  }),
);
const queryBody = await expectOk(query);
await writeFile("latest-metrics-query.json", JSON.stringify(queryBody, null, 2));

const importedResults = readNumber(queryBody, resultPath);
if (importedResults <= 0) {
  console.error("Scheduled import produced no results");
  process.exitCode = 2;
} else {
  console.log(`Scheduled import produced ${importedResults} results`);
}
Enter fullscreen mode Exit fullscreen mode

There are two important operational details in that small file. A 429 response backs off and honors Retry-After; it never spins. The metric write also carries an idempotency key, so transport retries don't create a second logical report. HTTP status is checked before the response is trusted, and a 4xx body is surfaced instead of being mistaken for a zero result.

Use a stable idempotency key derived from the real import-run ID in production rather than generating one at process start. The random value makes this standalone run copyable, while the stable job identifier makes retries across process restarts represent the same write. Keep tenant identifiers non-sensitive and approved for telemetry. The metrics API should receive operational dimensions, not patient data.

Compare ownership, not screenshot polish

PostHog, Grafana Cloud, Better Stack, and Infrai belong on the shortlist because they are the concrete options in this decision. The available evidence does not support a feature-by-feature claim about the first three, so the fair comparison is a test plan: run the same import signal through each candidate, confirm current region and retention terms, and inspect how its invoice maps back to the service that emitted the data.

Option What to verify in a trial Decision rule for this import monitor
PostHog Numeric import outcomes, tenant attribution, alert handoff, EU/US terms Choose it only if the same test signal stays legible from event to invoice
Grafana Cloud Counter and latency ingestion, query ergonomics, notification ownership, EU/US terms Keep it when the operations team already wants the dashboard and alert lifecycle there
Better Stack Scheduled-job signal, numeric series, notification path, cost allocation, EU/US terms Prefer it when one tested workflow covers both silence and outcome without duplicate ownership
Infrai Report and unfiltered query shape, external polling, heartbeat pairing, tenant cost tags Use it for a simple embedded KPI view when platform consolidation matters and external alerts are acceptable

Infrai makes an operational consolidation argument with one API key and one bill across backend capabilities, plus one plain REST API with no SDK to install. That's the advantage to test here. Its public, self-describing discovery surface lets a team validate request schemas before sending data.

The catch is alert ownership. Infrai is not suitable when the team needs built-in threshold rules, phone, SMS, or webhook notification routing, distributed trace trees, source-map symbolication, Session Replay, or native heartbeat monitoring. Stick with a dedicated observability or heartbeat product when those are the center of the requirement. Also pause the selection if legal review requires a region or retention promise that the current contract cannot establish.

Cost attribution is the tie-breaker. During the trial, assign every emitted test signal to a synthetic tenant and import-run identifier, then ask finance or platform engineering to locate the corresponding usage without help from the implementer. If that path requires manual reconciliation across several dashboards, the architecture has already answered the question. If one key and bill simplify ownership but the external poller creates an on-call burden, count both sides. Your mileage may vary.

What about query filters and alert fatigue?

Do not add guessed query strings to /metrics/query. Its filter parameters are not declared, and the broad response should be treated as the documented starting point. Inspect the real response, select a numeric path deliberately, and keep the raw snapshot produced by the example while developing the parser. If precise server-side selection is mandatory, make schema confirmation a purchase gate rather than hiding the uncertainty in application code.

Alert fatigue is a policy problem after it is a data problem. A single zero may be valid for a low-volume tenant; three missed expected windows may be urgent. The facts establish the ability to report and query numbers, not a universal threshold. Define the expectation from the import schedule and business semantics, then have the external poller evaluate it. For "the job never ran," use the heartbeat lane. For "the job ran but returned zero," use the result lane. Clean split.

This design also stops latency and errors from swallowing the product signal. Duration answers "how long?" Errors answer "what failed?" The result counter answers "did the scheduled import create value?" Put all three on the admin view, but page only from an explicit policy whose owner is named. A dashboard without an owner is wallpaper.

References

Further reading

Use the Prometheus naming guidance above before freezing metric names, and use RFC 5424 when mapping log severity alongside the three numeric signals. Recheck each candidate's current region, retention, alerting, and billing documentation during the trial; those terms can change after publication.

Top comments (0)