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_events table
  • 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 to uuidv7() 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:           ,            # 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: , 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: ,
  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)
  • since is inclusive (recorded_at >= since)
  • until is exclusive (recorded_at < until)
  • Return value depends on aggregation type — :sum and :count return 0 for empty scopes; :max, :min, :mean, and :latest return nil

MeterBox.breakdown

Groups aggregated results by one or more dimensions.

MeterBox.breakdown(
  owner: ,
  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: ,
  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: ,
  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 dimensions for JSONB queries
  • Partial unique on (owner_type, owner_id, meter_name, idempotency_key) where idempotency_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.