Skip to content

Repository files navigation

MeterBox

Gem VersionTest

MeterBox

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.

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.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]}}end

Aggregation types

The aggregation: option controls how MeterBox.total and MeterBox.breakdown aggregate events. Defaults to :sum.

TypeSQL equivalentReturn typeEmpty scope
:sumSUM(value)Numeric0
:countCOUNT(*)Integer0
:maxMAX(value)Numericnil
:minMIN(value)Numericnil
:meanAVG(value)BigDecimalnil
:latestValue from the most recent eventNumericnil
:count_distinctCOUNT(DISTINCT value)Integer0

: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

OptionTypeDefaultDescription
requiredBooleanfalseWhen true, MeterBox.record raises MissingDimension if this key is absent
valuesArray 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 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::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, optionaluntil: Time.utc(2026,2,1),# exclusive, optionalwhere: {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: 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.

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_eachdo |event|
puts"#{event.recorded_at}: #{event.value} (#{event.dimensions})"end

Errors

All errors inherit from MeterBox::Error < StandardError.

ErrorRaised when
ConfigurationFrozenRegistering a meter after configure has run
UnknownMeterRecording or querying with an unregistered meter name
MissingDimensionA required dimension is absent on record
UnknownDimensionAn undeclared dimension key is used on record, where:, or by:
InvalidDimensionValueA dimension value is not in the declared values: list
MissingOwnerowner is nil or has a nil id
InvalidValuevalue is not Numeric, or metadata is not a Hash

Database Schema

MeterBox uses a single table: meter_box_events.

ColumnTypeNotes
idUUIDPrimary key
owner_typestringPolymorphic type
owner_idstringStored as string to support any PK type
meter_namestringRegistered meter name
valuedecimalSigned; supports integers and fractional values; negative for corrections
dimensionsJSONBValidated, aggregation-relevant tags
metadataJSONBFree-form audit context, never queried
idempotency_keystringNullable; scoped unique per owner+meter
recorded_atdatetimeBusiness time (can be backfilled)
created_atdatetimeInsert 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.

MeterBoxusage_credits
PurposeRecord and query raw usage eventsManage a credits wallet with spending, fulfillment, and billing
Data modelSingle append-only events tableMulti-table ledger (wallets, transactions, allocations, fulfillments)
What it tracksDimensioned event counts/sumsCredit balance with FIFO allocation and expiration
Billing integrationNone — metering onlyStripe/PayPal via the pay gem
DimensionsTyped, validated, queryable (by:, where:)No built-in dimension system
Aggregationtotal, breakdown, time-range filtersTransaction history queries
IdempotencyBuilt-in via idempotency_keyVia pay gem for charges
DatabasePostgreSQL (JSONB, GIN indexes)Any ActiveRecord-supported database
CorrectionsNegative-value eventsRefunds and adjustments
Scope~300 LOC, zero billing opinionsFull 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:

classMyTest < ActiveSupport::TestCasesetupdoMeterBox.reset!MeterBox.configuredo |config|
config.meter:signatures,dimensions: {method: {required: true,values: %i[mitidotp]}}endendteardowndoMeterBox.reset!endend

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages