meter_box 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49aa4e6a97cf8056931198070491fada25452f673af872f06a67ec5629084e4f
4
+ data.tar.gz: 0a343321bb9b806529ea273df1bbc4b9e7e83e21e2ba0cba19e11d50c3bac702
5
+ SHA512:
6
+ metadata.gz: 96ef328822e774614ddcf2aaff22f3274807a605136b9ab76ca91fc7d88f6295aa9c447263304eb6ff71c18d76f799c20ff09f250320dd59d77d67a1a042c059
7
+ data.tar.gz: 935d1548499c9bdc67f35a566de41ad9771396a7d6f02dbf65dd88ce64329ff264f6a7d11fd604123ea26a27254ad2cf6a0a7061501dda6f96299a37e1b6d590
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release
6
+ - Append-only event recording with `MeterBox.record`
7
+ - Aggregation queries: `total`, `breakdown`, `over_cap?`, `events_for`
8
+ - Per-meter aggregation types (sum, count, latest, max, min, mean, count_distinct)
9
+ - Typed dimension validation (required, allowed values)
10
+ - Idempotent inserts via `idempotency_key`
11
+ - Polymorphic owner support
12
+ - Thread-safe configuration lifecycle
13
+ - Rails install generator (`rails generate meter_box:install`)
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ian Murray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # MeterBox
2
+
3
+ Append-only, multi-dimensional usage metering for ActiveRecord and PostgreSQL.
4
+
5
+ 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.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby >= 3.2
10
+ - ActiveRecord >= 7.1
11
+ - PostgreSQL (JSONB columns, GIN indexes, partial unique indexes)
12
+
13
+ ## Getting Started
14
+
15
+ Add the gem to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "meter_box"
19
+ ```
20
+
21
+ Run the install generator:
22
+
23
+ ```bash
24
+ bundle install
25
+ rails generate meter_box:install
26
+ rails db:migrate
27
+ ```
28
+
29
+ This creates:
30
+ - A migration for the `meter_box_events` table
31
+ - An initializer at `config/initializers/meter_box.rb`
32
+
33
+ > **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.
34
+
35
+ ## Configuration
36
+
37
+ Declare your meters in the initializer. Each meter has a name (a symbol), an optional aggregation type, and optional dimensions:
38
+
39
+ ```ruby
40
+ # config/initializers/meter_box.rb
41
+ MeterBox.configure do |config|
42
+ config.meter :signatures,
43
+ dimensions: {
44
+ method: { values: %i[mitid otp], required: true },
45
+ subaccount_id: { required: false }
46
+ }
47
+
48
+ config.meter :api_calls,
49
+ aggregation: :count,
50
+ dimensions: {
51
+ endpoint: { required: true }
52
+ }
53
+
54
+ config.meter :temperature,
55
+ aggregation: :latest,
56
+ dimensions: {
57
+ sensor: { required: true, values: %i[indoor outdoor] }
58
+ }
59
+ end
60
+ ```
61
+
62
+ ### Aggregation types
63
+
64
+ The `aggregation:` option controls how `MeterBox.total` and `MeterBox.breakdown` aggregate events. Defaults to `:sum`.
65
+
66
+ | Type | SQL equivalent | Return type | Empty scope |
67
+ |------|---------------|-------------|-------------|
68
+ | `:sum` | `SUM(value)` | `Numeric` | `0` |
69
+ | `:count` | `COUNT(*)` | `Integer` | `0` |
70
+ | `:max` | `MAX(value)` | `Numeric` | `nil` |
71
+ | `:min` | `MIN(value)` | `Numeric` | `nil` |
72
+ | `:mean` | `AVG(value)` | `BigDecimal` | `nil` |
73
+ | `:latest` | Value from the most recent event | `Numeric` | `nil` |
74
+ | `:count_distinct` | `COUNT(DISTINCT value)` | `Integer` | `0` |
75
+
76
+ `:latest` orders by `recorded_at DESC`, breaking ties with `created_at DESC`.
77
+
78
+ **`: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.
79
+
80
+ ### Dimension options
81
+
82
+ | Option | Type | Default | Description |
83
+ |--------|------|---------|-------------|
84
+ | `required` | Boolean | `false` | When `true`, `MeterBox.record` raises `MissingDimension` if this key is absent |
85
+ | `values` | Array of symbols | _(none)_ | Constrains allowed values. Omit to allow any value. Symbols and strings are interchangeable |
86
+
87
+ ### Freeze semantics
88
+
89
+ `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.
90
+
91
+ ## Usage
92
+
93
+ MeterBox exposes five public methods. All accept keyword arguments.
94
+
95
+ ### `MeterBox.record`
96
+
97
+ Records a usage event.
98
+
99
+ ```ruby
100
+ MeterBox.record(
101
+ owner: account, # any ActiveRecord model (polymorphic)
102
+ meter: :signatures, # registered meter name
103
+ value: 1, # any Numeric (Integer, Float, BigDecimal), defaults to 1
104
+ dimensions: { method: :mitid }, # validated against meter declaration
105
+ metadata: { session: "abc" }, # free-form JSONB, never queried
106
+ idempotency_key: "evt-123", # optional, prevents duplicate inserts
107
+ recorded_at: Time.current # defaults to now; backfill with past timestamps
108
+ )
109
+ # => MeterBox::Event
110
+ ```
111
+
112
+ **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.
113
+
114
+ **Corrections**: To correct a previous event, record a new event with a negative `value`. MeterBox never updates or deletes rows.
115
+
116
+ ```ruby
117
+ MeterBox.record(owner: account, meter: :signatures, value: -1,
118
+ dimensions: { method: :mitid },
119
+ metadata: { reason: "double-emitted" })
120
+ ```
121
+
122
+ ### `MeterBox.total`
123
+
124
+ Returns the aggregated result for matching events, using the meter's configured aggregation type.
125
+
126
+ ```ruby
127
+ MeterBox.total(
128
+ owner: account,
129
+ meter: :signatures,
130
+ since: Time.utc(2026, 1, 1), # inclusive, optional
131
+ until: Time.utc(2026, 2, 1), # exclusive, optional
132
+ where: { method: :mitid } # dimension filter, optional
133
+ )
134
+ # => Numeric or nil (see aggregation types table)
135
+ ```
136
+
137
+ - `since` is **inclusive** (`recorded_at >= since`)
138
+ - `until` is **exclusive** (`recorded_at < until`)
139
+ - Return value depends on aggregation type — `:sum` and `:count` return `0` for empty scopes; `:max`, `:min`, `:mean`, and `:latest` return `nil`
140
+
141
+ ### `MeterBox.breakdown`
142
+
143
+ Groups aggregated results by one or more dimensions.
144
+
145
+ ```ruby
146
+ MeterBox.breakdown(
147
+ owner: account,
148
+ meter: :signatures,
149
+ by: :method, # symbol or array of symbols
150
+ since: Time.utc(2026, 1, 1),
151
+ until: Time.utc(2026, 2, 1)
152
+ )
153
+ # => { { method: "mitid" } => 42, { method: "otp" } => 17 }
154
+ ```
155
+
156
+ Returns an empty hash when no events match. The `by:` keys must be declared dimensions on the meter.
157
+
158
+ ### `MeterBox.over_cap?`
159
+
160
+ Checks whether the total meets or exceeds a given cap.
161
+
162
+ ```ruby
163
+ MeterBox.over_cap?(
164
+ owner: account,
165
+ meter: :signatures,
166
+ cap: 1000,
167
+ since: Time.utc(2026, 1, 1),
168
+ where: { method: :mitid }
169
+ )
170
+ # => true / false
171
+ ```
172
+
173
+ MeterBox does not store cap values — the caller supplies the cap. This keeps plan/billing logic in the host application.
174
+
175
+ ### `MeterBox.events_for`
176
+
177
+ Returns an `ActiveRecord::Relation` of matching events for drill-down queries.
178
+
179
+ ```ruby
180
+ events = MeterBox.events_for(
181
+ owner: account,
182
+ meter: :signatures,
183
+ since: 1.month.ago,
184
+ where: { method: :mitid }
185
+ )
186
+
187
+ events.find_each do |event|
188
+ puts "#{event.recorded_at}: #{event.value} (#{event.dimensions})"
189
+ end
190
+ ```
191
+
192
+ ## Errors
193
+
194
+ All errors inherit from `MeterBox::Error < StandardError`.
195
+
196
+ | Error | Raised when |
197
+ |-------|-------------|
198
+ | `ConfigurationFrozen` | Registering a meter after `configure` has run |
199
+ | `UnknownMeter` | Recording or querying with an unregistered meter name |
200
+ | `MissingDimension` | A required dimension is absent on `record` |
201
+ | `UnknownDimension` | An undeclared dimension key is used on `record`, `where:`, or `by:` |
202
+ | `InvalidDimensionValue` | A dimension value is not in the declared `values:` list |
203
+ | `MissingOwner` | `owner` is `nil` or has a `nil` id |
204
+ | `InvalidValue` | `value` is not Numeric, or `metadata` is not a Hash |
205
+
206
+ ## Database Schema
207
+
208
+ MeterBox uses a single table: `meter_box_events`.
209
+
210
+ | Column | Type | Notes |
211
+ |--------|------|-------|
212
+ | `id` | UUID | Primary key |
213
+ | `owner_type` | string | Polymorphic type |
214
+ | `owner_id` | string | Stored as string to support any PK type |
215
+ | `meter_name` | string | Registered meter name |
216
+ | `value` | decimal | Signed; supports integers and fractional values; negative for corrections |
217
+ | `dimensions` | JSONB | Validated, aggregation-relevant tags |
218
+ | `metadata` | JSONB | Free-form audit context, never queried |
219
+ | `idempotency_key` | string | Nullable; scoped unique per owner+meter |
220
+ | `recorded_at` | datetime | Business time (can be backfilled) |
221
+ | `created_at` | datetime | Insert time |
222
+
223
+ Three indexes:
224
+ - **Composite** on `(owner_type, owner_id, meter_name, recorded_at)` for query performance
225
+ - **GIN** on `dimensions` for JSONB queries
226
+ - **Partial unique** on `(owner_type, owner_id, meter_name, idempotency_key)` where `idempotency_key IS NOT NULL`
227
+
228
+ ## MeterBox vs usage_credits
229
+
230
+ [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.
231
+
232
+ | | MeterBox | usage_credits |
233
+ |---|---|---|
234
+ | **Purpose** | Record and query raw usage events | Manage a credits wallet with spending, fulfillment, and billing |
235
+ | **Data model** | Single append-only events table | Multi-table ledger (wallets, transactions, allocations, fulfillments) |
236
+ | **What it tracks** | Dimensioned event counts/sums | Credit balance with FIFO allocation and expiration |
237
+ | **Billing integration** | None — metering only | Stripe/PayPal via the `pay` gem |
238
+ | **Dimensions** | Typed, validated, queryable (`by:`, `where:`) | No built-in dimension system |
239
+ | **Aggregation** | `total`, `breakdown`, time-range filters | Transaction history queries |
240
+ | **Idempotency** | Built-in via `idempotency_key` | Via `pay` gem for charges |
241
+ | **Database** | PostgreSQL (JSONB, GIN indexes) | Any ActiveRecord-supported database |
242
+ | **Corrections** | Negative-value events | Refunds and adjustments |
243
+ | **Scope** | ~300 LOC, zero billing opinions | Full billing stack with subscriptions, packs, webhooks |
244
+
245
+ **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.
246
+
247
+ **When to use usage_credits**: You want a turnkey credits system with wallets, spending, subscriptions, credit packs, and Stripe integration out of the box.
248
+
249
+ **Using both**: MeterBox records the raw events; your application reads the totals to decide when to deduct credits via usage_credits.
250
+
251
+ ## Testing Your Application
252
+
253
+ In your test suite, reset the MeterBox configuration in setup/teardown to avoid state leakage:
254
+
255
+ ```ruby
256
+ class MyTest < ActiveSupport::TestCase
257
+ setup do
258
+ MeterBox.reset!
259
+ MeterBox.configure do |config|
260
+ config.meter :signatures,
261
+ dimensions: { method: { required: true, values: %i[mitid otp] } }
262
+ end
263
+ end
264
+
265
+ teardown do
266
+ MeterBox.reset!
267
+ end
268
+ end
269
+ ```
270
+
271
+ ## Development
272
+
273
+ Prerequisites: Docker (for PostgreSQL).
274
+
275
+ ```bash
276
+ git clone https://github.com/ianmurrays/meter_box.git
277
+ cd meter_box
278
+ docker compose up -d --wait
279
+ bundle install
280
+ bundle exec rake test
281
+ ```
282
+
283
+ To stop the database:
284
+
285
+ ```bash
286
+ docker compose down
287
+ ```
288
+
289
+ ## License
290
+
291
+ MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module MeterBox
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def copy_migration
14
+ migration_template(
15
+ "create_meter_box_events.rb.erb",
16
+ "db/migrate/create_meter_box_events.rb"
17
+ )
18
+ end
19
+
20
+ def copy_initializer
21
+ template "meter_box.rb", "config/initializers/meter_box.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ class CreateMeterBoxEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :meter_box_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
4
+ t.string :owner_type, null: false
5
+ t.string :owner_id, null: false
6
+ t.string :meter_name, null: false
7
+ t.decimal :value, null: false
8
+ t.jsonb :dimensions, null: false, default: {}
9
+ t.jsonb :metadata, null: false, default: {}
10
+ t.string :idempotency_key
11
+ t.datetime :recorded_at, null: false
12
+ t.datetime :created_at, null: false
13
+ end
14
+
15
+ add_index :meter_box_events,
16
+ [:owner_type, :owner_id, :meter_name, :recorded_at],
17
+ name: "idx_mb_events_owner_meter_time"
18
+
19
+ add_index :meter_box_events, :dimensions, using: :gin,
20
+ name: "idx_mb_events_dimensions"
21
+
22
+ add_index :meter_box_events,
23
+ [:owner_type, :owner_id, :meter_name, :idempotency_key],
24
+ unique: true,
25
+ where: "idempotency_key IS NOT NULL",
26
+ name: "idx_mb_events_idempotency"
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ MeterBox.configure do |config|
4
+ # Declare your meters here. Each meter has a name, optional dimensions,
5
+ # and an optional aggregation type (defaults to :sum).
6
+ #
7
+ # Aggregation types: :sum, :count, :latest, :max, :min, :mean, :count_distinct
8
+ #
9
+ # config.meter :api_calls,
10
+ # aggregation: :count,
11
+ # dimensions: {
12
+ # endpoint: { required: true },
13
+ # plan: { values: %i[free pro enterprise] }
14
+ # }
15
+ #
16
+ # config.meter :storage_bytes,
17
+ # aggregation: :latest,
18
+ # dimensions: {
19
+ # region: { required: true, values: %i[us eu ap] }
20
+ # }
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ module Aggregation
5
+ class << self
6
+ def apply(scope, aggregation)
7
+ return scope.order(recorded_at: :desc, created_at: :desc).pick(:value) if aggregation == :latest
8
+
9
+ dispatch(scope, aggregation)
10
+ end
11
+
12
+ def apply_grouped(scope, group_sql, aggregation)
13
+ return latest_per_group(scope, group_sql) if aggregation == :latest
14
+
15
+ dispatch(scope.group(Arel.sql(group_sql)), aggregation)
16
+ end
17
+
18
+ private
19
+
20
+ def dispatch(scope, aggregation)
21
+ case aggregation
22
+ when :sum then scope.sum(:value)
23
+ when :count then scope.count
24
+ when :max then scope.maximum(:value)
25
+ when :min then scope.minimum(:value)
26
+ when :mean then scope.average(:value)
27
+ when :count_distinct then scope.distinct.count(:value)
28
+ end
29
+ end
30
+
31
+ def latest_per_group(scope, group_sql)
32
+ subquery = scope
33
+ .select(Arel.sql("DISTINCT ON (#{group_sql}) #{group_sql}, value"))
34
+ .order(Arel.sql("#{group_sql}, recorded_at DESC, created_at DESC"))
35
+
36
+ rows = Event.connection.select_rows("SELECT * FROM (#{subquery.to_sql}) AS t")
37
+
38
+ rows.each_with_object({}) do |row, h|
39
+ group_vals = row[0...-1]
40
+ key = group_vals.length == 1 ? group_vals.first : group_vals
41
+ h[key] = BigDecimal(row.last)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ # Mutation methods (meter, freeze!, reset!) require the caller to hold MeterBox's Mutex.
5
+ # fetch is lock-free — @meters is never mutated after freeze!.
6
+ class Configuration
7
+ def initialize
8
+ reset!
9
+ end
10
+
11
+ def meter(name, dimensions: {}, aggregation: :sum)
12
+ raise ConfigurationFrozen, "MeterBox configuration is frozen" if @frozen
13
+ @meters[name.to_sym] = Meter.new(name: name, dimensions: dimensions, aggregation: aggregation)
14
+ end
15
+
16
+ def fetch(name)
17
+ @meters.fetch(name.to_sym) do
18
+ raise UnknownMeter, "no meter registered as '#{name}'"
19
+ end
20
+ end
21
+
22
+ def freeze!
23
+ @frozen = true
24
+ end
25
+
26
+ def freeze
27
+ raise NoMethodError, "use MeterBox::Configuration#freeze! to lock the registry"
28
+ end
29
+
30
+ def reset!
31
+ @meters = {}
32
+ @frozen = false
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class ConfigurationFrozen < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class Event < ActiveRecord::Base
5
+ self.table_name = "meter_box_events"
6
+
7
+ belongs_to :owner, polymorphic: true
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class InvalidDimensionValue < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class InvalidValue < Error; end
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class Meter
5
+ AGGREGATIONS = %i[sum count latest max min mean count_distinct].freeze
6
+
7
+ attr_reader :name, :dimensions, :aggregation
8
+
9
+ def initialize(name:, dimensions:, aggregation: :sum)
10
+ @name = name.to_sym
11
+ @dimensions = dimensions.transform_keys(&:to_sym)
12
+ @aggregation = aggregation.to_sym
13
+ unless AGGREGATIONS.include?(@aggregation)
14
+ raise ArgumentError,
15
+ "unknown aggregation :#{@aggregation} for meter '#{@name}' (allowed: #{AGGREGATIONS.inspect})"
16
+ end
17
+ end
18
+
19
+ def declared_keys
20
+ @dimensions.keys
21
+ end
22
+
23
+ def validate_dimensions!(supplied)
24
+ supplied = supplied.transform_keys(&:to_sym)
25
+
26
+ check_required!(supplied)
27
+ check_unknown_keys!(supplied)
28
+ check_values!(supplied)
29
+ end
30
+
31
+ private
32
+
33
+ def check_required!(supplied)
34
+ @dimensions.each do |key, opts|
35
+ next unless opts[:required]
36
+ next if supplied.key?(key)
37
+ raise MissingDimension,
38
+ "missing required dimension '#{key}' for meter '#{name}'"
39
+ end
40
+ end
41
+
42
+ def check_unknown_keys!(supplied)
43
+ undeclared = supplied.keys - declared_keys
44
+ return if undeclared.empty?
45
+ raise UnknownDimension,
46
+ "undeclared dimension(s) #{undeclared.inspect} for meter '#{name}'"
47
+ end
48
+
49
+ def check_values!(supplied)
50
+ supplied.each do |key, value|
51
+ opts = @dimensions[key]
52
+ next unless opts && opts[:values]
53
+ allowed = opts[:values].map(&:to_s)
54
+ next if allowed.include?(value.to_s)
55
+ raise InvalidDimensionValue,
56
+ "value '#{value}' not allowed for dimension '#{key}' on meter '#{name}' (allowed: #{allowed.inspect})"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class MissingDimension < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class MissingOwner < Error; end
5
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class Query
5
+ ALLOWED_OPTS = %i[since until where].freeze
6
+
7
+ def self.total(owner:, meter:, **opts)
8
+ validate_opts!(opts)
9
+ meter_obj = MeterBox.config.fetch(meter)
10
+ Aggregation.apply(build_scope(owner, meter, opts), meter_obj.aggregation)
11
+ end
12
+
13
+ def self.breakdown(owner:, meter:, by:, **opts)
14
+ validate_opts!(opts)
15
+ meter_obj = MeterBox.config.fetch(meter)
16
+ keys = Array(by).map(&:to_sym)
17
+ keys.each do |k|
18
+ unless meter_obj.declared_keys.include?(k)
19
+ raise UnknownDimension,
20
+ "dimension '#{k}' not declared on meter '#{meter}'"
21
+ end
22
+ end
23
+
24
+ scope = build_scope(owner, meter, opts)
25
+ group_sql = keys.map { |k| "dimensions->>'#{k}'" }.join(", ")
26
+ raw = Aggregation.apply_grouped(scope, group_sql, meter_obj.aggregation)
27
+
28
+ raw.each_with_object({}) do |(row, val), out|
29
+ values = Array(row)
30
+ out[keys.zip(values).to_h] = val
31
+ end
32
+ end
33
+
34
+ def self.over_cap?(owner:, meter:, cap:, **opts)
35
+ (total(owner: owner, meter: meter, **opts) || 0) >= cap
36
+ end
37
+
38
+ def self.events_for(owner:, meter:, **opts)
39
+ validate_opts!(opts)
40
+ build_scope(owner, meter, opts)
41
+ end
42
+
43
+ class << self
44
+ private
45
+
46
+ def validate_opts!(opts)
47
+ bad = opts.keys - ALLOWED_OPTS
48
+ raise ArgumentError, "unknown option(s) #{bad.inspect}" unless bad.empty?
49
+ end
50
+
51
+ def build_scope(owner, meter, opts)
52
+ validate_owner!(owner)
53
+ meter_obj = MeterBox.config.fetch(meter)
54
+
55
+ where = opts.fetch(:where, {})
56
+ where.each_key do |k|
57
+ unless meter_obj.declared_keys.include?(k.to_sym)
58
+ raise UnknownDimension,
59
+ "dimension '#{k}' not declared on meter '#{meter}'"
60
+ end
61
+ end
62
+
63
+ scope = Event.where(
64
+ owner_type: owner.class.name,
65
+ owner_id: owner.id.to_s,
66
+ meter_name: meter.to_s
67
+ )
68
+ scope = scope.where("recorded_at >= ?", opts[:since]) if opts[:since]
69
+ scope = scope.where("recorded_at < ?", opts[:until]) if opts[:until]
70
+ where.each do |k, v|
71
+ scope = scope.where("dimensions ->> ? = ?", k.to_s, v.to_s)
72
+ end
73
+ scope
74
+ end
75
+
76
+ def validate_owner!(owner)
77
+ raise MissingOwner, "owner is required" if owner.nil?
78
+ unless owner.respond_to?(:id) && !owner.id.nil?
79
+ raise MissingOwner, "owner must respond to #id with a non-nil value"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class Recorder
5
+ def self.call(**kwargs)
6
+ new.call(**kwargs)
7
+ end
8
+
9
+ def call(owner:, meter:, value: 1, dimensions: {}, metadata: {},
10
+ idempotency_key: nil, recorded_at: nil)
11
+ validate_owner!(owner)
12
+ meter_obj = MeterBox.config.fetch(meter)
13
+ validate_value!(value)
14
+ validate_metadata!(metadata)
15
+ meter_obj.validate_dimensions!(dimensions)
16
+
17
+ attrs = {
18
+ owner_type: owner.class.name,
19
+ owner_id: owner.id.to_s,
20
+ meter_name: meter_obj.name.to_s,
21
+ value: value,
22
+ dimensions: stringify(dimensions),
23
+ metadata: stringify_keys(metadata),
24
+ idempotency_key: idempotency_key,
25
+ recorded_at: recorded_at || Time.current
26
+ }
27
+
28
+ insert_with_idempotency(attrs)
29
+ end
30
+
31
+ private
32
+
33
+ def validate_owner!(owner)
34
+ raise MissingOwner, "owner is required" if owner.nil?
35
+ unless owner.respond_to?(:id) && !owner.id.nil?
36
+ raise MissingOwner, "owner must respond to #id with a non-nil value"
37
+ end
38
+ end
39
+
40
+ def validate_value!(value)
41
+ return if value.is_a?(Numeric)
42
+ raise InvalidValue, "value must be Numeric (got #{value.class})"
43
+ end
44
+
45
+ def validate_metadata!(metadata)
46
+ return if metadata.is_a?(Hash)
47
+ raise InvalidValue, "metadata must be a Hash (got #{metadata.class})"
48
+ end
49
+
50
+ def stringify(dims)
51
+ dims.each_with_object({}) do |(k, v), h|
52
+ h[k.to_s] = v.is_a?(Symbol) ? v.to_s : v
53
+ end
54
+ end
55
+
56
+ def stringify_keys(hash)
57
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
58
+ end
59
+
60
+ def insert_with_idempotency(attrs)
61
+ Event.create!(attrs)
62
+ rescue ActiveRecord::RecordNotUnique
63
+ Event.find_by(
64
+ owner_type: attrs[:owner_type],
65
+ owner_id: attrs[:owner_id],
66
+ meter_name: attrs[:meter_name],
67
+ idempotency_key: attrs[:idempotency_key]
68
+ )
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class UnknownDimension < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ class UnknownMeter < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MeterBox
4
+ VERSION = "0.1.0"
5
+ end
data/lib/meter_box.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require_relative "meter_box/version"
6
+ require_relative "meter_box/error"
7
+ require_relative "meter_box/configuration_frozen"
8
+ require_relative "meter_box/unknown_meter"
9
+ require_relative "meter_box/missing_dimension"
10
+ require_relative "meter_box/unknown_dimension"
11
+ require_relative "meter_box/invalid_dimension_value"
12
+ require_relative "meter_box/missing_owner"
13
+ require_relative "meter_box/invalid_value"
14
+ require_relative "meter_box/meter"
15
+ require_relative "meter_box/configuration"
16
+ require_relative "meter_box/event"
17
+ require_relative "meter_box/recorder"
18
+ require_relative "meter_box/aggregation"
19
+ require_relative "meter_box/query"
20
+
21
+ module MeterBox
22
+ @mutex = Mutex.new
23
+
24
+ def self.config
25
+ @config || @mutex.synchronize { @config ||= Configuration.new }
26
+ end
27
+
28
+ def self.configure
29
+ @mutex.synchronize do
30
+ yield(@config ||= Configuration.new)
31
+ @config.freeze!
32
+ end
33
+ end
34
+
35
+ def self.reset!
36
+ @mutex.synchronize { @config&.reset! }
37
+ end
38
+
39
+ def self.record(**kwargs)
40
+ Recorder.call(**kwargs)
41
+ end
42
+
43
+ def self.total(**kwargs)
44
+ Query.total(**kwargs)
45
+ end
46
+
47
+ def self.breakdown(**kwargs)
48
+ Query.breakdown(**kwargs)
49
+ end
50
+
51
+ def self.over_cap?(**kwargs)
52
+ Query.over_cap?(**kwargs)
53
+ end
54
+
55
+ def self.events_for(**kwargs)
56
+ Query.events_for(**kwargs)
57
+ end
58
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: meter_box
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ian Murray
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ostruct
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pg
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: Record and query multi-dimensional usage events against polymorphic owners.
83
+ Supports typed dimensions, idempotent inserts, time-range aggregation, and dimension
84
+ breakdowns.
85
+ email:
86
+ - ianmurrays@gmail.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE.txt
93
+ - README.md
94
+ - lib/generators/meter_box/install/install_generator.rb
95
+ - lib/generators/meter_box/install/templates/create_meter_box_events.rb.erb
96
+ - lib/generators/meter_box/install/templates/meter_box.rb
97
+ - lib/meter_box.rb
98
+ - lib/meter_box/aggregation.rb
99
+ - lib/meter_box/configuration.rb
100
+ - lib/meter_box/configuration_frozen.rb
101
+ - lib/meter_box/error.rb
102
+ - lib/meter_box/event.rb
103
+ - lib/meter_box/invalid_dimension_value.rb
104
+ - lib/meter_box/invalid_value.rb
105
+ - lib/meter_box/meter.rb
106
+ - lib/meter_box/missing_dimension.rb
107
+ - lib/meter_box/missing_owner.rb
108
+ - lib/meter_box/query.rb
109
+ - lib/meter_box/recorder.rb
110
+ - lib/meter_box/unknown_dimension.rb
111
+ - lib/meter_box/unknown_meter.rb
112
+ - lib/meter_box/version.rb
113
+ homepage: https://github.com/ianmurrays/meter_box
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/ianmurrays/meter_box
118
+ source_code_uri: https://github.com/ianmurrays/meter_box
119
+ changelog_uri: https://github.com/ianmurrays/meter_box/blob/main/CHANGELOG.md
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '3.2'
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 4.0.6
136
+ specification_version: 4
137
+ summary: Append-only, multi-dimensional usage metering for ActiveRecord + PostgreSQL
138
+ test_files: []