MeterBox
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.
Requirements
- Ruby >= 3.2
- ActiveRecord >= 7.1
- PostgreSQL (JSONB columns, GIN indexes, partial unique indexes)
Getting Started
Add the gem to your Gemfile:
gem "meter_box"
Run the install generator:
bundle install
rails generate meter_box:install
rails db:migrate
This 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.
Configuration
Declare your meters in the initializer. Each meter has a name (a symbol), an optional aggregation type, and optional dimensions:
# config/initializers/meter_box.rb
MeterBox.configure do |config|
config.meter :signatures,
dimensions: {
method: { values: %i[mitid otp], 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[indoor outdoor] }
}
end
Aggregation types
The 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.
Dimension options
| 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 |
Freeze semantics
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.
Usage
MeterBox exposes five public methods. All accept keyword arguments.
MeterBox.record
Records a usage event.
MeterBox.record(
owner: account, # any ActiveRecord model (polymorphic)
meter: :signatures, # registered meter name
value: 1, # any Numeric (Integer, Float, BigDecimal), defaults to 1
dimensions: { method: :mitid }, # validated against meter declaration
metadata: { session: "abc" }, # free-form JSONB, never queried
idempotency_key: "evt-123", # optional, prevents duplicate inserts
recorded_at: Time.current # defaults to now; backfill with past timestamps
)
# => MeterBox::Event
Idempotency: 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" })
MeterBox.total
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, optional
until: Time.utc(2026, 2, 1), # exclusive, optional
where: { 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
MeterBox.breakdown
Groups aggregated results by one or more dimensions.
MeterBox.breakdown(
owner: account,
meter: :signatures,
by: :method, # symbol or array of symbols
since: 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.
MeterBox.over_cap?
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 / false
MeterBox does not store cap values — the caller supplies the cap. This keeps plan/billing logic in the host application.
MeterBox.events_for
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_each do |event|
puts "#{event.recorded_at}: #{event.value} (#{event.dimensions})"
end
Errors
All 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 |
Database Schema
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
MeterBox vs usage_credits
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.
Testing Your Application
In your test suite, reset the MeterBox configuration in setup/teardown to avoid state leakage:
class MyTest < ActiveSupport::TestCase
setup do
MeterBox.reset!
MeterBox.configure do |config|
config.meter :signatures,
dimensions: { method: { required: true, values: %i[mitid otp] } }
end
end
teardown do
MeterBox.reset!
end
end
Development
Prerequisites: 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 test
To stop the database:
docker compose down
License
MIT License. See LICENSE.txt.