Note: MeterBox is not production-ready. Use at your own risk.
Append-only, multi-dimensional usage metering for ActiveRecord and PostgreSQL.
MeterBox records usage events against polymorphic owners with typed dimensions, then queries them with time-range filtering and dimension breakdowns. Events are immutable — corrections are new events with negative values.
- Ruby >= 3.2
- ActiveRecord >= 7.1
- PostgreSQL (JSONB columns, GIN indexes, partial unique indexes)
Add the gem to your Gemfile:
gem"meter_box"Run the install generator:
bundle install
rails generate meter_box:install
rails db:migrateThis creates:
- A migration for the
meter_box_eventstable - An initializer at
config/initializers/meter_box.rb
PostgreSQL 17+: The generated migration uses
gen_random_uuid()for primary keys. If your database supports it, you can change this touuidv7()for time-ordered UUIDs.
Declare your meters in the initializer. Each meter has a name (a symbol), an optional aggregation type, and optional dimensions:
# config/initializers/meter_box.rbMeterBox.configuredo |config|
config.meter:signatures,dimensions: {method: {values: %i[mitidotp],required: true},subaccount_id: {required: false}}config.meter:api_calls,aggregation: :count,dimensions: {endpoint: {required: true}}config.meter:temperature,aggregation: :latest,dimensions: {sensor: {required: true,values: %i[indooroutdoor]}}endThe aggregation: option controls how MeterBox.total and MeterBox.breakdown aggregate events. Defaults to :sum.
| Type | SQL equivalent | Return type | Empty scope |
|---|---|---|---|
:sum | SUM(value) | Numeric | 0 |
:count | COUNT(*) | Integer | 0 |
:max | MAX(value) | Numeric | nil |
:min | MIN(value) | Numeric | nil |
:mean | AVG(value) | BigDecimal | nil |
:latest | Value from the most recent event | Numeric | nil |
:count_distinct | COUNT(DISTINCT value) | Integer | 0 |
:latest orders by recorded_at DESC, breaking ties with created_at DESC.
:count vs :sum: When every event uses the default value: 1, :count and :sum return the same number. They diverge when events carry varying values — :sum adds up the value column while :count counts rows regardless of value. If you're counting occurrences (API calls, logins), :sum with the default value is sufficient. :count is useful when events carry a meaningful value (e.g., bytes transferred) but you still want to know how many events occurred.
| Option | Type | Default | Description |
|---|---|---|---|
required | Boolean | false | When true, MeterBox.record raises MissingDimension if this key is absent |
values | Array of symbols | (none) | Constrains allowed values. Omit to allow any value. Symbols and strings are interchangeable |
MeterBox.configure freezes the registry after the block returns. Any attempt to register a meter afterwards raises ConfigurationFrozen. This ensures meters are defined at boot time and version with your codebase.
MeterBox exposes five public methods. All accept keyword arguments.
Records a usage event.
MeterBox.record(owner: account,# any ActiveRecord model (polymorphic)meter: :signatures,# registered meter namevalue: 1,# any Numeric (Integer, Float, BigDecimal), defaults to 1dimensions: {method: :mitid},# validated against meter declarationmetadata: {session: "abc"},# free-form JSONB, never queriedidempotency_key: "evt-123",# optional, prevents duplicate insertsrecorded_at: Time.current# defaults to now; backfill with past timestamps)# => MeterBox::EventIdempotency: When an idempotency_key is provided, a second call with the same key (scoped to owner + meter) returns the original event without inserting a duplicate or raising an error.
Corrections: To correct a previous event, record a new event with a negative value. MeterBox never updates or deletes rows.
MeterBox.record(owner: account,meter: :signatures,value: -1,dimensions: {method: :mitid},metadata: {reason: "double-emitted"})Returns the aggregated result for matching events, using the meter's configured aggregation type.
MeterBox.total(owner: account,meter: :signatures,since: Time.utc(2026,1,1),# inclusive, optionaluntil: Time.utc(2026,2,1),# exclusive, optionalwhere: {method: :mitid}# dimension filter, optional)# => Numeric or nil (see aggregation types table)sinceis inclusive (recorded_at >= since)untilis exclusive (recorded_at < until)- Return value depends on aggregation type —
:sumand:countreturn0for empty scopes;:max,:min,:mean, and:latestreturnnil
Groups aggregated results by one or more dimensions.
MeterBox.breakdown(owner: account,meter: :signatures,by: :method,# symbol or array of symbolssince: Time.utc(2026,1,1),until: Time.utc(2026,2,1))# => { { method: "mitid" } => 42, { method: "otp" } => 17 }Returns an empty hash when no events match. The by: keys must be declared dimensions on the meter.
Checks whether the total meets or exceeds a given cap.
MeterBox.over_cap?(owner: account,meter: :signatures,cap: 1000,since: Time.utc(2026,1,1),where: {method: :mitid})# => true / falseMeterBox does not store cap values — the caller supplies the cap. This keeps plan/billing logic in the host application.
Returns an ActiveRecord::Relation of matching events for drill-down queries.
events=MeterBox.events_for(owner: account,meter: :signatures,since: 1.month.ago,where: {method: :mitid})events.find_eachdo |event|
puts"#{event.recorded_at}: #{event.value} (#{event.dimensions})"endAll errors inherit from MeterBox::Error < StandardError.
| Error | Raised when |
|---|---|
ConfigurationFrozen | Registering a meter after configure has run |
UnknownMeter | Recording or querying with an unregistered meter name |
MissingDimension | A required dimension is absent on record |
UnknownDimension | An undeclared dimension key is used on record, where:, or by: |
InvalidDimensionValue | A dimension value is not in the declared values: list |
MissingOwner | owner is nil or has a nil id |
InvalidValue | value is not Numeric, or metadata is not a Hash |
MeterBox uses a single table: meter_box_events.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
owner_type | string | Polymorphic type |
owner_id | string | Stored as string to support any PK type |
meter_name | string | Registered meter name |
value | decimal | Signed; supports integers and fractional values; negative for corrections |
dimensions | JSONB | Validated, aggregation-relevant tags |
metadata | JSONB | Free-form audit context, never queried |
idempotency_key | string | Nullable; scoped unique per owner+meter |
recorded_at | datetime | Business time (can be backfilled) |
created_at | datetime | Insert time |
Three indexes:
- Composite on
(owner_type, owner_id, meter_name, recorded_at)for query performance - GIN on
dimensionsfor JSONB queries - Partial unique on
(owner_type, owner_id, meter_name, idempotency_key)whereidempotency_key IS NOT NULL
usage_credits is a credits-based billing system. MeterBox is a metering primitive. They solve different problems and can work together.
| MeterBox | usage_credits | |
|---|---|---|
| Purpose | Record and query raw usage events | Manage a credits wallet with spending, fulfillment, and billing |
| Data model | Single append-only events table | Multi-table ledger (wallets, transactions, allocations, fulfillments) |
| What it tracks | Dimensioned event counts/sums | Credit balance with FIFO allocation and expiration |
| Billing integration | None — metering only | Stripe/PayPal via the pay gem |
| Dimensions | Typed, validated, queryable (by:, where:) | No built-in dimension system |
| Aggregation | total, breakdown, time-range filters | Transaction history queries |
| Idempotency | Built-in via idempotency_key | Via pay gem for charges |
| Database | PostgreSQL (JSONB, GIN indexes) | Any ActiveRecord-supported database |
| Corrections | Negative-value events | Refunds and adjustments |
| Scope | ~300 LOC, zero billing opinions | Full billing stack with subscriptions, packs, webhooks |
When to use MeterBox: You need a metering layer that records what happened — how many signatures, API calls, or documents were processed — and you want to query that data with dimensional breakdowns and time ranges. Billing decisions (plans, caps, invoicing) live in your application code.
When to use usage_credits: You want a turnkey credits system with wallets, spending, subscriptions, credit packs, and Stripe integration out of the box.
Using both: MeterBox records the raw events; your application reads the totals to decide when to deduct credits via usage_credits.
In your test suite, reset the MeterBox configuration in setup/teardown to avoid state leakage:
classMyTest < ActiveSupport::TestCasesetupdoMeterBox.reset!MeterBox.configuredo |config|
config.meter:signatures,dimensions: {method: {required: true,values: %i[mitidotp]}}endendteardowndoMeterBox.reset!endendPrerequisites: Docker (for PostgreSQL).
git clone https://github.com/ianmurrays/meter_box.git
cd meter_box
docker compose up -d --wait
bundle install
bundle exec rake testTo stop the database:
docker compose downMIT License. See LICENSE.txt.
