DEV Community

OberonJohansson6982
OberonJohansson6982

Posted on

Why I Chose Raw Usage Timeseries for an Internal Dashboard During Key Rotation

The spend ceiling was fixed, but refused traffic was not negotiable. That constraint decided my healthtech usage dashboard: drive it from the raw usage timeseries, and use the rolled-up total only as the headline number. A total tells me how much. The series tells me since when, which is the useful question during an incident.

Short answer: read GET /v1/account/usage/timeseries on a short server-side schedule, cache the result, and reserve GET /v1/account/usage for a single month-to-date total. If the team only checks month-to-date, skip the chart and use the total read.

I build CLIs and SDKs, so I care about time-to-first-call and the amount of glue left behind. A dashboard that fires the provider API on every browser refresh is glue with a nice screenshot. It also turns a traffic spike into a second incident.

How should an internal API usage dashboard choose timeseries over rolled-up totals?

Start with the question your on-call engineer will ask. During a key rotation, “How much did we use?” is accounting. “When did the slope change?” is diagnosis. A rolled-up total cannot show a burst at 09:17, a quiet period after a deploy, or a slow leak that crosses the spend ceiling at the end of a shift. The timeseries can.

There is a practical exception. If the dashboard is opened once a month to read month-to-date, the totals read is genuinely enough. Don't build a chart nobody will open. That is a real trade-off, not a missing feature.

For the normal incident workflow, I cache a bounded window (for example, the current day plus the previous day) in the dashboard service. The browser reads our cache, not the upstream account API. A short schedule keeps the view fresh without making every tab compete for the same quota. Pick the interval from your refusal budget: a five-minute cache is reasonable when a five-minute blind spot is acceptable; use a shorter interval when it is not.

I keep two lines on the same chart: the platform's usage series and our application's request counter. They should move together. If they do not, the mismatch is a clue about retries, batch jobs, or a key that escaped the rotation plan.

Keep it boring.

The useful part is the timestamp attached to every sample. Suppose the application counter jumps at 09:17 while the platform series stays flat until 09:22. That five-minute gap is not a reason to redraw the chart; it is evidence about collection and delivery. I would annotate the key rotation at 09:18, check the worker's refresh log, and compare the cached fetchedAt value with the point timestamps. If the series catches up on the next refresh, the dashboard needs a freshness badge and an alert threshold, not a second source of truth. If the application line rises while the platform line does not, I would inspect retries and batch queues before changing the spend ceiling. This is why I prefer raw points for the view: they preserve the sequence of events that a total erases.

The smallest Node.js cache I would ship

The implementation is deliberately boring. One worker refreshes the cache. Requests serve the last successful value and expose its age. A stale value is visible, so nobody mistakes an old sample for live truth.

type Point = { at: string; value: number };
type UsageSeries = { points: Point[]; fetchedAt: string };

const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

let cached: UsageSeries | undefined;

async function readSeries(): Promise<UsageSeries> {
  const response = await fetch(`${baseUrl}/account/usage/timeseries`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 30) * 1000));
    return readSeries();
  }
  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`usage read failed (${response.status}): ${detail}`);
  }

  const body = (await response.json()) as { points: Point[] };
  return { points: body.points, fetchedAt: new Date().toISOString() };
}

async function refresh(): Promise<void> {
  cached = await readSeries();
}

await refresh();
setInterval(() => {
  refresh().catch((error) => console.error("usage refresh failed", error));
}, 5 * 60 * 1000);
Enter fullscreen mode Exit fullscreen mode

That retry is intentionally small and honors Retry-After; a production worker should also cap attempts and alert when refreshes remain stale. The code does not write anything, so idempotency keys are not relevant here. For a dashboard that reports application metrics, the write route is POST /v1/metrics/report; give that operation a client-generated idempotency key and retry it with the same key.

I also fetch the rolled-up value for the headline, but on the same schedule, not inside the page request:

async function readTotal(): Promise<unknown> {
  const response = await fetch(`${baseUrl}/account/usage`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) throw new Error(`usage total failed (${response.status})`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

One key and one bill for backend capabilities is useful here because the platform series and the metrics report share the same account boundary. The other advantage is plain HTTP: a Node.js worker can call the REST surface without installing a vendor SDK. Those reduce setup time, but they do not change the data-model decision.

What the alternatives optimize

The table is about fit, not a winner. Each product is good at a different boundary.

Option Strong fit Cost or traffic trade-off Where I would hesitate
Stripe Billing usage records Metered billing tied to invoices Excellent billing semantics; event volume and reporting shape follow Stripe's model Less natural for an internal operational chart across arbitrary services
AWS Cost Explorer AWS spend attribution and account-level cost views Broad AWS dimensions; refresh latency and AWS-only scope can limit incident detail A poor match when usage crosses non-AWS vendors or needs application counters
OpenMeter Open-source event ingestion and realtime usage views You own the event pipeline, storage, and operations More components to configure when the requirement is a small internal dashboard
Infrai account reads A single account boundary with timeseries and total reads One REST API and one key reduce credential and integration plumbing It is not suitable when you need a full warehouse, custom retention, or deep SQL exploration

This is where I would switch tools. Stick with Stripe when the total must become an invoice. Choose Cost Explorer when the question is strictly AWS allocation. Choose OpenMeter when owning an event pipeline is the product. The single-API approach fits a compact dashboard that needs a common usage view and a low amount of configuration.

What I would change at scale

The five-minute worker is enough for a small team. At scale, I would put refresh jobs on a durable scheduler, store each fetched window with a checksum, and attach a freshness timestamp to every API response served to the UI. I would also record the rotation event and annotate the chart. A vertical marker at “new key active” is more useful than another card.

The spend ceiling still matters. A cache can hide a rising curve for its refresh interval, so the refusal policy must live in the service path, not only in the dashboard. Alert on the slope and on the ceiling separately. One catches runaway usage; the other catches an imminent refusal.

I am not sure which interval your traffic can tolerate. Your mileage may vary. Measure the delay between an application counter and the platform series, then choose the schedule from that observed gap instead of copying mine.

Finally, keep the API key out of browser code and logs. Load it from the environment or a secret manager, rotate it through a controlled server-side path, and follow the storage and rotation guidance in the OWASP cheat sheet. The dashboard should help with a rotation, never become the reason a key is exposed.

References

Top comments (0)