https://github.com/ianmurrays/meter_box
https://github.com/ianmurrays/meter_box
Last synced: 12 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/ianmurrays/meter_box
- Owner: ianmurrays
- License: mit
- Created: 2026-05-10T06:57:14.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-10T12:33:47.000Z (about 2 months ago)
- Last Synced: 2026-05-11T13:02:54.466Z (about 2 months ago)
- Language: Ruby
- Size: 330 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# MeterBox
[](https://badge.fury.io/rb/meter_box)
[](https://github.com/ianmurrays/meter_box/actions/workflows/test.yml)
> **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:
```ruby
gem "meter_box"
```
Run the install generator:
```bash
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:
```ruby
# 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.
```ruby
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.
```ruby
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.
```ruby
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)
```
- `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.
```ruby
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.
```ruby
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.
```ruby
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 `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](https://github.com/rameerez/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:
```ruby
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).
```bash
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:
```bash
docker compose down
```
## License
MIT License. See [LICENSE.txt](LICENSE.txt).