trifle-stats 2.3.1 → 2.4.1
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 +4 -4
- data/.github/workflows/ruby.yml +29 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +5 -1
- data/README.md +60 -78
- data/lib/trifle/stats/driver/mysql.rb +305 -0
- data/lib/trifle/stats/driver/sqlite.rb +32 -15
- data/lib/trifle/stats/operations/timeseries/increment.rb +8 -1
- data/lib/trifle/stats/operations/timeseries/set.rb +8 -1
- data/lib/trifle/stats/operations/timeseries/values.rb +14 -1
- data/lib/trifle/stats/version.rb +1 -1
- data/lib/trifle/stats.rb +1 -0
- data/trifle-stats.gemspec +7 -7
- metadata +24 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 574331154d00b65ea5a994f7c7fbc754a1225c39617294acbeed3886c88d5b98
|
|
4
|
+
data.tar.gz: d4cca3dd912c0642594805395f727fcce350cee4cd6b388ddf09fb3032895317
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 423c591af0e2f61178be6553afef6ec4a52ab96268f30b82720e8bf591d079ece3b5b4753766f1ebeecffe442b92162ea89619a4aa7fa58298aee62b226f1cba
|
|
7
|
+
data.tar.gz: a1fdba88136d21091e95eb59b1b87fab7e917626e40860d5e7128f7771c0428547e2070ba98ae8a9cf5437ee5d61c7671745336226fc25c46492fd6aee1756f1
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -42,6 +42,19 @@ jobs:
|
|
|
42
42
|
ports:
|
|
43
43
|
- 6379:6379
|
|
44
44
|
|
|
45
|
+
mysql:
|
|
46
|
+
image: mysql:8
|
|
47
|
+
env:
|
|
48
|
+
MYSQL_ROOT_PASSWORD: password
|
|
49
|
+
MYSQL_DATABASE: trifle_stats_test
|
|
50
|
+
options: >-
|
|
51
|
+
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -ppassword"
|
|
52
|
+
--health-interval 10s
|
|
53
|
+
--health-timeout 5s
|
|
54
|
+
--health-retries 10
|
|
55
|
+
ports:
|
|
56
|
+
- 3306:3306
|
|
57
|
+
|
|
45
58
|
mongodb:
|
|
46
59
|
image: mongo:6.0
|
|
47
60
|
env:
|
|
@@ -64,6 +77,11 @@ jobs:
|
|
|
64
77
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
|
|
65
78
|
REDIS_URL: redis://localhost:6379/0
|
|
66
79
|
MONGODB_URL: mongodb://root:password@localhost:27017/test_db?authSource=admin
|
|
80
|
+
MYSQL_HOST: 127.0.0.1
|
|
81
|
+
MYSQL_PORT: 3306
|
|
82
|
+
MYSQL_USER: root
|
|
83
|
+
MYSQL_PASSWORD: password
|
|
84
|
+
MYSQL_DATABASE: trifle_stats_test
|
|
67
85
|
|
|
68
86
|
steps:
|
|
69
87
|
- uses: actions/checkout@v4
|
|
@@ -94,6 +112,12 @@ jobs:
|
|
|
94
112
|
sleep 2
|
|
95
113
|
done
|
|
96
114
|
|
|
115
|
+
# Wait for MySQL
|
|
116
|
+
until timeout 1 bash -c "</dev/tcp/localhost/3306"; do
|
|
117
|
+
echo "Waiting for MySQL..."
|
|
118
|
+
sleep 2
|
|
119
|
+
done
|
|
120
|
+
|
|
97
121
|
- name: Setup Database
|
|
98
122
|
run: |
|
|
99
123
|
# Create database if needed (adjust based on your setup)
|
|
@@ -107,6 +131,11 @@ jobs:
|
|
|
107
131
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
|
|
108
132
|
REDIS_URL: redis://localhost:6379/0
|
|
109
133
|
MONGODB_URL: mongodb://root:password@localhost:27017/test_db?authSource=admin
|
|
134
|
+
MYSQL_HOST: 127.0.0.1
|
|
135
|
+
MYSQL_PORT: 3306
|
|
136
|
+
MYSQL_USER: root
|
|
137
|
+
MYSQL_PASSWORD: password
|
|
138
|
+
MYSQL_DATABASE: trifle_stats_test
|
|
110
139
|
|
|
111
140
|
- name: Rubocop
|
|
112
141
|
run: bundle exec rubocop
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
trifle-stats (2.
|
|
4
|
+
trifle-stats (2.4.1)
|
|
5
5
|
tzinfo (~> 2.0)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
8
8
|
remote: https://rubygems.org/
|
|
9
9
|
specs:
|
|
10
10
|
ast (2.4.2)
|
|
11
|
+
bigdecimal (4.0.1)
|
|
11
12
|
bson (4.12.1)
|
|
12
13
|
byebug (11.1.3)
|
|
13
14
|
concurrent-ruby (1.3.6)
|
|
@@ -16,6 +17,8 @@ GEM
|
|
|
16
17
|
mini_portile2 (2.8.9)
|
|
17
18
|
mongo (2.14.0)
|
|
18
19
|
bson (>= 4.8.2, < 5.0.0)
|
|
20
|
+
mysql2 (0.5.7)
|
|
21
|
+
bigdecimal
|
|
19
22
|
parallel (1.20.1)
|
|
20
23
|
parser (3.0.0.0)
|
|
21
24
|
ast (~> 2.4.1)
|
|
@@ -66,6 +69,7 @@ DEPENDENCIES
|
|
|
66
69
|
byebug
|
|
67
70
|
dotenv
|
|
68
71
|
mongo
|
|
72
|
+
mysql2
|
|
69
73
|
pg
|
|
70
74
|
rake (~> 12.0)
|
|
71
75
|
redis
|
data/README.md
CHANGED
|
@@ -3,92 +3,83 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/trifle-stats)
|
|
4
4
|
[](https://github.com/trifle-io/trifle-stats)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Time-series metrics for Ruby. Track anything (signups, revenue, job durations) using the database you already have. No InfluxDB. No TimescaleDB. Just one call and your existing Postgres, Redis, MongoDB, MySQL, or SQLite.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Part of the [Trifle](https://trifle.io) ecosystem. Also available in [Elixir](https://github.com/trifle-io/trifle_stats) and [Go](https://github.com/trifle-io/trifle_stats_go).
|
|
9
|
+
|
|
10
|
+
## Why Trifle::Stats?
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
- **No new infrastructure.** Uses your existing database. No dedicated time-series DB to deploy, maintain, or pay for.
|
|
13
|
+
- **One call, many dimensions.** Track nested breakdowns (revenue by country by channel) in a single `track` call. Automatic rollup across dynamic time granularities (`1m`, `6h`, `1d`, etc.).
|
|
14
|
+
- **Library-first.** Start with the gem. Add [Trifle App](https://trifle.io/product/app) dashboards, [Trifle CLI](https://github.com/trifle-io/trifle-cli) terminal access, or AI agent integration via MCP when you need them.
|
|
11
15
|
|
|
12
|
-
##
|
|
16
|
+
## Quick Start
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
### 1. Install
|
|
15
19
|
|
|
16
20
|
```ruby
|
|
17
21
|
gem 'trifle-stats'
|
|
18
22
|
```
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
$ bundle install
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Or install it yourself as:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
$ gem install trifle-stats
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Quick Start
|
|
33
|
-
|
|
34
|
-
### 1. Configure
|
|
24
|
+
### 2. Configure
|
|
35
25
|
|
|
36
26
|
```ruby
|
|
37
|
-
require 'trifle/stats'
|
|
38
|
-
|
|
39
27
|
Trifle::Stats.configure do |config|
|
|
40
|
-
config.driver = Trifle::Stats::Driver::
|
|
41
|
-
config.granularities = ['
|
|
28
|
+
config.driver = Trifle::Stats::Driver::Postgres.new(ActiveRecord::Base.connection)
|
|
29
|
+
config.granularities = ['1h', '1d', '1w', '1mo']
|
|
42
30
|
end
|
|
43
31
|
```
|
|
44
32
|
|
|
45
|
-
###
|
|
33
|
+
### 3. Track
|
|
46
34
|
|
|
47
35
|
```ruby
|
|
48
|
-
Trifle::Stats.track(
|
|
36
|
+
Trifle::Stats.track(
|
|
37
|
+
key: 'orders',
|
|
38
|
+
at: Time.now,
|
|
39
|
+
values: {
|
|
40
|
+
count: 1,
|
|
41
|
+
revenue: 49_90,
|
|
42
|
+
revenue_by_country: { us: 49_90 },
|
|
43
|
+
revenue_by_channel: { organic: 49_90 }
|
|
44
|
+
}
|
|
45
|
+
)
|
|
49
46
|
```
|
|
50
47
|
|
|
51
|
-
###
|
|
48
|
+
### 4. Query
|
|
52
49
|
|
|
53
50
|
```ruby
|
|
54
|
-
Trifle::Stats.values(
|
|
55
|
-
|
|
51
|
+
Trifle::Stats.values(
|
|
52
|
+
key: 'orders',
|
|
53
|
+
from: 1.week.ago,
|
|
54
|
+
to: Time.now,
|
|
55
|
+
granularity: :day
|
|
56
|
+
)
|
|
57
|
+
#=> { at: [Mon, Tue, Wed, ...], values: [{ "count" => 12, "revenue" => 598_80, ... }, ...] }
|
|
56
58
|
```
|
|
57
59
|
|
|
58
60
|
## Drivers
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
| Driver | Backend | Best for |
|
|
63
|
+
|--------|---------|----------|
|
|
64
|
+
| **Postgres** | JSONB upsert | Most production apps |
|
|
65
|
+
| **Redis** | Hash increment | High-throughput counters |
|
|
66
|
+
| **MongoDB** | Document upsert | Document-oriented stacks |
|
|
67
|
+
| **MySQL** | JSON column | MySQL shops |
|
|
68
|
+
| **SQLite** | JSON1 extension | Single-server apps, dev/test |
|
|
69
|
+
| **Process** | In-memory | Testing |
|
|
70
|
+
| **Dummy** | No-op | Disabled analytics |
|
|
68
71
|
|
|
69
72
|
## Features
|
|
70
73
|
|
|
71
|
-
- **
|
|
72
|
-
- **
|
|
73
|
-
- **Series operations
|
|
74
|
-
- **
|
|
75
|
-
- **
|
|
76
|
-
- **Driver flexibility** - Switch between storage backends easily
|
|
74
|
+
- **Dynamic time granularities.** Use any interval like `1m`, `10m`, `1h`, `6h`, `1d`, `1w`, `1mo`, `1q`, `1y`.
|
|
75
|
+
- **Nested value hierarchies.** Track dimensional breakdowns in a single call.
|
|
76
|
+
- **Series operations.** Aggregators (sum, avg, min, max), transponders, formatters.
|
|
77
|
+
- **Buffered writes.** Queue metrics in-memory before flushing to reduce write load.
|
|
78
|
+
- **Driver flexibility.** Switch backends without changing application code.
|
|
77
79
|
|
|
78
80
|
## Buffered Persistence
|
|
79
81
|
|
|
80
|
-
Every `track
|
|
81
|
-
default and flushes on an interval, when the queue reaches a configurable size, and again on shutdown
|
|
82
|
-
(`SIGTERM`/`at_exit`).
|
|
83
|
-
|
|
84
|
-
Available configuration options:
|
|
85
|
-
|
|
86
|
-
- `buffer_enabled` (default: `true`) – Disable to write-through synchronously
|
|
87
|
-
- `buffer_duration` (default: `1` second) – Maximum time between automatic flushes
|
|
88
|
-
- `buffer_size` (default: `256`) – Maximum queued actions before forcing a flush
|
|
89
|
-
- `buffer_aggregate` (default: `true`) – Combine repeated operations on the same key set
|
|
90
|
-
|
|
91
|
-
Example:
|
|
82
|
+
Every `track`/`assert`/`assort` call is buffered by default. The buffer flushes on an interval, when the queue reaches a configurable size, and on shutdown (`SIGTERM`/`at_exit`).
|
|
92
83
|
|
|
93
84
|
```ruby
|
|
94
85
|
Trifle::Stats.configure do |config|
|
|
@@ -99,34 +90,25 @@ Trifle::Stats.configure do |config|
|
|
|
99
90
|
end
|
|
100
91
|
```
|
|
101
92
|
|
|
102
|
-
|
|
103
|
-
increase the pool size or disable buffering to avoid starving other threads.
|
|
104
|
-
|
|
105
|
-
## Testing
|
|
106
|
-
|
|
107
|
-
Tests are run against all supported drivers. To run the test suite:
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
$ bundle exec rspec
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Ensure Redis, Postgres, and MongoDB are running locally. The test suite will handle database setup automatically.
|
|
93
|
+
Set `buffer_enabled = false` for synchronous write-through.
|
|
114
94
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Use **single layer testing** to focus on testing a specific class or module in isolation. Use **appropriate stubbing** for driver methods when testing higher-level operations.
|
|
95
|
+
## Documentation
|
|
118
96
|
|
|
119
|
-
|
|
97
|
+
Full guides, API reference, and examples at **[docs.trifle.io/trifle-stats-rb](https://docs.trifle.io/trifle-stats-rb)**
|
|
120
98
|
|
|
121
|
-
|
|
99
|
+
## Trifle Ecosystem
|
|
122
100
|
|
|
123
|
-
|
|
101
|
+
Trifle::Stats is the tracking layer. The ecosystem grows with you:
|
|
124
102
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
103
|
+
| Component | What it does |
|
|
104
|
+
|-----------|-------------|
|
|
105
|
+
| **[Trifle App](https://trifle.io/product/app)** | Dashboards, alerts, scheduled reports, AI-powered chat. Cloud or self-hosted. |
|
|
106
|
+
| **[Trifle CLI](https://github.com/trifle-io/trifle-cli)** | Query and push metrics from the terminal. MCP server mode for AI agents. |
|
|
107
|
+
| **[Trifle::Stats (Elixir)](https://github.com/trifle-io/trifle_stats)** | Elixir implementation with the same API and storage format. |
|
|
108
|
+
| **[Trifle Stats (Go)](https://github.com/trifle-io/trifle_stats_go)** | Go implementation with the same API and storage format. |
|
|
109
|
+
| **[Trifle::Traces](https://github.com/trifle-io/trifle-traces)** | Structured execution tracing for background jobs. |
|
|
110
|
+
| **[Trifle::Logs](https://github.com/trifle-io/trifle-logs)** | File-based log storage with ripgrep-powered search. |
|
|
111
|
+
| **[Trifle::Docs](https://github.com/trifle-io/trifle-docs)** | Map a folder of Markdown files to documentation URLs. |
|
|
130
112
|
|
|
131
113
|
## Contributing
|
|
132
114
|
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative '../mixins/packer'
|
|
6
|
+
|
|
7
|
+
module Trifle
|
|
8
|
+
module Stats
|
|
9
|
+
module Driver
|
|
10
|
+
class Mysql # rubocop:disable Metrics/ClassLength
|
|
11
|
+
include Mixins::Packer
|
|
12
|
+
attr_accessor :client, :table_name, :ping_table_name
|
|
13
|
+
|
|
14
|
+
def initialize(client, table_name: 'trifle_stats', joined_identifier: :full, ping_table_name: nil, system_tracking: true) # rubocop:disable Layout/LineLength
|
|
15
|
+
@client = client
|
|
16
|
+
@table_name = table_name
|
|
17
|
+
@ping_table_name = ping_table_name || "#{table_name}_ping"
|
|
18
|
+
@joined_identifier = self.class.normalize_joined_identifier(joined_identifier)
|
|
19
|
+
@system_tracking = system_tracking
|
|
20
|
+
@separator = '::'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.setup!(client, table_name: 'trifle_stats', joined_identifier: :full, ping_table_name: nil) # rubocop:disable Metrics/MethodLength
|
|
24
|
+
ping_table_name ||= "#{table_name}_ping"
|
|
25
|
+
identifier_mode = normalize_joined_identifier(joined_identifier)
|
|
26
|
+
quoted_table_name = quote_identifier(table_name)
|
|
27
|
+
quoted_ping_table_name = quote_identifier(ping_table_name)
|
|
28
|
+
|
|
29
|
+
case identifier_mode
|
|
30
|
+
when :full
|
|
31
|
+
client.query(<<~SQL)
|
|
32
|
+
CREATE TABLE IF NOT EXISTS #{quoted_table_name}
|
|
33
|
+
(`key` VARCHAR(255) PRIMARY KEY, `data` JSON NOT NULL)
|
|
34
|
+
SQL
|
|
35
|
+
when :partial
|
|
36
|
+
client.query(<<~SQL)
|
|
37
|
+
CREATE TABLE IF NOT EXISTS #{quoted_table_name}
|
|
38
|
+
(`key` VARCHAR(255) NOT NULL, `at` DATETIME(6) NOT NULL, `data` JSON NOT NULL, PRIMARY KEY (`key`, `at`))
|
|
39
|
+
SQL
|
|
40
|
+
else
|
|
41
|
+
client.query(<<~SQL)
|
|
42
|
+
CREATE TABLE IF NOT EXISTS #{quoted_table_name}
|
|
43
|
+
(`key` VARCHAR(255) NOT NULL, `granularity` VARCHAR(255) NOT NULL, `at` DATETIME(6) NOT NULL, `data` JSON NOT NULL, PRIMARY KEY (`key`, `granularity`, `at`))
|
|
44
|
+
SQL
|
|
45
|
+
client.query(<<~SQL)
|
|
46
|
+
CREATE TABLE IF NOT EXISTS #{quoted_ping_table_name}
|
|
47
|
+
(`key` VARCHAR(255) PRIMARY KEY, `at` DATETIME(6) NOT NULL, `data` JSON NOT NULL)
|
|
48
|
+
SQL
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def description
|
|
53
|
+
mode = if @joined_identifier == :full
|
|
54
|
+
'J'
|
|
55
|
+
else
|
|
56
|
+
@joined_identifier == :partial ? 'P' : 'S'
|
|
57
|
+
end
|
|
58
|
+
"#{self.class.name}(#{mode})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :separator
|
|
62
|
+
|
|
63
|
+
def system_identifier_for(key:)
|
|
64
|
+
key = Nocturnal::Key.new(key: '__system__key__', granularity: key.granularity, at: key.at)
|
|
65
|
+
identifier_for(key)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def system_data_for(key:, count: 1, tracking_key: nil)
|
|
69
|
+
tracking_key ||= key.key
|
|
70
|
+
self.class.pack(hash: { count: count, keys: { tracking_key => count } })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inc(keys:, values:, count: 1, tracking_key: nil)
|
|
74
|
+
data = self.class.pack(hash: values)
|
|
75
|
+
with_transaction(client) do |connection|
|
|
76
|
+
keys.each do |key|
|
|
77
|
+
identifier = identifier_for(key)
|
|
78
|
+
query, args = inc_query(identifier: identifier, data: data)
|
|
79
|
+
execute_prepared(connection, query, args)
|
|
80
|
+
track_system_data(connection, key, count, tracking_key)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def set(keys:, values:, count: 1, tracking_key: nil)
|
|
86
|
+
data = self.class.pack(hash: values)
|
|
87
|
+
with_transaction(client) do |connection|
|
|
88
|
+
keys.each do |key|
|
|
89
|
+
identifier = identifier_for(key)
|
|
90
|
+
query, args = set_query(identifier: identifier, data: data)
|
|
91
|
+
execute_prepared(connection, query, args)
|
|
92
|
+
track_system_data(connection, key, count, tracking_key)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def get(keys:)
|
|
98
|
+
keys.map do |key|
|
|
99
|
+
identifier = identifier_for(key)
|
|
100
|
+
self.class.unpack(hash: fetch_packed_data(identifier))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ping(key:, values:)
|
|
105
|
+
return [] if @joined_identifier
|
|
106
|
+
|
|
107
|
+
data = self.class.pack(hash: { data: values, at: key.at })
|
|
108
|
+
query, args = ping_query(key: key.key, at: key.at, data: data)
|
|
109
|
+
|
|
110
|
+
with_transaction(client) do |connection|
|
|
111
|
+
execute_prepared(connection, query, args)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# rubocop:disable Metrics/MethodLength
|
|
116
|
+
def scan(key:)
|
|
117
|
+
return [] if @joined_identifier
|
|
118
|
+
|
|
119
|
+
query = <<~SQL
|
|
120
|
+
SELECT `at`, CAST(`data` AS CHAR) AS data
|
|
121
|
+
FROM #{self.class.quote_identifier(ping_table_name)}
|
|
122
|
+
WHERE `key` = ?
|
|
123
|
+
ORDER BY `at` DESC
|
|
124
|
+
LIMIT 1
|
|
125
|
+
SQL
|
|
126
|
+
result = execute_prepared(client, query, [key.key]).first
|
|
127
|
+
return [] if result.nil?
|
|
128
|
+
|
|
129
|
+
[parse_time_value(result['at']), self.class.unpack(hash: JSON.parse(result['data']))]
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
# rubocop:enable Metrics/MethodLength
|
|
134
|
+
|
|
135
|
+
def self.normalize_joined_identifier(value)
|
|
136
|
+
case value
|
|
137
|
+
when nil, :full, 'full', :partial, 'partial'
|
|
138
|
+
value.nil? ? nil : value.to_sym
|
|
139
|
+
else
|
|
140
|
+
raise ArgumentError, 'joined_identifier must be nil, :full, "full", :partial, or "partial"'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.quote_identifier(identifier)
|
|
145
|
+
"`#{identifier.to_s.gsub('`', '``')}`"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def track_system_data(connection, key, count, tracking_key)
|
|
151
|
+
return unless @system_tracking
|
|
152
|
+
|
|
153
|
+
query, args = inc_query(
|
|
154
|
+
identifier: system_identifier_for(key: key),
|
|
155
|
+
data: system_data_for(key: key, count: count, tracking_key: tracking_key)
|
|
156
|
+
)
|
|
157
|
+
execute_prepared(connection, query, args)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
161
|
+
def fetch_packed_data(identifier)
|
|
162
|
+
conditions = identifier.keys.map { |column| "#{self.class.quote_identifier(column)} = ?" }.join(' AND ')
|
|
163
|
+
query = <<~SQL
|
|
164
|
+
SELECT CAST(`data` AS CHAR) AS data
|
|
165
|
+
FROM #{self.class.quote_identifier(table_name)}
|
|
166
|
+
WHERE #{conditions}
|
|
167
|
+
LIMIT 1
|
|
168
|
+
SQL
|
|
169
|
+
packed_data = execute_prepared(client, query, query_values(identifier)).first&.fetch('data', nil)
|
|
170
|
+
return {} if packed_data.nil? || packed_data.empty?
|
|
171
|
+
|
|
172
|
+
JSON.parse(packed_data)
|
|
173
|
+
rescue JSON::ParserError
|
|
174
|
+
{}
|
|
175
|
+
end
|
|
176
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
177
|
+
|
|
178
|
+
def inc_query(identifier:, data:)
|
|
179
|
+
upsert_query(
|
|
180
|
+
identifier: identifier,
|
|
181
|
+
data: data,
|
|
182
|
+
conflict_data_sql: build_inc_json_set_expression(data),
|
|
183
|
+
conflict_values: increment_values(data)
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def set_query(identifier:, data:)
|
|
188
|
+
upsert_query(
|
|
189
|
+
identifier: identifier,
|
|
190
|
+
data: data,
|
|
191
|
+
conflict_data_sql: build_set_json_set_expression(data),
|
|
192
|
+
conflict_values: serialized_set_values(data)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def upsert_query(identifier:, data:, conflict_data_sql:, conflict_values:)
|
|
197
|
+
columns = identifier.keys
|
|
198
|
+
columns_sql = columns.map { |column| self.class.quote_identifier(column) }.join(', ')
|
|
199
|
+
values_sql = (['?'] * columns.size + ['CAST(? AS JSON)']).join(', ')
|
|
200
|
+
|
|
201
|
+
query = <<~SQL
|
|
202
|
+
INSERT INTO #{self.class.quote_identifier(table_name)} (#{columns_sql}, `data`) VALUES (#{values_sql})
|
|
203
|
+
ON DUPLICATE KEY UPDATE `data` = #{conflict_data_sql}
|
|
204
|
+
SQL
|
|
205
|
+
|
|
206
|
+
[query, query_values(identifier) + [JSON.generate(data)] + conflict_values]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ping_query(key:, at:, data:)
|
|
210
|
+
query = <<~SQL
|
|
211
|
+
INSERT INTO #{self.class.quote_identifier(ping_table_name)} (`key`, `at`, `data`) VALUES (?, ?, CAST(? AS JSON))
|
|
212
|
+
ON DUPLICATE KEY UPDATE `at` = VALUES(`at`), `data` = VALUES(`data`)
|
|
213
|
+
SQL
|
|
214
|
+
[query, [key.to_s, format_time_value(at), JSON.generate(data)]]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def build_inc_json_set_expression(data)
|
|
218
|
+
expression = +'JSON_SET(COALESCE(`data`, JSON_OBJECT())'
|
|
219
|
+
data.each_key do |path_key|
|
|
220
|
+
path = json_path_for(path_key)
|
|
221
|
+
expression << ", '#{path}', (COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(`data`, JSON_OBJECT()), '#{path}')) AS DECIMAL(65,10)), 0) + CAST(? AS DECIMAL(65,10)))" # rubocop:disable Layout/LineLength
|
|
222
|
+
end
|
|
223
|
+
expression << ')'
|
|
224
|
+
expression
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def build_set_json_set_expression(data)
|
|
228
|
+
expression = +'JSON_SET(COALESCE(`data`, JSON_OBJECT())'
|
|
229
|
+
data.each_key do |path_key|
|
|
230
|
+
path = json_path_for(path_key)
|
|
231
|
+
expression << ", '#{path}', CAST(? AS JSON)"
|
|
232
|
+
end
|
|
233
|
+
expression << ')'
|
|
234
|
+
expression
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def query_values(identifier)
|
|
238
|
+
identifier.values.map { |value| normalize_query_value(value) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def increment_values(data)
|
|
242
|
+
data.map do |key, value|
|
|
243
|
+
next value if value.is_a?(Numeric)
|
|
244
|
+
|
|
245
|
+
raise ArgumentError, "increment requires numeric value for key #{key.inspect}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def serialized_set_values(data)
|
|
250
|
+
data.values.map { |value| JSON.generate(value) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def normalize_query_value(value)
|
|
254
|
+
return format_time_value(value) if value.is_a?(Time)
|
|
255
|
+
|
|
256
|
+
value
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def format_time_value(value)
|
|
260
|
+
parse_time_value(value).utc.strftime('%Y-%m-%d %H:%M:%S.%6N')
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def parse_time_value(value)
|
|
264
|
+
case value
|
|
265
|
+
when Time
|
|
266
|
+
value
|
|
267
|
+
when String
|
|
268
|
+
Time.parse(value)
|
|
269
|
+
when DateTime
|
|
270
|
+
value.to_time
|
|
271
|
+
else
|
|
272
|
+
raise ArgumentError, "unsupported time value: #{value.inspect}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def json_path_for(key)
|
|
277
|
+
escaped = key.to_s.gsub('\\', '\\\\').gsub('"', '\"').gsub("'", "''")
|
|
278
|
+
"$.\"#{escaped}\""
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def with_transaction(connection)
|
|
282
|
+
connection.query('START TRANSACTION')
|
|
283
|
+
result = yield(connection)
|
|
284
|
+
connection.query('COMMIT')
|
|
285
|
+
result
|
|
286
|
+
rescue StandardError
|
|
287
|
+
connection.query('ROLLBACK')
|
|
288
|
+
raise
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def execute_prepared(connection, query, args = [])
|
|
292
|
+
statement = connection.prepare(query)
|
|
293
|
+
result = statement.execute(*args)
|
|
294
|
+
result.respond_to?(:to_a) ? result.to_a : result
|
|
295
|
+
ensure
|
|
296
|
+
statement&.close
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def identifier_for(key)
|
|
300
|
+
key.identifier(separator, @joined_identifier)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'time'
|
|
4
5
|
require_relative '../mixins/packer'
|
|
5
6
|
|
|
6
7
|
module Trifle
|
|
@@ -126,7 +127,7 @@ module Trifle
|
|
|
126
127
|
sample = identifiers.first
|
|
127
128
|
|
|
128
129
|
results.each_with_object(Hash.new({})) do |r, o|
|
|
129
|
-
identifier = sample.each_with_index.to_h { |(k, _), i| [k, k == :at ? Time.
|
|
130
|
+
identifier = sample.each_with_index.to_h { |(k, _), i| [k, k == :at ? Time.iso8601(r[i]) : r[i]] }
|
|
130
131
|
|
|
131
132
|
o[identifier] = JSON.parse(r.last)
|
|
132
133
|
rescue JSON::ParserError
|
|
@@ -136,7 +137,7 @@ module Trifle
|
|
|
136
137
|
|
|
137
138
|
def get_query(identifiers:)
|
|
138
139
|
conditions = identifiers.map do |identifier|
|
|
139
|
-
identifier.map { |k, v|
|
|
140
|
+
identifier.map { |k, v| build_field_condition(k, v) }.join(' AND ')
|
|
140
141
|
end.join(' OR ')
|
|
141
142
|
|
|
142
143
|
<<-SQL
|
|
@@ -155,9 +156,11 @@ module Trifle
|
|
|
155
156
|
end
|
|
156
157
|
|
|
157
158
|
def ping_query(key:, at:, data:)
|
|
159
|
+
at_formatted = format_time_value(at)
|
|
160
|
+
|
|
158
161
|
<<-SQL
|
|
159
|
-
INSERT INTO #{ping_table_name} (key, at, data) VALUES ('#{key}', '#{
|
|
160
|
-
ON CONFLICT (key) DO UPDATE SET at = '#{
|
|
162
|
+
INSERT INTO #{ping_table_name} (key, at, data) VALUES ('#{key}', '#{at_formatted}', json('#{data.to_json}'))
|
|
163
|
+
ON CONFLICT (key) DO UPDATE SET at = '#{at_formatted}', data = json('#{data.to_json}');
|
|
161
164
|
SQL
|
|
162
165
|
end
|
|
163
166
|
|
|
@@ -168,7 +171,7 @@ module Trifle
|
|
|
168
171
|
return [] if result.nil?
|
|
169
172
|
|
|
170
173
|
# SQLite returns columns in order: key, at, data
|
|
171
|
-
[Time.
|
|
174
|
+
[Time.iso8601(result[1]), self.class.unpack(hash: JSON.parse(result[2]))]
|
|
172
175
|
rescue JSON::ParserError
|
|
173
176
|
[]
|
|
174
177
|
end
|
|
@@ -190,6 +193,15 @@ module Trifle
|
|
|
190
193
|
|
|
191
194
|
private
|
|
192
195
|
|
|
196
|
+
def build_field_condition(key, value)
|
|
197
|
+
return "#{key} = #{format_value(value)}" unless key == :at
|
|
198
|
+
|
|
199
|
+
formatted = format_time_value(value)
|
|
200
|
+
with_microseconds = formatted.sub('Z', '.000000Z')
|
|
201
|
+
|
|
202
|
+
"(at = '#{formatted}' OR at = '#{with_microseconds}')"
|
|
203
|
+
end
|
|
204
|
+
|
|
193
205
|
# Batch data operations to avoid SQLite parser stack overflow
|
|
194
206
|
# Splits large data hashes into smaller chunks to prevent too many nested json_set calls
|
|
195
207
|
def batch_data_operations(identifier:, data:, connection:, operation:)
|
|
@@ -203,15 +215,22 @@ module Trifle
|
|
|
203
215
|
end
|
|
204
216
|
|
|
205
217
|
def format_value(value)
|
|
218
|
+
return "'#{format_time_value(value)}'" if value.is_a?(Time) || value.is_a?(DateTime)
|
|
219
|
+
return value.to_s if value.is_a?(Integer) || value.is_a?(Float)
|
|
220
|
+
|
|
221
|
+
"'#{value}'"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def format_time_value(value)
|
|
206
225
|
case value
|
|
207
|
-
when String
|
|
208
|
-
"'#{value}'"
|
|
209
226
|
when Time
|
|
210
|
-
|
|
211
|
-
when
|
|
212
|
-
value.
|
|
227
|
+
value.getutc.iso8601(0)
|
|
228
|
+
when DateTime
|
|
229
|
+
value.to_time.getutc.iso8601(0)
|
|
230
|
+
when Integer
|
|
231
|
+
Time.at(value).getutc.iso8601(0)
|
|
213
232
|
else
|
|
214
|
-
|
|
233
|
+
Time.iso8601(value.to_s).getutc.iso8601(0)
|
|
215
234
|
end
|
|
216
235
|
end
|
|
217
236
|
|
|
@@ -224,11 +243,9 @@ module Trifle
|
|
|
224
243
|
|
|
225
244
|
def build_identifier_key(identifier)
|
|
226
245
|
return identifier[:key] if @joined_identifier == :full
|
|
227
|
-
if @joined_identifier == :partial
|
|
228
|
-
return "#{identifier[:key]}::#{identifier[:at].strftime('%Y-%m-%d %H:%M:%S')}"
|
|
229
|
-
end
|
|
246
|
+
return "#{identifier[:key]}::#{format_time_value(identifier[:at])}" if @joined_identifier == :partial
|
|
230
247
|
|
|
231
|
-
"#{identifier[:key]}::#{identifier[:granularity]}::#{identifier[:at]
|
|
248
|
+
"#{identifier[:key]}::#{identifier[:granularity]}::#{format_time_value(identifier[:at])}"
|
|
232
249
|
end
|
|
233
250
|
|
|
234
251
|
def identifier_for(key)
|
|
@@ -21,7 +21,7 @@ module Trifle
|
|
|
21
21
|
|
|
22
22
|
def key_for(granularity:)
|
|
23
23
|
pgrn = Nocturnal::Parser.new(granularity)
|
|
24
|
-
at = Nocturnal.new(@at, config: config).floor(pgrn.offset, pgrn.unit)
|
|
24
|
+
at = Nocturnal.new(localized_time(@at), config: config).floor(pgrn.offset, pgrn.unit)
|
|
25
25
|
Nocturnal::Key.new(key: key, granularity: granularity, at: at)
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -41,6 +41,13 @@ module Trifle
|
|
|
41
41
|
def tracking_key
|
|
42
42
|
@untracked ? '__untracked__' : nil
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def localized_time(time)
|
|
48
|
+
base_time = time.is_a?(Time) ? time : time.to_time
|
|
49
|
+
config.tz.utc_to_local(base_time.getutc)
|
|
50
|
+
end
|
|
44
51
|
end
|
|
45
52
|
end
|
|
46
53
|
end
|
|
@@ -21,7 +21,7 @@ module Trifle
|
|
|
21
21
|
|
|
22
22
|
def key_for(granularity:)
|
|
23
23
|
pgrn = Nocturnal::Parser.new(granularity)
|
|
24
|
-
at = Nocturnal.new(@at, config: config).floor(pgrn.offset, pgrn.unit)
|
|
24
|
+
at = Nocturnal.new(localized_time(@at), config: config).floor(pgrn.offset, pgrn.unit)
|
|
25
25
|
Nocturnal::Key.new(key: key, granularity: granularity, at: at)
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -41,6 +41,13 @@ module Trifle
|
|
|
41
41
|
def tracking_key
|
|
42
42
|
@untracked ? '__untracked__' : nil
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def localized_time(time)
|
|
48
|
+
base_time = time.is_a?(Time) ? time : time.to_time
|
|
49
|
+
config.tz.utc_to_local(base_time.getutc)
|
|
50
|
+
end
|
|
44
51
|
end
|
|
45
52
|
end
|
|
46
53
|
end
|
|
@@ -23,7 +23,13 @@ module Trifle
|
|
|
23
23
|
def timeline
|
|
24
24
|
@timeline ||= begin
|
|
25
25
|
pgrn = Nocturnal::Parser.new(granularity)
|
|
26
|
-
Nocturnal.timeline(
|
|
26
|
+
Nocturnal.timeline(
|
|
27
|
+
from: localized_time(@from),
|
|
28
|
+
to: localized_time(@to),
|
|
29
|
+
offset: pgrn.offset,
|
|
30
|
+
unit: pgrn.unit,
|
|
31
|
+
config: config
|
|
32
|
+
)
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
|
|
@@ -54,6 +60,13 @@ module Trifle
|
|
|
54
60
|
def perform
|
|
55
61
|
@skip_blanks ? clean_values : values
|
|
56
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def localized_time(time)
|
|
67
|
+
base_time = time.is_a?(Time) ? time : time.to_time
|
|
68
|
+
config.tz.utc_to_local(base_time.getutc)
|
|
69
|
+
end
|
|
57
70
|
end
|
|
58
71
|
end
|
|
59
72
|
end
|
data/lib/trifle/stats/version.rb
CHANGED
data/lib/trifle/stats.rb
CHANGED
|
@@ -14,6 +14,7 @@ require 'trifle/stats/designator/custom'
|
|
|
14
14
|
require 'trifle/stats/designator/geometric'
|
|
15
15
|
require 'trifle/stats/designator/linear'
|
|
16
16
|
require 'trifle/stats/driver/mongo'
|
|
17
|
+
require 'trifle/stats/driver/mysql'
|
|
17
18
|
require 'trifle/stats/driver/postgres'
|
|
18
19
|
require 'trifle/stats/driver/process'
|
|
19
20
|
require 'trifle/stats/driver/redis'
|
data/trifle-stats.gemspec
CHANGED
|
@@ -6,19 +6,18 @@ Gem::Specification.new do |spec|
|
|
|
6
6
|
spec.authors = ['Jozef Vaclavik']
|
|
7
7
|
spec.email = ['jozef@hey.com']
|
|
8
8
|
|
|
9
|
-
spec.summary = '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'
|
|
13
|
-
'increments counters for each enabled range. '\
|
|
14
|
-
'It supports timezones and different week beginning.'
|
|
9
|
+
spec.summary = 'Time-series metrics for Ruby backed by Postgres, Redis, MongoDB, MySQL, or SQLite.'
|
|
10
|
+
spec.description = 'Track custom business metrics using your existing database. '\
|
|
11
|
+
'One call to record nested, multi-dimensional counters with '\
|
|
12
|
+
'automatic rollup across configurable time granularities.'
|
|
15
13
|
spec.homepage = 'https://trifle.io'
|
|
16
14
|
spec.licenses = ['MIT']
|
|
17
15
|
spec.required_ruby_version = Gem::Requirement.new('>= 2.6')
|
|
18
16
|
|
|
19
17
|
spec.metadata['homepage_uri'] = spec.homepage
|
|
20
18
|
spec.metadata['source_code_uri'] = 'https://github.com/trifle-io/trifle-stats'
|
|
21
|
-
spec.metadata['changelog_uri'] = 'https://trifle.io/trifle-stats/changelog'
|
|
19
|
+
spec.metadata['changelog_uri'] = 'https://docs.trifle.io/trifle-stats-rb/changelog'
|
|
20
|
+
spec.metadata['documentation_uri'] = 'https://docs.trifle.io/trifle-stats-rb'
|
|
22
21
|
|
|
23
22
|
# Specify which files should be added to the gem when it is released.
|
|
24
23
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
@@ -33,6 +32,7 @@ Gem::Specification.new do |spec|
|
|
|
33
32
|
spec.add_development_dependency('byebug', '>= 0')
|
|
34
33
|
spec.add_development_dependency('dotenv')
|
|
35
34
|
spec.add_development_dependency('mongo', '>= 2.14.0')
|
|
35
|
+
spec.add_development_dependency('mysql2', '>= 0.5.5')
|
|
36
36
|
spec.add_development_dependency('sqlite3', '>= 1.4.4')
|
|
37
37
|
spec.add_development_dependency('pg', '>= 1.2')
|
|
38
38
|
spec.add_development_dependency('rake', '~> 13.0')
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trifle-stats
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jozef Vaclavik
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -66,6 +66,20 @@ dependencies:
|
|
|
66
66
|
- - ">="
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: 2.14.0
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: mysql2
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 0.5.5
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: 0.5.5
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
84
|
name: sqlite3
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -164,9 +178,9 @@ dependencies:
|
|
|
164
178
|
- - "~>"
|
|
165
179
|
- !ruby/object:Gem::Version
|
|
166
180
|
version: '2.0'
|
|
167
|
-
description:
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
description: Track custom business metrics using your existing database. One call
|
|
182
|
+
to record nested, multi-dimensional counters with automatic rollup across configurable
|
|
183
|
+
time granularities.
|
|
170
184
|
email:
|
|
171
185
|
- jozef@hey.com
|
|
172
186
|
executables: []
|
|
@@ -212,6 +226,7 @@ files:
|
|
|
212
226
|
- lib/trifle/stats/designator/linear.rb
|
|
213
227
|
- lib/trifle/stats/driver/README.md
|
|
214
228
|
- lib/trifle/stats/driver/mongo.rb
|
|
229
|
+
- lib/trifle/stats/driver/mysql.rb
|
|
215
230
|
- lib/trifle/stats/driver/postgres.rb
|
|
216
231
|
- lib/trifle/stats/driver/process.rb
|
|
217
232
|
- lib/trifle/stats/driver/redis.rb
|
|
@@ -248,7 +263,8 @@ licenses:
|
|
|
248
263
|
metadata:
|
|
249
264
|
homepage_uri: https://trifle.io
|
|
250
265
|
source_code_uri: https://github.com/trifle-io/trifle-stats
|
|
251
|
-
changelog_uri: https://trifle.io/trifle-stats/changelog
|
|
266
|
+
changelog_uri: https://docs.trifle.io/trifle-stats-rb/changelog
|
|
267
|
+
documentation_uri: https://docs.trifle.io/trifle-stats-rb
|
|
252
268
|
post_install_message:
|
|
253
269
|
rdoc_options: []
|
|
254
270
|
require_paths:
|
|
@@ -267,6 +283,6 @@ requirements: []
|
|
|
267
283
|
rubygems_version: 3.3.3
|
|
268
284
|
signing_key:
|
|
269
285
|
specification_version: 4
|
|
270
|
-
summary:
|
|
271
|
-
|
|
286
|
+
summary: Time-series metrics for Ruby backed by Postgres, Redis, MongoDB, MySQL, or
|
|
287
|
+
SQLite.
|
|
272
288
|
test_files: []
|