Multi-Dimensional Meters — Technical Design
- Decided: one meter takes usage events with dimension labels, such as
model. Pricing rules match sets of labels, and the highest-priority matching rule claims each event, so no event is priced twice.- Shipped: v0.1.0, in #20 (migration
0054_multi_dim_meters).- Current docs: How Hardcount picks the price for an event in the AI pricing reference. The decisions made since are listed below. Where this page and the code disagree, the code wins.
Design document from 2026-04-25 (the proposal as reviewed before building). It is not kept current.
Current design: ADR-044 (canonical
tokensmeter +token_typedimension), ADR-045 (decimal per-unit rates; cycle billing prices multi-dim meters), ADR-070 (a rule is priced at the version of itsrule_keyin force when the period opened), and ADR-130 (usage prices belong to the plan; see item 5 below), plus the package docs ininternal/usage/andinternal/billing/.Known out of date in the body below:
- The examples’
operation+ booleancacheddimensions were replaced by ADR-044’s singletoken_typeenum (input, output, cache_read, cache_write_5m, cache_write_1h). The mechanism (one meter, N dimension-matched rules, priority+claim) is unchanged.- A matched rule is not priced at a stored rating-rule version. Its
rule_keyis looked up as of the period start (ADR-070), so a newly published rate applies from the next period.- Until ADR-045 (2026-06-01), only the preview and the threshold scan priced multi-dim meters; the cycle close billed them at $0. It now emits one line per claimed rule.
- A meter has one aggregation (
sum,count,max,last) and every rule bills under it (ADR-130 §2, 2026-10-23). The per-ruleaggregation_mode,last_during_periodandlast_everare gone,lastbreaks a timestamp tie by id, and the meter’skeyisevent_name. The aggregation is fixed once the meter has usage events (held rows do not count, amended 2026-10-24).- Rating rules,
priority,rule_keyand the meter’s default price are gone (ADR-130 PR-4, #1386, 2026-10-26). A plan’s usage prices live on the plan, each on one meter with adimension_match. A price takes an event whose labels include all of itsdimension_match. When several do, the one with the most labels bills it, and two that could tie are refused (409price_conflict). See How Hardcount picks the price for an event.
This page is the technical design for multi-dimensional metering: one usage meter that carries many differently-priced slices of usage, distinguished by dimension labels such as model or operation. It is written for engineers building on or evaluating Hardcount’s pricing engine.
Motivation
Section titled “Motivation”Stripe’s Meter API was bolted onto a card-subscription engine. To bill gpt-4 input cached vs gpt-4 input uncached vs gpt-4 output at different rates, you create three Meters with three event names and three pricing rules. For full Anthropic / OpenAI parity (3 models × 4 operations × 2 cache states), you need ~24 Meters. The dimensional structure is encoded in event-name strings instead of data.
Hardcount’s wedge — the product bet it differentiates on — is AI-native billing for usage-heavy SaaS. The minimum-viable expression of that is: one meter receives events with arbitrary dimension labels, many pricing rules pick out subsets to apply rates. Same data, far fewer meters, far simpler subscription wiring.
Without this design, the wedge is just a slide.
- Single meter per usage type (
tokens,requests,gb_hours) - Events carry arbitrary dimensions (
{model, operation, cached, tier}) on the existingproperties JSONBcolumn (JSONB is Postgres’s binary JSON column type) - Pricing rules match dimension subsets and apply rates
- Aggregation modes per rule, not per meter —
sum,count,last_during_period,last_ever,max— a top-priority feature gap versus Stripe, pulled forward into this design (Stripe Tier 1 gap, hoisted) - Decimal quantities — arbitrary-precision
NUMERICinstead of integerBIGINT(Stripe Tier 1 gap, hoisted) - Forward-compatible with existing
usage_events/meters/rating_rule_versions(versioned rate configurations) schema (no breaking change for current tenants) - Sustained 50k events/sec ingest on a single tenant on commodity Postgres
Non-goals (deferred)
Section titled “Non-goals (deferred)”- Streaming meter events (Stripe v2 stream API) — Phase 4 (a later roadmap phase)
- Bulk S3 ingest — Phase 4
- Server-sent / push aggregation — Phase 4
- Cross-meter formulas (
cost = tokens × rate × surcharge) — separate “computed meters” design - Schema enforcement on dimension keys — free-form for v1; revisit after the first design partner (an early customer who co-develops the product)
Today’s schema (in repo at internal/platform/migrate/sql/0001_schema.up.sql)
Section titled “Today’s schema (in repo at internal/platform/migrate/sql/0001_schema.up.sql)”CREATE TABLE meters ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), key TEXT NOT NULL, name TEXT NOT NULL, unit TEXT NOT NULL DEFAULT 'unit', aggregation TEXT NOT NULL DEFAULT 'sum', rating_rule_version_id TEXT REFERENCES rating_rule_versions(id), ... UNIQUE (tenant_id, key));
CREATE TABLE usage_events ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), customer_id TEXT NOT NULL REFERENCES customers(id), meter_id TEXT NOT NULL REFERENCES meters(id), subscription_id TEXT REFERENCES subscriptions(id), quantity BIGINT NOT NULL DEFAULT 0, properties JSONB NOT NULL DEFAULT '{}', idempotency_key TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, idempotency_key));Today: one meter has one rating rule, one aggregation mode. The properties column is metadata-only and not used in pricing.
Proposed schema changes — migration 0054_multi_dim_meters
Section titled “Proposed schema changes — migration 0054_multi_dim_meters”Migration number caveat: pick the real number at PR-open time from
origin/main, not from the local branch — a standing project rule, recorded asfeedback_migration_numbering. 0054 is a placeholder.
-- 0054_multi_dim_meters.up.sql
-- 1. Decimal quantity support. Existing BIGINT data widens losslessly.-- NUMERIC(38,12) covers tokens (integer) + GPU-hours (6 decimals) +-- everything plausible without precision loss.ALTER TABLE usage_events ALTER COLUMN quantity TYPE NUMERIC(38, 12) USING quantity::numeric;
-- 2. GIN index on properties for dimension-keyed lookups during aggregation.CREATE INDEX idx_usage_events_properties_gin ON usage_events USING GIN (properties);
-- 3. New table: pricing rules per meter, with dimension match + per-rule-- aggregation mode. Enables N rules per meter (vs today's 1:1 on meters).CREATE TABLE meter_pricing_rules ( id TEXT PRIMARY KEY DEFAULT 'hc_mpr_' || encode(gen_random_bytes(12), 'hex'), tenant_id TEXT NOT NULL REFERENCES tenants(id), meter_id TEXT NOT NULL REFERENCES meters(id), rating_rule_version_id TEXT NOT NULL REFERENCES rating_rule_versions(id), dimension_match JSONB NOT NULL DEFAULT '{}', aggregation_mode TEXT NOT NULL DEFAULT 'sum' CHECK (aggregation_mode IN ('sum', 'count', 'last_during_period', 'last_ever', 'max')), priority INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, meter_id, rating_rule_version_id));
CREATE INDEX idx_meter_pricing_rules_lookup ON meter_pricing_rules (tenant_id, meter_id, priority DESC);
ALTER TABLE meter_pricing_rules ENABLE ROW LEVEL SECURITY;ALTER TABLE meter_pricing_rules FORCE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON meter_pricing_rules FOR ALL USING ( current_setting('app.bypass_rls', true) = 'on' OR tenant_id = current_setting('app.tenant_id', true));GRANT ALL ON TABLE meter_pricing_rules TO hardcount_app;Why a new table instead of extending meters
Section titled “Why a new table instead of extending meters”meters.rating_rule_version_id is 1:1 today — one rule per meter. The new model is N:1 — many rules per meter. A new table is the cleanest expression. The existing meters.rating_rule_version_id becomes the default rule — events not claimed by any pricing rule fall back to it, which preserves backward compatibility for tenants currently in production.
Backward compatibility
Section titled “Backward compatibility”- Tenants with zero
meter_pricing_rulesrows behave identically to today (every event matches the meter’s default rule, withaggregation_mode = meters.aggregation). propertiesJSONB column gains pricing semantics but no schema change — existing data is forward-compatible.quantity BIGINT → NUMERIC(38, 12)is a widening cast; no data loss; existing Go code reading int64 needs the type-update pass.
API surface
Section titled “API surface”Wire-contract conventions (consistent with the rest of
/v1/*):
- Paths use hyphens, not underscores:
/v1/usage-events,/v1/meters/{id}/pricing-rules. Matches/v1/credit-notes,/v1/rating-rules, etc.- Quantity field is
quantity, both directions. JSON wire form is a string for decimal precision ("1234.5"); numeric form is also accepted on input. There is novaluealias — pre-launch we don’t owe back-compat, and one canonical name beats two.- External identifiers on input. Ingest takes
external_customer_id(the developer’s own ID) andevent_name(the meter key); the handler resolves both to internalcustomer_id/meter_idserver-side.
POST /v1/usage-events
Section titled “POST /v1/usage-events”POST /v1/usage-eventsIdempotency-Key: <key>Authorization: Bearer <secret_key>
{ "external_customer_id": "cust_xyz", "event_name": "tokens", "quantity": "1234.5", // string for decimal precision; numeric also accepted "dimensions": { "model": "gpt-4", "operation": "input", "cached": false }, "idempotency_key": "evt-1", // required; one unique key per event, reused only on its retries "timestamp": "2026-04-25T12:34:56Z" // optional, defaults to now}Response 201:
{ "id": "hc_evt_...", "tenant_id": "tnt_...", "customer_id": "hc_cus_...", "meter_id": "hc_mtr_...", "subscription_id": "hc_sub_...", "quantity": "1234.5", "properties": { "model": "gpt-4", "operation": "input", "cached": false }, "timestamp": "2026-04-25T12:34:56Z", "origin": "api"}The response carries internal IDs (hc_cus_*, hc_mtr_*) and surfaces dimensions on properties (the storage column name) — dimensions on the request is just an alias and collapses onto properties at the storage layer.
POST /v1/meters/{id}/pricing-rules
Section titled “POST /v1/meters/{id}/pricing-rules”POST /v1/meters/mtr_xyz/pricing-rulesAuthorization: Bearer <secret_key>
{ "rating_rule_version_id": "rrv_xyz", "dimension_match": { "model": "gpt-4", "operation": "input", "cached": false }, "aggregation_mode": "sum", "priority": 100}Plus GET, LIST, DELETE for full CRUD (create, read, update, delete).
GET /v1/customers/{id}/usage
Section titled “GET /v1/customers/{id}/usage”GET /v1/customers/cust_xyz/usage?event_name=tokens&period_start=2026-04-01&period_end=2026-04-30&group_by=model,operationReturns the per-rule aggregated quantity + projected charges, grouped by requested dimensions. Powers the cost-dashboard component (scheduled as Week 5 of the build plan).
Aggregation semantics
Section titled “Aggregation semantics”When billing finalizes for (customer, meter, period_start, period_end):
- Load all
meter_pricing_rulesfor the meter, ordered bypriority DESC, thencreated_at ASC, thenid ASCfor deterministic tie-breaking. Theidfinal tiebreaker covers the corner case where two same-priority rules share acreated_attick (bulk import, same-txn bootstrap, clock-resolution collisions). Without it, Postgres is free to pick an order, which would make billing non-reproducible across replicas. - Iterate rules. For each rule:
- Find events in the period whose
propertiesis a superset ofdimension_matchAND that haven’t been claimed by a higher-priority rule. - Apply the rule’s
aggregation_mode:sum→SUM(quantity)count→COUNT(*)last_during_period→ quantity of the latest event bytimestampwithin the periodlast_ever→ quantity of the latest event bytimestampbefore the period end, however far back it lies (used for “current state” billing like seat counts). An event stamped after the period end belongs to a later period, even when the period is billed latemax→MAX(quantity)
- Resolve the aggregated quantity → cents via the rule’s
rating_rule_version(existing flat / graduated / package logic, unchanged).
- Find events in the period whose
- Events not claimed by any pricing rule fall back to the meter’s default
rating_rule_version_idwith modemeters.aggregation.
Subset-match semantics
Section titled “Subset-match semantics”dimension_match: {model: "gpt-4"} matches an event with properties: {model: "gpt-4", operation: "input", cached: false} — extra dimensions on the event are fine. Subset, not equality.
This lets you write coarse rules (“all gpt-4 usage at $X / 1k tokens”) and override with finer rules (“but gpt-4 cached input at $0.30 / 1k tokens”) via priority.
Priority + claim semantics
Section titled “Priority + claim semantics”Higher priority rules claim events first; each event is claimed by at most one rule per (customer, period). This prevents double-counting when rules overlap.
Implementation: walk rules in order; for each rule, query events not already in the claimed set; add results to the claimed set. A single SQL query with ordered window-function logic is preferable to N queries — see “Implementation notes” below.
Implementation notes
Section titled “Implementation notes”Aggregation query shape (illustrative)
Section titled “Aggregation query shape (illustrative)”WITH ranked_rules AS ( SELECT id, dimension_match, aggregation_mode, rating_rule_version_id, ROW_NUMBER() OVER (ORDER BY priority DESC, created_at ASC, id ASC) AS rule_rank FROM meter_pricing_rules WHERE tenant_id = $1 AND meter_id = $2),claimed AS ( SELECT DISTINCT ON (e.id) e.id, e.quantity, e.timestamp, r.id AS rule_id, r.aggregation_mode, r.rating_rule_version_id FROM usage_events e CROSS JOIN ranked_rules r WHERE e.tenant_id = $1 AND e.meter_id = $2 AND e.customer_id = $3 AND e.timestamp >= $4 AND e.timestamp < $5 AND e.properties @> r.dimension_match -- JSONB superset operator ORDER BY e.id, r.rule_rank ASC)SELECT rule_id, aggregation_mode, ...FROM claimedGROUP BY rule_id, aggregation_mode, rating_rule_version_id;@> is the Postgres JSONB superset operator and uses the GIN index (an inverted index type suited to JSONB containment lookups). The DISTINCT ON (e.id) ORDER BY rule_rank claims each event to its highest-priority matching rule. Aggregation modes are dispatched in the outer aggregation step.
For last_during_period, last_ever, max modes the aggregation differs — implementation will fork by mode. Likely cleanest: one query per mode, scoped by rule_id.
Decimal handling in Go
Section titled “Decimal handling in Go”- Domain type:
decimal.Decimalfromgithub.com/shopspring/decimal(already battle-tested, used by Stripe’s own SDK and many Go billing systems) - Postgres driver:
pgtype.Numeric↔decimal.Decimalconversion in store layer domain.UsageEvent.Quantitybecomesdecimal.Decimal(kept theQuantityname — wire field isquantity, no rename toValue)- Existing internal callers passing
int64need adapters
A standing project rule (recorded as feedback_prefer_battle_tested_libs) applies here: use shopspring/decimal, do not roll our own.
Test strategy
Section titled “Test strategy”Unit (internal/usage/service_test.go)
Section titled “Unit (internal/usage/service_test.go)”- Ingest with dimensions → stored correctly, idempotency-keyed (retry-safe: resending the same key cannot create a duplicate)
- Aggregation correctness across all five modes
- Subset-match semantics (extra dimensions on event don’t disqualify)
- Priority + claim — overlapping rules don’t double-count
- Default-rule fallback when no pricing rule matches
- Decimal precision round-trip (1234.567890123456 stays 1234.567890123456)
Integration (internal/usage/postgres_test.go)
Section titled “Integration (internal/usage/postgres_test.go)”- Real Postgres
- RLS isolation: tenant A cannot see tenant B’s pricing rules or events
- GIN index actually used (asserted by inspecting the query plan with EXPLAIN ANALYZE)
- Concurrent ingest with same idempotency key → single row, deterministic winner
- Migration up/down round-trip (a standing project rule, recorded as
feedback_longterm_fixes)
Handler (internal/usage/handler_test.go)
Section titled “Handler (internal/usage/handler_test.go)”- Decimal in JSON parses correctly (string and number forms)
- Invalid
dimension_match(non-object) rejected with 400 aggregation_modeoutside enum rejected with 400meter_idfrom another tenant returns 404 (RLS)
Benchmark plan
Section titled “Benchmark plan”- Target: sustained 50k events/sec ingest on a single tenant
- Hardware: Postgres 16, 8 vCPU, 16GB RAM, NVMe SSD (matches commodity AWS m5/m6)
- Workload:
scripts/bench-rig/ingest.js(k6, an open-source load-testing tool) — synthetic event stream with realistic dimension cardinality (~10 model values, 4 operation values, 2 boolean cache states), driving the public ingest endpoint.cmd/hardcount-bench-seedcreates the fixtures and mints the key. - Measurement: total events ingested over 60s, p50/p95/p99 ingest latency, Postgres CPU + IO
- Mitigations if we miss target:
- Batch INSERTs (already needed for any high-throughput workload)
- Move idempotency-key uniqueness to a separate dedupe table to avoid full-row UNIQUE pressure
- Hash-partition
usage_eventsby(tenant_id, month)— out-of-scope for this design unless benchmark forces it
Open questions
Section titled “Open questions”- Should dimensions be schema-validated against the meter? Proposal: no for v1. Free-form JSONB. Revisit after first design partner reports a typo’d dimension caused a billing miss.
- Should pricing rules support time-windowed match? Proposal: no. Period boundary is the only window for v1.
- How do we handle events whose
quantityis zero? Proposal: ingest, count forcountmode, sum to zero forsummode (no special case). - Decimal precision —
NUMERIC(38, 12)sufficient? Proposal: yes. Tokens are integer; GPU-hours need 4–6 decimals; 12 is generous. - Cap on dimension count per event? Proposal: 16 keys, soft limit. Reject events with >16 dimension keys to bound JSONB size and avoid pathological tenants.
- Do existing
usage_events.quantityBIGINT values need any migration data work? Proposal: no. Widening cast is lossless; max BIGINT (≈9.2 × 10¹⁸) fits NUMERIC(38, 12) trivially. - Should we deprecate the existing
meters.aggregationcolumn? Proposal: not yet. Keep it as the default-rule aggregation mode for back-compat; flag for removal in 6 months once all tenants migrate tometer_pricing_rules.
Implementation checklist
Section titled “Implementation checklist”- Migration
0054_multi_dim_meters.{up,down}.sql -
domain.UsageEvent.Quantity→decimal.Decimal -
domain.MeterPricingRulestruct -
usage.Store.UpsertPricingRule(...),ListPricingRulesByMeter(...),DeletePricingRule(...) -
usage.Service.IngestEvent(...)accepts decimal quantity + dimensions -
usage.Service.AggregateForBillingPeriod(...)resolves rules with priority + dimension match - HTTP handlers:
POST /v1/usage-events,POST/GET/LIST/DELETE /v1/meters/{id}/pricing-rules -
GET /v1/customers/{id}/usagepowering the cost dashboard - Unit tests: ingest, aggregation per mode, subset-match, priority-claim
- Integration tests: real Postgres, RLS-isolated tenants, decimal precision, idempotency
- Ingest benchmark (the 50k events/sec target was never validated. Measured 2026-08-15 on an isolated Postgres 16 container, 8 workers, 10s, with the since-deleted
hardcount-bench --batch(k6BATCH=is the equivalent today): 2,714 ev/s single-event; 8,245 at batch 10; 10,821 at batch 50; 12,095 at batch 500, the curve flattening after ~50 while per-call latency keeps climbing. The long-standing “single-row-INSERT-bound” reading is WRONG — statement-level profiling attributes ~66% of in-database time to COMMIT and only ~28% to the INSERT, with the three per-transactionset_configcalls at 5%. Ingest is fsync/commit-bound, so the lever is commits-per-event (batching), not statements-per-event; the plateau above batch 50 is the INSERT-bound ceiling underneath. Laptop-container numbers — not a cloud benchmark.) - OpenAPI spec update (
api/openapi.yaml) - CHANGELOG entry + public changelog rollup