puma_metrics_engine 1.0.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: 711453f75e30a751aaedaa85d58133fb0c0b5e5d4819339503f10af88bdbd964
4
+ data.tar.gz: 291c8e013fd863f42b6adb83b95b6c38904de5402442902c3fe65c53f8fd231f
5
+ SHA512:
6
+ metadata.gz: aaa668f89f9931652c7682ba0c45390bc4a37dde9fe8e9d692549a8ee246cbfb92128cdbfcfffd5b22015b0f19e599b9dafe59abf89103ee9e07ab94bf353f26
7
+ data.tar.gz: afe36aab4715936a485b54279c803ed4dc6d8fe8027bdbff9bb487e5bac547bae3db37c1784c37080ae93a19478bf92f4251f836b934e395f40773b124db4ff7
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 BigBinary
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.
22
+
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # PumaMetricsEngine
2
+
3
+ A Rails engine gem that provides a `/matrix` endpoint for Puma metrics and queue time statistics.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "puma_metrics_engine", path: "../puma_metrics_engine" # For local development
11
+ # Or from a gem server:
12
+ # gem "puma_metrics_engine"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ $ bundle install
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Mount the engine in your routes:
24
+
25
+ ```ruby
26
+ # config/routes.rb
27
+ Rails.application.routes.draw do
28
+ mount PumaMetricsEngine::Engine, at: "/"
29
+ # ... other routes
30
+ end
31
+ ```
32
+
33
+ The engine automatically provides a `/matrix` endpoint that returns JSON with:
34
+
35
+ - `timestamp`: Current timestamp
36
+ - `queue_time_ms`: Aggregate queue time statistics (avg, p50, p95, p99, max, min, sample_count)
37
+ - `queue_time_windows`: Queue time averages for 10s, 20s, and 30s windows
38
+ - `requests_per_minute`: Request count in the last minute
39
+ - `puma`: Puma server statistics (workers, threads, backlog, etc.)
40
+
41
+ ## Example Response
42
+
43
+ ```json
44
+ {
45
+ "timestamp": "2024-01-15T10:30:00Z",
46
+ "queue_time_ms": {
47
+ "avg": 12.5,
48
+ "p50": 10.0,
49
+ "p95": 25.0,
50
+ "p99": 50.0,
51
+ "max": 100.0,
52
+ "min": 1.0,
53
+ "sample_count": 1000
54
+ },
55
+ "queue_time_windows": {
56
+ "10s": { "avg": 12.3, "sample_count": 50 },
57
+ "20s": { "avg": 12.5, "sample_count": 100 },
58
+ "30s": { "avg": 12.4, "sample_count": 150 }
59
+ },
60
+ "requests_per_minute": {
61
+ "count": 120,
62
+ "window_seconds": 60
63
+ },
64
+ "puma": {
65
+ "workers": 2,
66
+ "phase": 0,
67
+ "booted_workers": 2,
68
+ "backlog": 0,
69
+ "running": 10,
70
+ "pool_capacity": 20,
71
+ "max_threads": 10
72
+ }
73
+ }
74
+ ```
75
+
76
+ ## Dependencies
77
+
78
+ - **Rails** >= 7.0
79
+ - **Redis** >= 5.0 (for storing queue time and request data)
80
+ - **Puma** (optional, for Puma-specific stats)
81
+
82
+ ## Configuration
83
+
84
+ The engine uses the following Redis keys (shared with ApplicationController if tracking is enabled):
85
+ - `puma:request_timestamps` - Request timestamps
86
+ - `puma:queue_times` - Queue time data
87
+
88
+ The engine reads from the same Redis keys that `ApplicationController` writes to (if you have queue time tracking enabled in your ApplicationController).
89
+
90
+ Redis connection uses `ENV["REDIS_URL"]` or defaults to `redis://localhost:6379/1`.
91
+
92
+ ## Notes
93
+
94
+ - The endpoint skips CSRF token verification
95
+ - The engine is isolated in the `PumaMetricsEngine` namespace
96
+ - No database migrations required
97
+
98
+ ## Development
99
+
100
+ After checking out the repo, run `bundle install` to install dependencies.
101
+
102
+ To build the gem:
103
+
104
+ ```bash
105
+ gem build puma_metrics_engine.gemspec
106
+ ```
107
+
108
+ To install the gem locally:
109
+
110
+ ```bash
111
+ gem install ./puma_metrics_engine-1.0.0.gem
112
+ ```
113
+
114
+ ## Contributing
115
+
116
+ Bug reports and pull requests are welcome.
117
+
118
+ ## License
119
+
120
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
121
+
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaMetricsEngine
4
+ class MatrixController < ActionController::Base
5
+ skip_before_action :verify_authenticity_token
6
+
7
+ TTL_SECONDS = 300 # 5 minutes
8
+ REQUESTS_KEY = "puma:request_timestamps"
9
+ QUEUE_TIMES_KEY = "puma:queue_times"
10
+ MAX_SAMPLES = 1000
11
+
12
+ def show
13
+ puma_stats = fetch_puma_stats
14
+ queue_time_stats = calculate_aggregate_queue_times
15
+ queue_time_windows = calculate_queue_time_windows
16
+ requests_per_minute = calculate_requests_per_minute
17
+
18
+ render json: {
19
+ timestamp: Time.current,
20
+ queue_time_ms: queue_time_stats,
21
+ queue_time_windows: queue_time_windows,
22
+ requests_per_minute: requests_per_minute,
23
+ puma: puma_stats
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def calculate_aggregate_queue_times
30
+ # Fetch all queue times from Redis (already sorted by timestamp)
31
+ # This includes queue times from ALL requests across all workers and dynos (aggregated in shared Redis)
32
+ times = redis.zrange(QUEUE_TIMES_KEY, 0, -1).map(&:to_f)
33
+
34
+ return { error: "No queue time data available", sample_count: 0 } if times.empty?
35
+
36
+ sorted = times.sort
37
+ {
38
+ avg: times.sum / times.size.to_f,
39
+ p50: percentile(sorted, 50),
40
+ p95: percentile(sorted, 95),
41
+ p99: percentile(sorted, 99),
42
+ max: times.max,
43
+ min: times.min,
44
+ sample_count: times.size
45
+ }.transform_values { |v| v.is_a?(Numeric) ? v.round(2) : v }
46
+ rescue Redis::BaseError => e
47
+ {
48
+ error: "Failed to fetch queue time data: #{e.message}",
49
+ sample_count: 0
50
+ }
51
+ end
52
+
53
+ def calculate_queue_time_windows
54
+ current_time = Time.now.to_f
55
+ windows = {
56
+ "10s" => current_time - 10,
57
+ "20s" => current_time - 20,
58
+ "30s" => current_time - 30
59
+ }
60
+
61
+ result = {}
62
+ windows.each do |window_name, start_time|
63
+ # Get queue times within the time window using Redis sorted set range query
64
+ queue_times = redis.zrangebyscore(QUEUE_TIMES_KEY, start_time, current_time)
65
+ times = queue_times.map(&:to_f)
66
+
67
+ if times.empty?
68
+ result[window_name] = {
69
+ avg: 0,
70
+ sample_count: 0
71
+ }
72
+ else
73
+ result[window_name] = {
74
+ avg: (times.sum / times.size.to_f).round(2),
75
+ sample_count: times.size
76
+ }
77
+ end
78
+ end
79
+
80
+ result
81
+ rescue Redis::BaseError => e
82
+ {
83
+ error: "Failed to calculate queue time windows: #{e.message}",
84
+ "10s" => { avg: 0, sample_count: 0 },
85
+ "20s" => { avg: 0, sample_count: 0 },
86
+ "30s" => { avg: 0, sample_count: 0 }
87
+ }
88
+ end
89
+
90
+ def calculate_requests_per_minute
91
+ current_time = Time.now.to_f
92
+ one_minute_ago = current_time - 60
93
+
94
+ # Count requests in the last minute using Redis sorted set range query
95
+ # This counts ALL requests across all workers and dynos (aggregated in shared Redis)
96
+ request_count = redis.zcount(REQUESTS_KEY, one_minute_ago, current_time)
97
+
98
+ {
99
+ count: request_count,
100
+ window_seconds: 60
101
+ }
102
+ rescue Redis::BaseError => e
103
+ {
104
+ error: "Failed to calculate requests per minute: #{e.message}",
105
+ count: 0,
106
+ window_seconds: 60
107
+ }
108
+ end
109
+
110
+ def redis
111
+ @redis ||= Redis.new(url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" })
112
+ end
113
+
114
+ def percentile(sorted_array, percentile)
115
+ return 0 if sorted_array.empty?
116
+
117
+ index = (percentile / 100.0 * sorted_array.size).ceil - 1
118
+ sorted_array[[index, 0].max]
119
+ end
120
+
121
+ def fetch_puma_stats
122
+ return { error: "Puma stats not available" } unless defined?(Puma)
123
+
124
+ begin
125
+ if Puma.respond_to?(:stats)
126
+ stats = JSON.parse(Puma.stats, symbolize_names: true)
127
+ format_puma_stats(stats)
128
+ else
129
+ get_basic_stats
130
+ end
131
+ rescue StandardError => e
132
+ { error: "Failed to fetch Puma stats: #{e.message}" }
133
+ end
134
+ end
135
+
136
+ def format_puma_stats(stats)
137
+ formatted = {
138
+ workers: stats[:workers] || 0,
139
+ phase: stats[:phase] || 0,
140
+ booted_workers: stats[:booted_workers] || 0,
141
+ old_workers: stats[:old_workers] || 0,
142
+ backlog: stats[:backlog] || 0,
143
+ running: stats[:running] || 0,
144
+ pool_capacity: stats[:pool_capacity] || 0,
145
+ max_threads: stats[:max_threads] || 0
146
+ }
147
+
148
+ # Include worker status if available
149
+ if stats[:worker_status].present?
150
+ formatted[:worker_status] = stats[:worker_status].map do |worker|
151
+ {
152
+ pid: worker[:pid],
153
+ index: worker[:index],
154
+ phase: worker[:phase],
155
+ booted: worker[:booted],
156
+ last_status: worker[:last_status]
157
+ }
158
+ end
159
+ end
160
+
161
+ formatted
162
+ end
163
+
164
+ def get_basic_stats
165
+ {
166
+ threads: Thread.list.count,
167
+ process_id: Process.pid,
168
+ rails_env: Rails.env,
169
+ note: "Running in single-threaded mode"
170
+ }
171
+ end
172
+ end
173
+ end
174
+
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ PumaMetricsEngine::Engine.routes.draw do
4
+ get "matrix", to: "matrix#show"
5
+ end
6
+
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaMetricsEngine
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace PumaMetricsEngine
6
+ end
7
+ end
8
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaMetricsEngine
4
+ VERSION = "1.0.0"
5
+ end
6
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma_metrics_engine/version"
4
+ require "puma_metrics_engine/engine"
5
+
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: puma_metrics_engine
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Neeto
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ description: A Rails engine that exposes Puma server metrics, queue time statistics,
70
+ and request rate information via a /matrix endpoint
71
+ email:
72
+ - engineering@bigbinary.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE.txt
78
+ - README.md
79
+ - app/controllers/puma_metrics_engine/matrix_controller.rb
80
+ - config/routes.rb
81
+ - lib/puma_metrics_engine.rb
82
+ - lib/puma_metrics_engine/engine.rb
83
+ - lib/puma_metrics_engine/version.rb
84
+ homepage: https://github.com/bigbinary/puma_metrics_engine
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/bigbinary/puma_metrics_engine
89
+ source_code_uri: https://github.com/bigbinary/puma_metrics_engine
90
+ changelog_uri: https://github.com/bigbinary/puma_metrics_engine/blob/main/CHANGELOG.md
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 3.0.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.5.22
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: Rails engine that provides a /matrix endpoint for Puma metrics and queue
110
+ time statistics
111
+ test_files: []