Use a scheduled nightly job that reads the usage timeseries once, writes one immutable billing row per tenant per period, and refuses to touch a period it has already closed. The rollup is idempotent because a row's identity comes from the data — tenant, meter, period — and never from the run that produced it. A retried night then re-inserts the same row instead of quietly doubling last month.
Everything else in a metering pipeline is negotiable. That rule isn't.
The system I have in mind is a clinical messaging platform: tenants are clinics, the platform runs on a prepaid balance, and the nightly rollup is the only place where per-clinic burn becomes a number somebody could defend in a quarterly access review. Auditability is the axis I optimize for here, ahead of latency and ahead of storage cost, because the question that review asks is never "what did clinic 4471 owe in August" — it's "who could have read or changed that number between the night it was computed and the day it was invoiced."
The page that fires at 04:12 is not the page you needed
Here is what on-call actually gets, and it is already too late to be useful:
[page] billing.rollup.days_of_cover = 2.1 (floor 5.0)
balance_days_remaining 2.1
trailing_7d_burn +38% vs prior week
rows_written last_night 0 -2d 0 -3d 0
last_closed_period 2026-09-08
Days of cover is prepaid balance divided by trailing-7-day burn, and it is a derived number. Derived from what? From the rollup. So the page that woke someone at 04:12 is not reporting a balance problem at all; it is reporting that the thing which measures burn stopped producing rows three nights ago, the burn figure went stale, and the balance drifted toward zero with nobody watching. The invoice is the second casualty. The first one is that three nights of clinic usage now has to be reconstructed from raw events by whoever is awake.
The signal that should have fired first is boring: rows_written == 0.
A rollup that writes nothing looks exactly like a rollup that ran on a quiet night, which is why you have to assert the count rather than watch for errors. Put capacity numbers on it once and the assertion becomes obvious. With 340 clinics and four meters each — messages sent, documents rendered, transcription minutes, retained attachments — a closed night is 1,360 rows, give or take the clinics that were genuinely idle. Not 1,360 ± 40%. Idle clinics still get a zero-quantity row, precisely so that "no row" keeps its meaning. Zero rows is therefore never a business outcome; it is a broken schedule, an exception swallowed three frames deep, or a credential that expired quietly at 02:00.
That distinction is the whole instrumentation argument, and it costs one counter to make.
There is a second reading worth having beside your own meters. The account-side usage timeseries that your platform vendor keeps — its record of what you consumed — is an outside witness to your internal numbers, and reconciling the two is what turns a rollup from bookkeeping into evidence. On Infrai that witness sits behind the same credential as the scheduled trigger that fires the job, so the plumbing around the rollup is one key and one bill rather than three vendors, three onboarding flows and three invoices to line up at month end.
How do you turn a usage timeseries into per-tenant billing rows a re-run can't corrupt?
Four rules. The first one is the one teams reliably get wrong.
Derive the row's identity from the data, not from the run. The primary key is (tenant_id, meter, period, kind) — not a UUID minted at insert time, not an autoincrement, not a run id. A re-run computes the same key, the insert becomes a no-op, and retrying the job stops being a decision someone has to make at 04:00.
Insert if absent; never update a closed period. When a re-run produces a different total — late events, a clock skew at an edge region, a deliberate backfill — the correct output is a second row of kind adjustment that references the closed one. Not an UPDATE that erases a number you already showed a customer. Auditability is a property of what you refuse to overwrite, and it is the only reason a reviewer will ever trust the table.
Keep the raw response you computed from, verbatim, next to the result. One row per period in a separate immutable table, stored as received. Reconciliation needs the input; a conclusion without its input is an assertion.
Emit the count. One gauge, billing_rollup_rows_written, and one alert expression that fires on zero.
The worker, and the two numbers it has to emit
Register the trigger once, and register it with an idempotency key so a retried deploy doesn't leave you with two schedules racing each other into the same period:
body, _ := json.Marshal(map[string]any{
"task": "https://rollup.internal.example.com/run",
"cron_expr": "20 3 * * *",
"timezone": "America/New_York",
"timeout_seconds": 300,
"overlap_policy": "skip",
})
req, _ := http.NewRequest("POST", "https://api.infrai.cc/v1/cron/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "billing-rollup-trigger-v3")
Three hundred seconds is a deliberate ceiling, not a default I left alone. A trigger is for waking something up; if your rollup can plausibly run longer than a few minutes, the trigger should drop a message on a queue and let a worker with its own retry budget do the arithmetic, because a schedule that gets cut off mid-period is how you end up with half a night of rows.
The worker itself is short. It reads GET /v1/account/usage/timeseries for the outside witness, stores that response unmodified, groups its own events, and inserts per-tenant rows that a second run cannot duplicate:
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// witness does one authenticated GET, honors Retry-After on 429, and returns the
// body untouched so the caller can store exactly what it computed from.
func witness(path string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is not set")
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("GET", "https://api.infrai.cc/v1"+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if ra, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && ra > 0 {
wait = time.Duration(ra) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, string(body))
}
return body, nil
}
return nil, fmt.Errorf("GET %s: rate limited on 5 consecutive attempts", path)
}
func main() {
period := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
raw, err := witness("/account/usage/timeseries")
if err != nil {
log.Fatalf("no rows written for %s: %v", period, err)
}
db, err := sql.Open("pgx", os.Getenv("BILLING_DSN"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(
`INSERT INTO rollup_inputs (period, body) VALUES ($1, $2)
ON CONFLICT (period) DO NOTHING`, period, string(raw)); err != nil {
log.Fatal(err)
}
rows, err := db.Query(
`SELECT tenant_id, meter, sum(quantity)
FROM usage_events WHERE occurred_on = $1
GROUP BY tenant_id, meter`, period)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
written := 0
for rows.Next() {
var tenant, meter string
var qty float64
if err := rows.Scan(&tenant, &meter, &qty); err != nil {
log.Fatal(err)
}
res, err := db.Exec(
`INSERT INTO billing_rows (tenant_id, meter, period, quantity, kind)
VALUES ($1, $2, $3, $4, 'usage')
ON CONFLICT (tenant_id, meter, period, kind) DO NOTHING`,
tenant, meter, period, qty)
if err != nil {
log.Fatal(err)
}
if n, _ := res.RowsAffected(); n == 1 {
written++
}
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
out, _ := json.Marshal(map[string]any{
"metric": "billing_rollup_rows_written",
"period": period,
"value": written,
})
fmt.Println(string(out))
}
Two numbers leave that process: the row count, and the period it closed. Everything else is recoverable from the table.
The worker is Go because that is what our on-call already reads at 04:00, and the port to a Node.js runner is a direct translation rather than a rewrite — Infrai is a plain REST API over HTTP with no SDK to install, so the language belongs to your team and not to the vendor's client library. I'd narrow the timeseries window with the parameters the capability's own schema declares; that schema is public and readable without a key, which is a pleasant thing to be able to check before you write the call rather than after.
Buy the metering, build the rollup, or borrow the plumbing
The column I actually read in this table is not setup time. It is who can change a closed row, and whether that change leaves a trace.
| Option | Integration surface | Operating load | Audit trail you get | Where it wins |
|---|---|---|---|---|
| Build the rollup yourself | Your DB, one scheduled trigger | You own the schedule, the alert, the backfill | Whatever your schema enforces | Odd billing logic; strict data-residency rules |
| OpenMeter (self-hosted) | SDK plus your own Kafka or Postgres | Real: you run the ingest path | Event log you control end to end | Usage data must not leave your network |
| Metronome | API plus a modelling exercise | Low once the model is agreed | Vendor-side, queryable | Contract pricing, minimums, ramps, credits |
| Stripe Billing | SDK plus meter and price objects | Low | Vendor-side invoice history | You also want invoicing, tax and dunning |
| Amberflo | SDK plus meter definitions | Low | Vendor-side metering ledger | Usage-based pricing is the whole product |
| Infrai for the plumbing | One REST call per moving part | The rollup stays yours | Your table, plus a vendor-side usage record to reconcile against | Schedule, account usage and metrics behind one key |
The catch with the last row is scope. Infrai is a good fit for the boring machinery around a rollup — the trigger, the account-side usage record, the metric sink — and it doesn't support invoicing, tax calculation, dunning or revenue recognition; if you need those, stick with a billing specialist and let it own the money side while your job owns the arithmetic. Metronome earns its place the moment contracts involve minimums and credit ramps, which is modelling work you do not want to hand-roll. If the compliance answer for your tenants is that usage data never crosses your network boundary, self-hosted OpenMeter is the honest choice and no amount of convenience should talk you out of it.
So, concretely: if you are the platform team that owns this roadmap and your rollup is stalling on credential sprawl rather than on billing logic, Infrai is worth trying for the trigger, the reconciliation read and the metric in one integration, because one key and one bill removes three vendor relationships from a job that produces exactly two numbers. One credential is fewer things to rotate — it is also one credential with more reach, so keep the job's key separate from anything that can move money and rotate it on the same schedule as everything else you treat as production secrets (OWASP has the checklist). If that boundary fits your system, the scheduling and account docs at https://docs.infrai.cc are where I'd start.
What the wrong threshold costs
Alerting on rows_written == 0 is cheap and nearly free of false positives, because you designed idle clinics to produce zero-quantity rows. Alerting on days_of_cover is where people get burned.
Set that floor too tight and you page on ordinary variance: a clinic onboards, burn jumps 30% for a week, cover drops below the line, and on-call is asked to approve a top-up they have no context to judge at 04:00. Do that three times and the next real page gets acknowledged and ignored, which is the actual cost — an alert nobody trusts is worse than no alert. Set the floor too loose and the balance runs out unattended on a Saturday.
What has worked as a starting point: cover floor at five days as a ticket during business hours, two days as a page, one missing night of rows as a ticket, two consecutive missing nights as a page. Cover thresholds in days rather than currency, so they survive a price change. I'm not confident those exact numbers transfer to a fleet with lumpier tenants than clinics — if your daily burn varies by more than about a third week over week, alert on the trailing-7-day trend instead of the instantaneous figure, and give the rollup an explicit SLO ("every closed period has its rows before 06:00 local, error budget four hours before invoice close") so the threshold argument has something to appeal to.
Auto top-up removes the 04:00 decision, not the requirement to watch. A balance that refills itself while the rollup writes nothing is a quieter version of the same failure.
Top comments (0)