puma_metrics_engine 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 711453f75e30a751aaedaa85d58133fb0c0b5e5d4819339503f10af88bdbd964
4
- data.tar.gz: 291c8e013fd863f42b6adb83b95b6c38904de5402442902c3fe65c53f8fd231f
3
+ metadata.gz: 1ab335fa7843d45934fd97c5442b9bfd3a87006066b6f14ef99123a9316ab165
4
+ data.tar.gz: 7bd9134d5a436aecebd2a3f2a4073a5b51c46f3fb3d4a8b2391464e108326503
5
5
  SHA512:
6
- metadata.gz: aaa668f89f9931652c7682ba0c45390bc4a37dde9fe8e9d692549a8ee246cbfb92128cdbfcfffd5b22015b0f19e599b9dafe59abf89103ee9e07ab94bf353f26
7
- data.tar.gz: afe36aab4715936a485b54279c803ed4dc6d8fe8027bdbff9bb487e5bac547bae3db37c1784c37080ae93a19478bf92f4251f836b934e395f40773b124db4ff7
6
+ metadata.gz: c9f71532254eed460c8169a894c41e0ac03c7a089c37a133fc097d7632a0ab2bd2e282bbe84b7cd3ea667ea1adfa9f5a9cb3afc4aaaeb8d470107e5f403ab016
7
+ data.tar.gz: b704e4c376bdbdad8324b004c71ed20d176dbdda1febaaa63c6c411da33beee40cdb98460671dd8c558feb085a491602f2babc220d3ede42f2d77e90594b00d7
data/LICENSE.txt CHANGED
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
data/README.md CHANGED
@@ -1,22 +1,16 @@
1
1
  # PumaMetricsEngine
2
2
 
3
- A Rails engine gem that provides a `/matrix` endpoint for Puma metrics and queue time statistics.
3
+ A Rails engine that provides a `/matrix` endpoint for Puma metrics and queue time statistics.
4
4
 
5
5
  ## Installation
6
6
 
7
- Add this line to your application's Gemfile:
7
+ Add to your Gemfile:
8
8
 
9
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"
10
+ gem "puma_metrics_engine"
13
11
  ```
14
12
 
15
- And then execute:
16
-
17
- ```bash
18
- $ bundle install
19
- ```
13
+ Then run `bundle install`.
20
14
 
21
15
  ## Usage
22
16
 
@@ -26,19 +20,18 @@ Mount the engine in your routes:
26
20
  # config/routes.rb
27
21
  Rails.application.routes.draw do
28
22
  mount PumaMetricsEngine::Engine, at: "/"
29
- # ... other routes
30
23
  end
31
24
  ```
32
25
 
33
- The engine automatically provides a `/matrix` endpoint that returns JSON with:
26
+ Access metrics at `/matrix` endpoint.
34
27
 
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.)
28
+ For debugging production issues, use `/debug` endpoint to check:
29
+ - Middleware registration status
30
+ - Redis connectivity
31
+ - Header presence
32
+ - Current data in Redis
40
33
 
41
- ## Example Response
34
+ ## Response Format
42
35
 
43
36
  ```json
44
37
  {
@@ -57,65 +50,19 @@ The engine automatically provides a `/matrix` endpoint that returns JSON with:
57
50
  "20s": { "avg": 12.5, "sample_count": 100 },
58
51
  "30s": { "avg": 12.4, "sample_count": 150 }
59
52
  },
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
- }
53
+ "requests_per_minute": { "count": 120, "window_seconds": 60 },
54
+ "puma": { "workers": 2, "backlog": 0, "running": 10, ... }
73
55
  }
74
56
  ```
75
57
 
76
- ## Dependencies
58
+ ## Requirements
77
59
 
78
60
  - **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).
61
+ - **Redis** >= 5.0
62
+ - **X-Request-Start header**: Set by your load balancer (nginx, HAProxy, etc.) for queue time tracking
89
63
 
90
64
  Redis connection uses `ENV["REDIS_URL"]` or defaults to `redis://localhost:6379/1`.
91
65
 
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
66
  ## License
119
67
 
120
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
121
-
68
+ MIT
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaMetricsEngine
4
+ class DebugController < ActionController::Base
5
+ skip_before_action :verify_authenticity_token
6
+
7
+ def show
8
+ render json: {
9
+ timestamp: Time.current,
10
+ middleware: middleware_status,
11
+ redis: redis_status,
12
+ headers: sample_headers,
13
+ data: redis_data_summary,
14
+ environment: environment_info
15
+ }
16
+ end
17
+
18
+ private
19
+
20
+ def middleware_status
21
+ middleware_stack = Rails.application.middleware.to_a.map(&:klass).map(&:name)
22
+ is_registered = middleware_stack.include?("PumaMetricsEngine::QueueTimeTracker")
23
+
24
+ {
25
+ registered: is_registered,
26
+ middleware_stack: middleware_stack,
27
+ note: is_registered ? "Middleware is registered" : "Middleware NOT found in stack"
28
+ }
29
+ rescue StandardError => e
30
+ { error: e.message }
31
+ end
32
+
33
+ def redis_status
34
+ client = redis
35
+ client.ping
36
+ {
37
+ connected: true,
38
+ url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" },
39
+ keys: {
40
+ queue_times: client.exists?(MatrixController::QUEUE_TIMES_KEY),
41
+ request_timestamps: client.exists?(MatrixController::REQUESTS_KEY)
42
+ }
43
+ }
44
+ rescue StandardError => e
45
+ {
46
+ connected: false,
47
+ error: e.message,
48
+ url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
49
+ }
50
+ end
51
+
52
+ def redis_data_summary
53
+ client = redis
54
+ {
55
+ queue_times_count: client.zcard(MatrixController::QUEUE_TIMES_KEY),
56
+ request_timestamps_count: client.zcard(MatrixController::REQUESTS_KEY),
57
+ recent_queue_times: client.zrange(MatrixController::QUEUE_TIMES_KEY, -10, -1),
58
+ recent_timestamps: client.zrange(MatrixController::REQUESTS_KEY, -10, -1)
59
+ }
60
+ rescue StandardError => e
61
+ { error: e.message }
62
+ end
63
+
64
+ def sample_headers
65
+ # Show what headers we're looking for
66
+ {
67
+ x_request_start: request.headers["X-Request-Start"],
68
+ http_x_request_start: request.headers["HTTP_X_REQUEST_START"],
69
+ all_x_headers: request.headers.select { |k, _| k.to_s.upcase.include?("X-REQUEST") },
70
+ note: "Check if X-Request-Start header is being sent by your load balancer"
71
+ }
72
+ end
73
+
74
+ def environment_info
75
+ {
76
+ rails_env: Rails.env,
77
+ redis_url_set: ENV.key?("REDIS_URL"),
78
+ puma_defined: defined?(Puma)
79
+ }
80
+ end
81
+
82
+ def redis
83
+ @redis ||= Redis.new(url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" })
84
+ end
85
+ end
86
+ end
87
+
@@ -171,4 +171,3 @@ module PumaMetricsEngine
171
171
  end
172
172
  end
173
173
  end
174
-
data/config/routes.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  PumaMetricsEngine::Engine.routes.draw do
4
4
  get "matrix", to: "matrix#show"
5
+ get "debug", to: "debug#show"
5
6
  end
6
-
@@ -3,6 +3,7 @@
3
3
  module PumaMetricsEngine
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace PumaMetricsEngine
6
+
7
+ config.app_middleware.use PumaMetricsEngine::QueueTimeTracker
6
8
  end
7
9
  end
8
-
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaMetricsEngine
4
+ class QueueTimeTracker
5
+ TTL_SECONDS = 300 # 5 minutes
6
+ REQUESTS_KEY = "puma:request_timestamps"
7
+ QUEUE_TIMES_KEY = "puma:queue_times"
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ request_start_time = extract_request_start_time(env)
15
+ process_start_time = Time.now.to_f
16
+
17
+ # Log header presence for debugging
18
+ if defined?(Rails)
19
+ header_value = env["HTTP_X_REQUEST_START"] || env["X-Request-Start"]
20
+ Rails.logger.debug("[QueueTimeTracker] X-Request-Start header: #{header_value.inspect}") if header_value
21
+ Rails.logger.debug("[QueueTimeTracker] No X-Request-Start header found") unless header_value
22
+ end
23
+
24
+ status, headers, response = @app.call(env)
25
+
26
+ # Calculate queue time if we have request start time
27
+ begin
28
+ if request_start_time
29
+ queue_time_ms = ((process_start_time - request_start_time) * 1000).round(2)
30
+
31
+ # Only store if queue time is reasonable (positive and less than 1 hour)
32
+ # Negative values indicate clock skew, very large values are likely errors
33
+ if queue_time_ms >= 0 && queue_time_ms < 3_600_000
34
+ timestamp = process_start_time
35
+ # Store in Redis asynchronously to avoid blocking the request
36
+ store_metrics_async(timestamp, queue_time_ms)
37
+ Rails.logger.debug("[QueueTimeTracker] Stored queue time: #{queue_time_ms}ms") if defined?(Rails)
38
+ else
39
+ # Still track request timestamp even if queue time is invalid
40
+ Rails.logger.warn("[QueueTimeTracker] Invalid queue time: #{queue_time_ms}ms (rejected)") if defined?(Rails)
41
+ store_request_timestamp_async(process_start_time)
42
+ end
43
+ else
44
+ # Still track request timestamp even without queue time
45
+ store_request_timestamp_async(process_start_time)
46
+ end
47
+ rescue StandardError => e
48
+ # Don't let tracking errors break the request
49
+ Rails.logger.error("[QueueTimeTracker] Error: #{e.message}") if defined?(Rails)
50
+ Rails.logger.error("[QueueTimeTracker] Backtrace: #{e.backtrace.first(5).join("\n")}") if defined?(Rails)
51
+ end
52
+
53
+ [status, headers, response]
54
+ end
55
+
56
+ private
57
+
58
+ def extract_request_start_time(env)
59
+ # Try X-Request-Start header (common in nginx, HAProxy, etc.)
60
+ # Format: "t=1234567890.123" or "1234567890.123" or Unix timestamp in microseconds
61
+ header_value = env["HTTP_X_REQUEST_START"] || env["X-Request-Start"]
62
+
63
+ return nil unless header_value
64
+
65
+ # Handle different formats
66
+ # Format 1: "t=1234567890.123"
67
+ if header_value =~ /t=([\d.]+)/
68
+ timestamp_str = $1
69
+ # Format 2: "1234567890.123" (direct timestamp)
70
+ elsif header_value =~ /^[\d.]+$/
71
+ timestamp_str = header_value
72
+ else
73
+ return nil
74
+ end
75
+
76
+ timestamp = timestamp_str.to_f
77
+
78
+ # If timestamp is in microseconds (common with nginx), convert to seconds
79
+ # Timestamps > year 2100 are likely in microseconds
80
+ if timestamp > 4_102_444_800 # Year 2100 in seconds
81
+ timestamp = timestamp / 1_000_000.0
82
+ end
83
+
84
+ timestamp
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ def store_metrics_async(timestamp, queue_time_ms)
90
+ # Use a thread pool or async job, but for simplicity, we'll do it synchronously
91
+ # In production, you might want to use a background job
92
+ Thread.new do
93
+ begin
94
+ redis_client = redis
95
+ # Store queue time with timestamp as score
96
+ redis_client.zadd(QUEUE_TIMES_KEY, timestamp, queue_time_ms)
97
+ # Store request timestamp
98
+ redis_client.zadd(REQUESTS_KEY, timestamp, timestamp)
99
+ # Cleanup old data (older than TTL)
100
+ cleanup_old_data(redis_client)
101
+ rescue StandardError => e
102
+ Rails.logger.error("[QueueTimeTracker] Failed to store metrics: #{e.message}") if defined?(Rails)
103
+ Rails.logger.error("[QueueTimeTracker] Redis error backtrace: #{e.backtrace.first(3).join("\n")}") if defined?(Rails)
104
+ end
105
+ end
106
+ end
107
+
108
+ def store_request_timestamp_async(timestamp)
109
+ Thread.new do
110
+ begin
111
+ redis_client = redis
112
+ redis_client.zadd(REQUESTS_KEY, timestamp, timestamp)
113
+ cleanup_old_data(redis_client)
114
+ rescue StandardError => e
115
+ Rails.logger.error("[QueueTimeTracker] Failed to store request timestamp: #{e.message}") if defined?(Rails)
116
+ end
117
+ end
118
+ end
119
+
120
+ def cleanup_old_data(redis_client)
121
+ # Only cleanup occasionally to avoid overhead (10% chance)
122
+ return unless rand < 0.1
123
+
124
+ cutoff_time = Time.now.to_f - TTL_SECONDS
125
+ # Remove old entries from both sorted sets
126
+ redis_client.zremrangebyscore(QUEUE_TIMES_KEY, "-inf", cutoff_time)
127
+ redis_client.zremrangebyscore(REQUESTS_KEY, "-inf", cutoff_time)
128
+ end
129
+
130
+ def redis
131
+ @redis ||= Redis.new(url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" })
132
+ end
133
+ end
134
+ end
135
+
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PumaMetricsEngine
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
6
-
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "puma_metrics_engine/version"
4
+ require "puma_metrics_engine/queue_time_tracker"
4
5
  require "puma_metrics_engine/engine"
5
-
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: puma_metrics_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neeto
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-11-28 00:00:00.000000000 Z
11
+ date: 2025-11-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -66,6 +66,76 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '6.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '6.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-mocks
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.12'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.12'
97
+ - !ruby/object:Gem::Dependency
98
+ name: fakeredis
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.9'
111
+ - !ruby/object:Gem::Dependency
112
+ name: timecop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.9'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: webmock
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.18'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.18'
69
139
  description: A Rails engine that exposes Puma server metrics, queue time statistics,
70
140
  and request rate information via a /matrix endpoint
71
141
  email:
@@ -76,10 +146,12 @@ extra_rdoc_files: []
76
146
  files:
77
147
  - LICENSE.txt
78
148
  - README.md
149
+ - app/controllers/puma_metrics_engine/debug_controller.rb
79
150
  - app/controllers/puma_metrics_engine/matrix_controller.rb
80
151
  - config/routes.rb
81
152
  - lib/puma_metrics_engine.rb
82
153
  - lib/puma_metrics_engine/engine.rb
154
+ - lib/puma_metrics_engine/queue_time_tracker.rb
83
155
  - lib/puma_metrics_engine/version.rb
84
156
  homepage: https://github.com/bigbinary/puma_metrics_engine
85
157
  licenses:
@@ -103,7 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
175
  - !ruby/object:Gem::Version
104
176
  version: '0'
105
177
  requirements: []
106
- rubygems_version: 3.5.22
178
+ rubygems_version: 3.5.10
107
179
  signing_key:
108
180
  specification_version: 4
109
181
  summary: Rails engine that provides a /matrix endpoint for Puma metrics and queue