Usage tracking and metering is the process of measuring, recording, and aggregating resource consumption in a SaaS platform so that customers can be billed accurately and operators can understand capacity demands. Unlike monitoring (which tracks system health) or audit logging (which tracks user actions), metering tracks quantitative consumption: how many queries a tenant ran, how many tokens their AI answers consumed, how many bytes of storage they used, and how many hours their clusters ran. These usage metrics are the source of truth for billing — every dollar on the invoice traces back to a metered event.
In a SaaS management platform, usage metering tracks three primary dimensions: queries executed (broken down by type — search, analytics, admin), AI-generated answers (measured in tokens consumed by the LLM inference pipeline), and storage consumed (bytes stored across all of a tenant’s clusters). Usage events are collected in real time, aggregated into time buckets (hourly and daily), and exposed through both a customer-facing usage dashboard and an internal billing pipeline. A background cleanup job prunes granular usage records after aggregation, keeping storage costs manageable while preserving the aggregated totals needed for invoicing.
This topic covers the architecture of a usage metering pipeline, time-bucketed aggregation, idempotent event recording, billing integration, and the cleanup strategies that keep the metering system sustainable at scale.
A usage event is a single, granular record: “tenant X executed a search query at 14:32:07 that scanned 2.3 MB.” Storing every individual event forever is impractical — a busy tenant might generate millions of events per day. Instead, events are aggregated into time buckets: “tenant X executed 4,207 search queries in the 14:00–15:00 hour, scanning a total of 8.7 GB.” The aggregated metrics are what billing and dashboards consume. The raw events are retained briefly (hours to days) for debugging and reconciliation, then purged.
Usage is aggregated into fixed time windows — typically hourly buckets for operational reporting and daily buckets for billing. Each bucket is identified by its start timestamp (e.g., 2024-03-15T14:00:00Z) and contains counters for each metric dimension. Hourly buckets enable near-real-time usage dashboards; daily buckets align with billing cycles. The aggregation is incremental: as new events arrive, counters in the current bucket are atomically incremented rather than recalculated from scratch.
Network retries, service restarts, and at-least-once message delivery can cause the same usage event to be recorded multiple times. Double-counting directly impacts billing accuracy — a customer charged twice for the same query will lose trust and file disputes. Idempotent recording uses a unique event ID (typically the request ID) and an upsert pattern: INSERT ... ON CONFLICT (event_id) DO NOTHING. If the same event arrives twice, the second insert is silently ignored.
SaaS billing often depends on multiple independent dimensions. The platform meters queries (count and type), tokens (LLM input + output tokens for AI answers), and storage (peak bytes per day). Each dimension has its own unit, rate, and aggregation method. Queries and tokens are summed over the billing period; storage is sampled at peak daily value and averaged. The metering system must track each dimension independently while associating all dimensions with the same tenant and time bucket.
Granular usage events serve their purpose once aggregated — keeping them indefinitely wastes storage and slows queries against the usage table. A cleanup job runs daily, deleting raw events older than a configurable window (e.g., 7 days) while preserving the aggregated hourly and daily records. The aggregated records are retained for the billing retention period (typically 13 months for annual comparisons). This tiered retention keeps the metering system fast and affordable.
The platform’s metering pipeline has four stages: capture (recording individual usage events), aggregate (rolling events into time buckets), serve (exposing aggregated data to dashboards and billing), and clean (purging stale granular data). Each stage operates independently, connected by the database as the shared state store.
-- Granular usage events — high volume, short retention.
CREATE TABLE usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL, -- "query", "answer", "storage_sample"
metric VARCHAR(50) NOT NULL, -- "query_count", "tokens", "bytes_stored"
value BIGINT NOT NULL, -- the measured quantity
dimensions JSONB, -- {"query_type":"search","model":"gpt-4"}
event_id VARCHAR(255) UNIQUE, -- idempotency key
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Aggregated hourly buckets — medium volume, long retention.
CREATE TABLE usage_hourly (
tenant_id UUID NOT NULL,
metric VARCHAR(50) NOT NULL,
bucket TIMESTAMPTZ NOT NULL, -- start of the hour
value BIGINT NOT NULL DEFAULT 0,
dimensions JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, metric, bucket, dimensions)
);
-- Aggregated daily buckets — low volume, billing retention.
CREATE TABLE usage_daily (
tenant_id UUID NOT NULL,
metric VARCHAR(50) NOT NULL,
bucket DATE NOT NULL,
value BIGINT NOT NULL DEFAULT 0,
dimensions JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, metric, bucket, dimensions)
);
CREATE INDEX idx_usage_events_tenant_time ON usage_events (tenant_id, created_at);
CREATE INDEX idx_usage_hourly_tenant ON usage_hourly (tenant_id, bucket DESC);
CREATE INDEX idx_usage_daily_tenant ON usage_daily (tenant_id, bucket DESC);
// internal/metering/recorder.go
package metering
import (
"context"
"time"
)
type Recorder struct {
events chan UsageEvent
store Store
logger *zap.Logger
}
type UsageEvent struct {
TenantID string
EventType string
Metric string
Value int64
Dimensions map[string]string
EventID string // idempotency key
}
func NewRecorder(store Store, logger *zap.Logger, bufSize int) *Recorder {
return &Recorder{
events: make(chan UsageEvent, bufSize),
store: store,
logger: logger.Named("metering"),
}
}
// Record enqueues a usage event. Non-blocking — drops if buffer full.
func (r *Recorder) Record(event UsageEvent) {
select {
case r.events <- event:
default:
r.logger.Warn("usage event dropped: buffer full",
zap.String("tenant_id", event.TenantID),
zap.String("metric", event.Metric),
)
}
}
// Run processes events in batches and writes them to the store.
func (r *Recorder) Run(ctx context.Context) {
batch := make([]UsageEvent, 0, 200)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case ev := <-r.events:
batch = append(batch, ev)
if len(batch) >= 200 {
r.flush(ctx, batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
r.flush(ctx, batch)
batch = batch[:0]
}
case <-ctx.Done():
// Drain on shutdown.
close(r.events)
for ev := range r.events {
batch = append(batch, ev)
}
if len(batch) > 0 {
r.flush(context.Background(), batch)
}
return
}
}
}
func (r *Recorder) flush(ctx context.Context, batch []UsageEvent) {
if err := r.store.InsertEvents(ctx, batch); err != nil {
r.logger.Error("failed to flush usage events",
zap.Int("batch_size", len(batch)),
zap.Error(err),
)
}
}
// internal/metering/aggregator.go
func (a *Aggregator) AggregateHour(ctx context.Context, hour time.Time) error {
// Truncate to the start of the hour.
bucket := hour.Truncate(time.Hour)
// Aggregate raw events into hourly buckets using an upsert.
query := `
INSERT INTO usage_hourly (tenant_id, metric, bucket, value, dimensions, updated_at)
SELECT
tenant_id,
metric,
date_trunc('hour', created_at) AS bucket,
SUM(value) AS value,
dimensions,
NOW()
FROM usage_events
WHERE created_at >= $1 AND created_at < $2
GROUP BY tenant_id, metric, date_trunc('hour', created_at), dimensions
ON CONFLICT (tenant_id, metric, bucket, dimensions)
DO UPDATE SET
value = usage_hourly.value + EXCLUDED.value,
updated_at = NOW()
`
_, err := a.db.ExecContext(ctx, query, bucket, bucket.Add(time.Hour))
if err != nil {
return fmt.Errorf("aggregate hour %s: %w", bucket, err)
}
a.logger.Info("hourly aggregation completed",
zap.Time("bucket", bucket),
)
return nil
}
// internal/api/query_handler.go
func (h *QueryHandler) HandleQuery(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
actor := auth.ActorFromContext(ctx)
req := parseQueryRequest(r)
result, err := h.queryEngine.Execute(ctx, req)
if err != nil {
respondError(w, err)
return
}
// Record usage: query count.
h.metering.Record(metering.UsageEvent{
TenantID: actor.TenantID,
EventType: "query",
Metric: "query_count",
Value: 1,
Dimensions: map[string]string{
"query_type": req.Type, // "search", "analytics", "admin"
},
EventID: middleware.RequestIDFromContext(ctx),
})
// Record usage: tokens consumed (if AI answer was generated).
if result.TokensUsed > 0 {
h.metering.Record(metering.UsageEvent{
TenantID: actor.TenantID,
EventType: "answer",
Metric: "tokens",
Value: int64(result.TokensUsed),
Dimensions: map[string]string{
"model": result.ModelUsed,
},
EventID: middleware.RequestIDFromContext(ctx) + ":tokens",
})
}
respondJSON(w, result)
}
// internal/metering/cleanup.go
func (c *Cleaner) PurgeOldEvents(ctx context.Context) error {
cutoff := time.Now().UTC().Add(-c.eventRetention) // e.g., -7 days
result, err := c.db.ExecContext(ctx,
"DELETE FROM usage_events WHERE created_at < $1", cutoff,
)
if err != nil {
return fmt.Errorf("purge events: %w", err)
}
rows, _ := result.RowsAffected()
c.logger.Info("usage event cleanup completed",
zap.Int64("deleted", rows),
zap.Time("cutoff", cutoff),
)
return nil
}
The platform’s metering data flows into three downstream systems. The customer-facing Usage Dashboard shows real-time and historical usage broken down by metric type, with hourly granularity for the current billing period and daily granularity for previous periods. Customers use this to understand their consumption patterns, forecast upcoming bills, and identify unexpected usage spikes — for example, a runaway integration that fires 100x more queries than expected.
The internal billing pipeline reads daily aggregated usage at the end of each billing cycle, applies the tenant’s pricing plan (per-query rates, token pricing tiers, storage per-GB rates), and generates an invoice. The pipeline includes a reconciliation step that compares metered usage against Prometheus metrics as a sanity check — if the metered query count diverges from the Prometheus request counter by more than 1%, the billing run is flagged for manual review.
The capacity planning system uses aggregated usage trends to forecast infrastructure needs. By analyzing weekly growth rates in queries, tokens, and storage across all tenants, the platform team can predict when additional database nodes, GPU instances, or storage volumes will be needed — weeks before capacity limits are hit.
When usage events arrive through a message queue or are retried after transient failures, duplicates are inevitable. Use the request ID or a deterministic event ID as an idempotency key with ON CONFLICT DO NOTHING for raw events. For aggregation upserts, use ON CONFLICT DO UPDATE SET value = value + EXCLUDED.value — this is safe because the aggregation query runs over the same time window each time (the raw events are idempotent, so re-aggregating produces the same delta).
The write path (recording events, aggregating buckets) and the read path (dashboard queries, billing exports) have very different access patterns. The write path is high-throughput, append-only, and latency-insensitive. The read path is lower-throughput but requires fast, indexed lookups by tenant and time range. Use separate database connections (or connection pool priorities) for reads and writes to prevent write-heavy aggregation jobs from starving dashboard queries.
Metering systems can drift from reality due to bugs, dropped events, or race conditions. Cross-check metered totals against independent data sources — Prometheus request counters, API gateway access logs, or infrastructure provider billing data. Build the reconciliation check into the billing pipeline so discrepancies are caught before invoices are sent, not after.
// internal/billing/reconcile.go
func (r *Reconciler) Check(ctx context.Context, tenantID string, period BillingPeriod) error {
metered, err := r.meteringStore.QueryTotal(ctx, tenantID, "query_count", period)
if err != nil {
return err
}
prometheus, err := r.prometheusClient.QueryRange(ctx,
fmt.Sprintf(`sum(increase(mydb_api_requests_total{tenant="%s"}[1h]))`, tenantID),
period.Start, period.End,
)
if err != nil {
return err
}
drift := math.Abs(float64(metered-prometheus)) / float64(prometheus)
if drift > 0.01 { // >1% discrepancy
return fmt.Errorf("metering drift %.2f%% for tenant %s: metered=%d prometheus=%d",
drift*100, tenantID, metered, prometheus)
}
return nil
}
Without idempotent event recording, a network retry that re-sends the same usage event inflates the customer’s bill. This is the most common and most damaging metering bug. Every usage event must have a unique, deterministic ID derived from the operation that generated it (the request ID is ideal). Use database-level uniqueness constraints — application-level dedup checks are vulnerable to race conditions.
If the aggregation job uses local time for bucket boundaries and the server’s time zone changes (DST transition, VM migration), buckets overlap or have gaps. Always use UTC for bucket timestamps. Store all timestamps as TIMESTAMPTZ in PostgreSQL and truncate to hour/day boundaries in UTC. Display local time in the customer-facing dashboard, but never use local time internally.
If the hourly aggregation job runs at 15:00:00 for the 14:00–15:00 bucket, events that arrive with a few seconds of clock skew or network delay after 15:00:00 are missed. Add a lag window: aggregate the 14:00–15:00 bucket at 15:05:00, giving late-arriving events a 5-minute grace period. Use the upsert pattern so re-running aggregation for the same bucket is safe.
If dimensions include high-cardinality values (individual query IDs, user IDs, or full query text), the aggregated usage table explodes in size. Dimensions should be low-cardinality grouping keys — query type (search, analytics, admin), model name (gpt-4, claude-3), storage tier (hot, cold). High-cardinality details belong in the raw event (which is cleaned up quickly), not in the aggregated buckets.
When the metering pipeline is down (database outage, deployment bug), usage events are lost. Without a backfill strategy, the billing period has gaps that either undercharge the customer (revenue loss) or require manual adjustment (operational overhead). Retain enough data in secondary sources (API gateway logs, Prometheus metrics) to reconstruct metered usage for backfill. Build and test the backfill procedure before you need it.