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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +291 -0
- data/lib/generators/meter_box/install/install_generator.rb +25 -0
- data/lib/generators/meter_box/install/templates/create_meter_box_events.rb.erb +28 -0
- data/lib/generators/meter_box/install/templates/meter_box.rb +21 -0
- data/lib/meter_box/aggregation.rb +46 -0
- data/lib/meter_box/configuration.rb +35 -0
- data/lib/meter_box/configuration_frozen.rb +5 -0
- data/lib/meter_box/error.rb +5 -0
- data/lib/meter_box/event.rb +9 -0
- data/lib/meter_box/invalid_dimension_value.rb +5 -0
- data/lib/meter_box/invalid_value.rb +5 -0
- data/lib/meter_box/meter.rb +60 -0
- data/lib/meter_box/missing_dimension.rb +5 -0
- data/lib/meter_box/missing_owner.rb +5 -0
- data/lib/meter_box/query.rb +84 -0
- data/lib/meter_box/recorder.rb +71 -0
- data/lib/meter_box/unknown_dimension.rb +5 -0
- data/lib/meter_box/unknown_meter.rb +5 -0
- data/lib/meter_box/version.rb +5 -0
- data/lib/meter_box.rb +58 -0
- metadata +138 -0
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,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,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
|
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: []
|