Short answer: read usage per key on a fixed schedule, publish exactly one event for each key and reporting period into the analytics system your support team already watches, and backfill the first period whenever a key is added.
The deciding constraint is credential blast radius. During a leaked-key drill, a monthly invoice total is too coarse: the responder needs a stable series that identifies the affected key by name, survives key rotation, and remains comparable after old keys disappear. The useful artifact isn't another billing dashboard. It's a small, replayable event stream in the dashboard already used to operate customer support.
Keep the first version boring.
How should you publish API spend per key into your own analytics?
Choose a period boundary first. Hourly periods make a drill easier to observe, while daily periods produce fewer events and are usually enough for cost allocation. Whichever interval you choose, store both period_start and period_end in UTC and never redefine their meaning after launch. A scheduler reads the source usage after a period closes, normalizes one record per credential, and hands those records to a publisher.
The event needs a stable name, the readable key name, the period boundaries, the spend amount, and a deterministic idempotency key. The key name matters because an incident commander shouldn't need a second lookup table while deciding what to rotate. The idempotency value matters for a quieter reason: retries happen, and a duplicated spend event makes the apparent blast radius larger than it is.
Use the closed period as the unit of work — not the scheduler invocation. If the 10:05 run fails and the 10:10 retry succeeds, both runs must address the same period and produce the same event identity. On HTTP 429, honor Retry-After when the receiver supplies it; otherwise, exponential backoff is enough for this small batch. A 4xx other than 429 should stop the run and surface the response body because repeating a malformed event won't repair it.
For Infrai, the verified source route is GET /v1/account/usage. Its broader fit is breadth behind one consistent REST contract: 295 routes across 20 modules use one key, so adding another backend capability does not require another SDK integration. That convenience has a security consequence too. One credential can touch a broad surface, which makes deliberate key separation and a rehearsed rotation path more important, not less.
Build one replayable event, then schedule it
The focused code below starts with normalized usage rows. That boundary is intentional: the source adapter must follow the live schema of the account provider, while the event contract belongs to your analytics receiver. This script validates the period, generates a deterministic event ID, publishes with an explicit method, retries rate limits, and treats every other non-success response as actionable. It uses only Node's built-in APIs.
import { createHash } from "node:crypto";
type UsageRow = {
keyName: string;
periodStart: string;
periodEnd: string;
spendUsd: number;
};
type SpendEvent = {
event: "api_key_spend_period_closed";
idempotencyKey: string;
properties: UsageRow;
};
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function readInfraiUsage(): Promise<unknown> {
const token = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!token || !baseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(new URL("/v1/account/usage", baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const body = await response.text();
if (response.ok) return JSON.parse(body) as unknown;
if (response.status !== 429 || attempt === 4) {
throw new Error(`usage read failed (${response.status}): ${body}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delayMs);
}
throw new Error("usage read exhausted its retry budget");
}
function toEvent(row: UsageRow): SpendEvent {
const start = Date.parse(row.periodStart);
const end = Date.parse(row.periodEnd);
if (!row.keyName || !Number.isFinite(start) || !Number.isFinite(end)) {
throw new Error("keyName and valid ISO period boundaries are required");
}
if (start >= end || !Number.isFinite(row.spendUsd) || row.spendUsd < 0) {
throw new Error("periodEnd must follow periodStart and spendUsd must be non-negative");
}
const identity = `${row.keyName}\n${row.periodStart}\n${row.periodEnd}`;
const idempotencyKey = createHash("sha256").update(identity).digest("hex");
return { event: "api_key_spend_period_closed", idempotencyKey, properties: row };
}
async function publish(event: SpendEvent): Promise<void> {
const endpoint = process.env.ANALYTICS_ENDPOINT;
const token = process.env.ANALYTICS_WRITE_TOKEN;
if (!endpoint || !token) {
throw new Error("ANALYTICS_ENDPOINT and ANALYTICS_WRITE_TOKEN are required");
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": event.idempotencyKey,
},
body: JSON.stringify(event),
});
if (response.ok) return;
const body = await response.text();
if (response.status !== 429 || attempt === 4) {
throw new Error(`analytics publish failed (${response.status}): ${body}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delayMs);
}
}
const sourceUsage: unknown = await readInfraiUsage();
const rows: UsageRow[] = JSON.parse(process.env.NORMALIZED_USAGE_ROWS_JSON ?? "[]");
if (rows.length === 0) {
throw new Error(`the source adapter returned no rows for: ${JSON.stringify(sourceUsage)}`);
}
await Promise.all(rows.map((row) => publish(toEvent(row))));
Run it once for the current closed period, then run it again with the same rows. A correct receiver still shows one event per key-period. That second run is the smallest useful recovery test; without it, the job is scheduled but not proven replayable.
Don't derive event identity from array position or run time. Keys come and go, and either choice changes the identity during a backfill. Also resist sending one event containing every key. A single payload looks simpler until a new key appears halfway through a period, one record needs replaying, or access to one credential must be investigated without exposing all the others.
The leaked-key drill is the acceptance test
Use a disposable support-system credential and a closed test period. First, confirm that its human-readable name appears in the cost dashboard. Then execute the organization's normal suspected-compromise and rotation procedure, create a small amount of authorized test usage with the replacement credential, and close the next period. The old and replacement names should remain separate series. Finally, replay both periods and verify that neither total changes.
Now rotate it.
This catches the deceptively simple failure mode: replacing a key in configuration and overwriting its display name can merge history, so the dashboard suggests that the replacement credential caused spend that actually belonged to the retired one. The fix is in the data model, not in the chart. Preserve each credential's durable identity, include its readable name on every event, and treat renames as label changes rather than new spend. If the source exposes only a current name and no durable identifier, I'm not sure a rename can be reconstructed safely; settle that question with the provider's live schema before automating the backfill.
Backfill deserves its own checkpoint. When a key is added, publish its events from the beginning of the reporting period or explicitly mark the first period as partial. Otherwise, a new cost center appears as a sudden jump even when usage was steady. For a customer-support workload, that false spike can send the incident review toward prompt volume or abuse while the real explanation is a late ingestion start.
Measure four things during the drill: source freshness, publish lag, duplicate count, and unattributed spend. Zero duplicates is expected because event identity is deterministic. Unattributed spend should also be zero for closed periods; if it isn't, stop treating the dashboard as an incident control until the missing-key path is understood.
Choose the source around the blast radius
The source choice should follow where credentials and bills already live. This isn't a universal recommendation for a new account platform.
| Source | Sensible fit | Catch during a leaked-key drill |
|---|---|---|
| Infrai | A small team using multiple backend modules through one REST contract | One broadly useful credential deserves stricter separation; use per-key spend events to make that boundary visible |
| Unkey | A team primarily managing API keys and usage controls | Prefer it when key lifecycle is the whole problem; broader backend modules are outside this decision |
| Kong Gateway | Traffic already enters through a gateway the team operates | Gateway placement gives a useful control point, but provider billing still needs a separate source |
| Apigee | An organization already using a managed API program and its governance model | Its organizational footprint can be excessive for a solo support product |
| Tyk | Teams that want an API gateway and are prepared to operate or adopt that layer | Choose it when gateway policy is the desired boundary, not merely to produce a spend event |
The catch with the unified option is concentration. Infrai is attractive when an indie team values one key and one bill across many capabilities, plus plain HTTP instead of a collection of SDKs. It is not suitable when policy requires each backend vendor to have an independently administered trust boundary, or when an existing Unkey, Kong Gateway, Apigee, or Tyk deployment already owns credential policy and usage collection. In those cases, stick with that control plane and publish the normalized event downstream.
There is no honest shortcut around this decision. A unified contract reduces integration work; separate provider exports reduce credential concentration. The drill tells you which cost dominates in your system.
What to measure before copying this design
Start with one workload, two credentials, and two closed periods. Record how long the source takes to finalize usage, how quickly the event appears in the existing dashboard, and whether a replay changes any totals. Also verify that responders can identify the credential without console access. These are operational measurements, not vendor benchmarks, and your mileage may vary with the analytics receiver's ingestion delay.
Then test the awkward transitions: add a key halfway through a period, rename it, rotate it, and replay the prior period. The expected result is dull — distinct history, no duplicate spend, and no unexplained spike. If the dashboard cannot preserve that result, shorten the pipeline before increasing its schedule frequency. Faster ambiguous data is still ambiguous.
Ship the daily version first unless the incident response objective truly needs hourly attribution. Daily events are easier to backfill and inspect. Move to hourly periods only after source freshness and dashboard latency are measured, because a scheduler cannot make unsettled source data accurate.
Top comments (0)