An open API service indexing awesome lists of open source software.

https://github.com/ianmurrays/meter_box


https://github.com/ianmurrays/meter_box

Last synced: 12 days ago
JSON representation

Awesome Lists containing this project

README

          

# MeterBox

[![Gem Version](https://badge.fury.io/rb/meter_box.svg)](https://badge.fury.io/rb/meter_box)
[![Test](https://github.com/ianmurrays/meter_box/actions/workflows/test.yml/badge.svg)](https://github.com/ianmurrays/meter_box/actions/workflows/test.yml)


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:

```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).