DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

5 Rules for Metering Internal API Usage: Raw Events, One Cache, One Nightly Schedule

Pick the least complicated read path that still survives an audit: append every billable call to a raw event log, roll those events up on a schedule into per-customer totals, and point the internal usage dashboard at the rolled-up totals behind a short cache. The raw timeseries stays in the system as evidence. The dashboard just doesn't query it.

Take a property-management SaaS that charges per API call — door-code pushes, tenant directory sync, monthly statement generation. The usage dashboard and the metered invoice are then the same number seen from two angles, and that changes what "good enough" means. A dashboard can lag ten minutes and nobody cares. An invoice line nobody can reconstruct is a refund, a support thread, and a customer who now checks every bill by hand.

Dashboard read path What it costs to build Where it breaks
Query the raw event log on every page load Almost nothing Scan cost grows with call volume; one slow dashboard tab becomes a slow database for the whole app
Scheduled rollups into a totals table, cached One job, one table, one cache key Totals lag the schedule; a correction means re-running a window
In-process counters only Cheapest read of all Nothing to recompute from when a counter drifts or a pod restarts mid-increment
Report each call straight to the billing provider No storage at all Dashboard and invoice disagree the moment one send fails, and you can't prove which is right

Row two is the one I'd build for a metered invoice, and I'd build it in that order: raw first, rollup second, cache last. The two criteria that decide it are whether you can restate a disputed month, and how much damage a single leaked credential does. Everything else — query latency, dashboard framework, chart library — is downstream of those two.

1. The raw event log is the only thing you're allowed to call the truth

One row per billable call, written once, never updated. Tenant id, meter name, quantity, an event id from the caller for deduplication, and the timestamp the call actually happened rather than the time your worker got around to it. That last distinction is what lets a late-arriving batch land in the correct billing period instead of inflating the current one.

Retention is a decision, not a default. Thirty-five days of raw events covers a full billing cycle plus a few days of dispute window, and after that the rolled-up totals plus a cold archive are enough. Keeping raw rows forever feels safer and quietly becomes the largest table you own.

The event id matters more than it looks. Retries happen at every layer — client, gateway, queue — and without a unique constraint on (tenant_id, event_id) a network blip turns into double billing, which is the one bug your customers will find before you do.

2. Should the dashboard read raw timeseries or rolled-up totals?

Rolled-up totals, for anything a customer can see or be charged for. Raw timeseries, for the debugging view you open when someone disputes a number.

The reason isn't performance, though the performance argument is real. It's that a rollup is a decision you made at a known time, with a known version, over a known window — and you can print it, store it, and hand it to a customer. A live aggregate over raw rows is a different number every time the page loads, which is fine for a traffic graph and terrible for a line item. Two people looking at the same tenant on the same afternoon should see the same total, or the first billing dispute will teach you why.

The compromise that keeps both is a rollup row that carries its own version, so a corrected month is a new version rather than an overwrite of history.

OpenTelemetry's metrics data model draws the same line in different words: delta and cumulative temporality describe what a reported number means over a window, and mixing them without saying which is which is how dashboards start disagreeing with each other. Billing has exactly that failure mode, with money attached.

3. Scope the dashboard's credential to reads, and keep it away from ingest

This is the axis I'd weigh above everything else, because it's the one that can't be fixed after the fact. Ask what one leaked key buys an attacker.

A single key that can both write usage events and read every tenant's totals is the worst possible one to lose: whoever has it can inflate a competitor's bill, deflate their own, and enumerate your customer list on the way out.

Split it.

The ingest path gets a write-only credential per service, the dashboard gets a read credential scoped to one tenant at a time, and the rollup job gets a third that can read raw events and write rollups but cannot serve HTTP. Three credentials, three blast radii, and a rotation you can do one at a time without a maintenance window.

The OWASP secrets management guidance is worth reading end to end here, but the operational part that actually changes your architecture is short: secrets get a defined lifetime, a defined owner, and an automated rotation path, which means your code has to tolerate a credential changing under it while requests are in flight.

HashiCorp Vault and Infisical both issue short-lived database credentials for exactly this pattern; the trade-off is another component in the request path and a startup dependency your rollup job now has. A one-person team can reasonably start with per-role static secrets in the platform's own secret store and a scripted rotation, as long as the split by role is there from day one. Retrofitting the split later means touching every call site.

4. Roll up on a schedule you can re-run without flinching

An idempotent job over a closed window. That's the whole design. It reads raw events between two timestamps, aggregates by tenant and meter, and upserts the result — so running it twice produces the same row, and running it for last Tuesday produces last Tuesday's corrected number.

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.USAGE_DB_URL });

type Window = { from: Date; to: Date };

// Recomputes one closed window. Safe to run twice, safe to run late.
// Requires a unique index on (tenant_id, meter, window_start).
export async function rollUp({ from, to }: Window, version: number): Promise<number> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const result = await client.query(
      `INSERT INTO usage_rollup
            (tenant_id, meter, window_start, window_end, quantity, rollup_version, computed_at)
       SELECT tenant_id, meter, $1::timestamptz, $2::timestamptz, sum(quantity), $3, now()
         FROM usage_event
        WHERE occurred_at >= $1 AND occurred_at < $2
        GROUP BY tenant_id, meter
       ON CONFLICT (tenant_id, meter, window_start)
       DO UPDATE SET quantity       = excluded.quantity,
                     rollup_version = excluded.rollup_version,
                     computed_at    = now()`,
      [from, to, version],
    );
    await client.query("COMMIT");
    return result.rowCount ?? 0;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Schedule it nightly against yesterday's UTC window, then re-run the trailing three days every night as well. Late events land, corrections apply, and the cost is a few extra seconds of database time on a table you already indexed. The alternative — a watermark that advances and never looks back — is faster and loses every event that arrives out of order.

Pick UTC day boundaries and write them into the rollup row explicitly. Local time zones in a billing window are a trap: a property manager in Denver and one in Boston will both insist their month ended at midnight, and the only defensible answer is a boundary you can point at in the data. Postgres materialized views handle the same job if your windows are fixed and your data never arrives late, and a continuous-aggregate feature in a timeseries extension handles it with less code — the catch is that both make the refresh policy the vendor's business rather than yours, which is a bad trade when the output is an invoice.

Test the boundaries, not the happy path. Node's built-in test runner is enough: assert that an event at exactly window_end lands in the next window, that a duplicate event id doesn't double the total, and that a second run of the same window changes nothing.

5. Cache the totals, not the query

The cache key should name the thing being cached, including the rollup version. Then a completed rollup invalidates by bumping a version counter instead of chasing individual keys, and a stale total can't outlive the correction that replaced it.

import { createClient } from "redis";
import { readRollup, type Totals } from "./rollup-read.js";

const redis = createClient({ url: process.env.USAGE_CACHE_URL });
const TTL_SECONDS = 60;

export async function totalsFor(tenantId: string, period: string): Promise<Totals> {
  const version = (await redis.get(`rollup:version:${period}`)) ?? "0";
  const key = `totals:${period}:v${version}:${tenantId}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Totals;

  // Always the rollup table, never the raw event log.
  const fresh = await readRollup(tenantId, period);
  await redis.set(key, JSON.stringify(fresh), { EX: TTL_SECONDS });
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

Sixty seconds is a starting point, not a law — an internal dashboard that five people open can probably go higher, and your mileage may vary with how twitchy your customers are about live numbers. What matters is that the number is reproducible at any TTL, because it came from a stored rollup rather than from a query that races the writer.

Put the same reasoning in the HTTP layer. Cache-Control: private, max-age=60 on the dashboard response keeps per-tenant totals out of shared caches, and stale-while-revalidate gives you a fast paint without serving a number that nobody can reproduce. RFC 9111 spells out the freshness rules; the part people skip is that private is doing security work, not just performance work, when the payload is one customer's usage.

When the simpler path wins

Skip the rollup table entirely if your event volume is small, your dashboard is internal-only, and nothing in it reaches an invoice. A few thousand rows a day answered by a live GROUP BY is less machinery than a scheduled job, a version counter, and a cache — and less machinery is the correct default for a one-person team measuring everything in hours it doesn't have to spend.

The line I'd draw: the moment a number in that dashboard becomes a number on a bill, the rollup stops being optional.

Managed metering exists for teams that would rather not own any of this. OpenMeter is open source and handles ingestion and aggregation as a service you run or subscribe to; Stripe's usage-based billing is driven by meter events your service reports, so the aggregation window and the deduplication key remain your engineering problem either way. Neither removes the credential split, and neither answers the question of what happens when your event stream and their totals disagree. That reconciliation job is yours no matter whose infrastructure does the counting.

I'm not certain there's a single right retention number, and I'd be suspicious of anyone who is. Thirty-five days fits a monthly cycle with a dispute window on top. Annual contracts with quarterly true-ups need something longer, and a free internal tool needs a lot less.

References

Top comments (0)