perfm 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: 6e1cff83c55f6245ed510d9f41fe5c169a662173e243ba0e8fb58c10d3274933
4
+ data.tar.gz: 3084b6db3933db8c23045b49374ac1474ad070b586a716452c095075be135a71
5
+ SHA512:
6
+ metadata.gz: a511d3fbefdb4554f5f87fa27f09e3f1c64d590dcac763e9cc0ca8b9e53c867ccf80337ff465edd0bcd2b6a63f88b27277d26121cc0dd87abec6411bab1e7e3e
7
+ data.tar.gz: cb3838701d25771a72c52866b6aaa7bddf215452f1980495c36854655da73ec254088a2e6d38d660e9b59242f5b6cd8f7db3001e74d7d89a2cf68eb76cee50f2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ Perfm
2
+ ==============
3
+ Perfm aims to be a performance monitoring tool for Ruby on Rails applications. Currently, it has support for GVL instrumentation and provides analytics to help optimize Puma thread concurrency settings based on the collected GVL data.
4
+
5
+ Requirements
6
+ -----------------
7
+ - Ruby: MRI 3.2+
8
+
9
+ This is because the GVL instrumentation API was [added](https://bugs.ruby-lang.org/issues/18339) in 3.2.0. Perfm makes use of the [gvl_timing](https://github.com/jhawthorn/gvl_timing) gem to capture per-thread timings for each GVL state.
10
+
11
+ Installation
12
+ -----------------
13
+ Add perfm to your Gemfile.
14
+ ```ruby
15
+ gem 'perfm'
16
+ ```
17
+
18
+ To set up GVL instrumentation run the following command:
19
+
20
+ ```bash
21
+ bin/rails generate perfm:install
22
+ ```
23
+
24
+ This will create a migration file with a table to store the GVL metrics. Run the migration and configure the gem as described below.
25
+
26
+ Configuration
27
+ -----------------
28
+ Configure Perfm in an initializer:
29
+
30
+ ```ruby
31
+ Perfm.configure do |config|
32
+ config.enabled = true
33
+ config.monitor_gvl = true
34
+ config.storage = :local
35
+ end
36
+
37
+ Perfm.setup!
38
+
39
+ ```
40
+
41
+ When `monitor_gvl` is enabled, perfm adds a Rack middleware to log GVL metrics for each request. The metrics are stored in the database.
42
+
43
+ We just need around `20000` datapoints(i.e requests) to get an idea of the app's workload. So the `monitor_gvl` config can be disabled after that. You can control the value via an ENV variable if you prefer.
44
+
45
+ ## Analysis
46
+
47
+ ```ruby
48
+ gvl_metrics_analyzer = Perfm::GvlMetricsAnalyzer.new(
49
+ start_time: 5.days.ago,
50
+ end_time: Time.current
51
+ )
52
+
53
+ gvl_metrics_analyzer.analyze
54
+
55
+ # Write to file
56
+ File.write(
57
+ "tmp/perfm/gvl_analysis_#{Time.current.strftime('%Y%m%d_%H%M%S')}.json",
58
+ JSON.pretty_generate(gvl_metrics_analyzer.analyze)
59
+ )
60
+ ```
61
+
62
+ This will print the following metrics:
63
+
64
+ - `total_io_percentage`: Percentage of time spent doing I/O operations
65
+ - `total_io_and_stall_percentage`: Percentage of time spent in I/O operations(idle time) and GVL stalls combined
66
+ - `average_response_time_ms`: Average response time in milliseconds per request
67
+ - `average_stall_ms`: Average GVL stall time in milliseconds per request
68
+ - `request_count`: Total number of requests analyzed
69
+ - `time_range`: Details about the analysis period including:
70
+ - `start_time`
71
+ - `end_time`
72
+ - `duration_seconds`
73
+
74
+ After analysis, you can drop the table to save space. The following command generates a migration to drop the table.
75
+
76
+ ```bash
77
+ bin/rails generate perfm:uninstall
78
+ ```
79
+
80
+ ## Beta Features
81
+
82
+ The following features are currently in beta and may have limited functionality or be subject to change.
83
+
84
+ ### Perfm queue latency monitor
85
+
86
+
87
+ The queue latency monitor tracks Sidekiq queue times and raises alerts when the queue latency exceed their thresholds. To enable this feature, set `config.monitor_sidekiq_queues = true` in your Perfm configuration.
88
+
89
+ ruby
90
+
91
+ ```ruby
92
+ Perfm.configure do |config|
93
+ # Other configurations...
94
+ config.monitor_sidekiq_queues = true
95
+ end
96
+ ```
97
+
98
+ When enabled, Perfm will monitor your Sidekiq queues and raise a `Perfm::Errors::LatencyExceededError` when the queue latency exceeds the threshold.
99
+
100
+ #### Queue Naming Convention
101
+
102
+ Perfm expects queues that need latency monitoring to follow this naming pattern:
103
+
104
+ - `within_X_seconds` (e.g., within_5_seconds)
105
+ - `within_X_minutes` (e.g., within_2_minutes)
106
+ - `within_X_hours` (e.g., within_1_hours)
107
+
108
+ ### Heap analyzer
109
+
110
+ ### Generate and Store Heap Dumps via ActiveStorage
111
+
112
+ Perfm has a heap dump generator which can be used to generate heap dumps from running Puma worker processes and storing them via ActiveStorage. This can be useful for debugging memory leaks. We can generate three dumps separate by a time period of lets say 15 minutes and analyze it via heapy or sheap.
113
+
114
+ _Note: The process of heap dump generation can increase the memory usage._
115
+
116
+ #### Puma configuration changes:
117
+
118
+ Add the following to your `config/puma.rb`:
119
+
120
+ ```ruby
121
+ on_worker_boot do
122
+ Perfm::PidStore.instance.add_worker_pid(Process.pid)
123
+ end
124
+
125
+ on_worker_shutdown do
126
+ Perfm::PidStore.instance.clear
127
+ end
128
+ ```
129
+
130
+ We need to keep track of pid of each worker process so that we inject code to generate heap dump in each worker process using [rbtrace](https://github.com/tmm1/rbtrace)
131
+
132
+ #### Route setup
133
+
134
+ ```ruby
135
+ # config/routes.rb
136
+ Rails.application.routes.draw do
137
+ namespace :perfm do
138
+ namespace :admin do
139
+ resources :heap_dumps, only: :create
140
+ end
141
+ end
142
+ end
143
+ ```
144
+
145
+ #### Controller to generate heap dumps
146
+
147
+ As we need to invoke rbtrace from the same process, we'll use a controller itself to invoke the `HeapDumper`.
148
+ ```ruby
149
+ class Perfm::Admin::HeapDumpsController < ActionController::Base
150
+ skip_forgery_protection
151
+ before_action :authenticate_admin
152
+
153
+ def create
154
+ blob = Perfm::HeapDumper.generate
155
+
156
+ render json: {
157
+ status: "success",
158
+ message: "Heap dump generated successfully",
159
+ blob_id: blob.id,
160
+ filename: blob.filename.to_s
161
+ }
162
+ rescue Perfm::HeapDumper::Error => e
163
+ render json: { status: "error", message: e.message }, status: :unprocessable_entity
164
+ end
165
+
166
+ private
167
+
168
+ def authenticate_admin
169
+ return if Rails.env.development?
170
+
171
+ unless valid_token?(request.headers["X-Perfm-Token"])
172
+ head :unauthorized
173
+ end
174
+ end
175
+
176
+ def valid_token?(token)
177
+ return false if token.blank? || Perfm.configuration.admin_token.blank?
178
+
179
+ ActiveSupport::SecurityUtils.secure_compare(
180
+ token,
181
+ Perfm.configuration.admin_token
182
+ )
183
+ end
184
+ end
185
+ ```
186
+
187
+ #### Usage
188
+
189
+ ```bash
190
+ curl -X POST https://your-app.com/perfm/admin/heap_dumps -H "X-Perfm-Token: your-secure-token"
191
+ ```
192
+
193
+ The generated heap dump will be stored via ActiveStorage and the response includes the blob ID and filename for later reference.
194
+
195
+ #### Configuration
196
+
197
+ Configure the admin token in your Perfm initializer:
198
+
199
+ ```ruby
200
+ # config/initializers/perfm.rb
201
+ Perfm.configure do |config|
202
+ config.admin_token = ENV["PERFM_ADMIN_TOKEN"]
203
+ end
204
+ ```
205
+
206
+ The generated heap dumps can be downloaded and analyzed using [heapy](https://github.com/zombocom/heapy)
207
+
208
+ We're planning to add a heap analyzer within perfm itself to make the process seamless.
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+ require 'bundler/gem_tasks'
9
+
10
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
11
+ load 'rails/tasks/engine.rake'
12
+ load 'rails/tasks/statistics.rake'
13
+
14
+ require 'bundler/gem_tasks'
15
+
16
+ task default: :test
@@ -0,0 +1,5 @@
1
+ module Perfm
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ module Perfm
2
+ class GvlMetric < ApplicationRecord
3
+ self.table_name = "perfm_gvl_metrics"
4
+
5
+ scope :within_time_range, ->(start_time, end_time) {
6
+ where(created_at: start_time..end_time)
7
+ }
8
+
9
+ def action_path
10
+ return "rack middleware" if controller.blank?
11
+ "#{controller}##{action}"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module Perfm
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_migration_file
11
+ migration_template(
12
+ 'create_perfm_gvl_metrics.rb.erb',
13
+ File.join(db_migrate_path, "create_perfm_gvl_metrics.rb")
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ def migration_version
20
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ class CreatePerfmGvlMetrics < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :perfm_gvl_metrics do |t|
4
+ t.float :gc_ms
5
+ t.float :run_ms
6
+ t.float :idle_ms
7
+ t.float :stall_ms
8
+ t.float :io_percent
9
+ t.string :method
10
+ t.string :controller
11
+ t.string :action
12
+ t.integer :puma_max_threads
13
+
14
+ t.index [:controller, :action]
15
+
16
+ t.timestamps
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ class DropPerfmGvlMetrics < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ drop_table :perfm_gvl_metrics
4
+ end
5
+
6
+ def down
7
+ create_table :perfm_gvl_metrics do |t|
8
+ t.float :gc_ms
9
+ t.float :run_ms
10
+ t.float :idle_ms
11
+ t.float :stall_ms
12
+ t.float :io_percent
13
+ t.string :method
14
+ t.string :controller
15
+ t.string :action
16
+
17
+ t.index [:controller, :action]
18
+
19
+ t.timestamps
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module Perfm
6
+ class UninstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_migration_file
11
+ migration_template(
12
+ 'drop_perfm_gvl_metrics.rb.erb',
13
+ File.join(db_migrate_path, "drop_perfm_gvl_metrics.rb")
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ def migration_version
20
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ module Perfm
2
+ class Agent
3
+ attr_reader :config, :queue
4
+
5
+ def initialize(config, storage)
6
+ @config = config
7
+ @queue = Queue.new(storage)
8
+ end
9
+
10
+ def push_metrics(data)
11
+ queue.push_metrics(data)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,78 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Perfm
5
+ class Client
6
+ HTTPS = "https".freeze
7
+ DEFAULT_TIMEOUT = 10
8
+
9
+ def initialize(config)
10
+ @api_url = config.api_url
11
+ @api_key = config.api_key
12
+ @timeout = DEFAULT_TIMEOUT
13
+ @mutex = Mutex.new
14
+ @connections = []
15
+ @headers = {
16
+ "Content-Type" => "application/json",
17
+ "Authorization" => "Bearer #{@api_key}",
18
+ "X-Perfm-Version" => Perfm::VERSION,
19
+ }
20
+ end
21
+
22
+ def post(path, data)
23
+ uri = URI(@api_url + path)
24
+ request = Net::HTTP::Post.new(uri.path, @headers)
25
+ request.body = data.to_json
26
+ transmit(request)
27
+ end
28
+
29
+ private
30
+
31
+ def transmit(request)
32
+ http = take_connection
33
+ response = http.request(request)
34
+ handle_response(response)
35
+ rescue => e
36
+ puts "HTTP Error: #{e.message}"
37
+ ensure
38
+ release_connection(http) if http
39
+ end
40
+
41
+ def take_connection
42
+ @mutex.synchronize do
43
+ if conn = @connections.pop
44
+ conn.start unless conn.started?
45
+ conn
46
+ else
47
+ create_connection
48
+ end
49
+ end
50
+ end
51
+
52
+ def release_connection(conn)
53
+ @mutex.synchronize { @connections << conn }
54
+ end
55
+
56
+ def create_connection
57
+ uri = URI(@api_url)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = uri.scheme == HTTPS
60
+ http.open_timeout = @timeout
61
+ http.read_timeout = @timeout
62
+ http
63
+ end
64
+
65
+ def handle_response(response)
66
+ case response
67
+ when Net::HTTPSuccess
68
+ true
69
+ when Net::HTTPUnauthorized
70
+ puts "Invalid API key"
71
+ false
72
+ else
73
+ puts "Unexpected response: #{response.code}"
74
+ false
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ module Perfm
2
+ class Configuration < Anyway::Config
3
+ config_name :perfm
4
+
5
+ attr_config(
6
+ enabled: true,
7
+ monitor_sidekiq: false,
8
+ monitor_gvl: false,
9
+ monitor_sidekiq_queues: false,
10
+ storage: :api,
11
+ api_url: nil,
12
+ api_key: nil,
13
+ )
14
+
15
+ def monitor_sidekiq?
16
+ enabled? && monitor_sidekiq
17
+ end
18
+
19
+ def monitor_gvl?
20
+ enabled? && monitor_gvl
21
+ end
22
+
23
+ def enabled?
24
+ enabled
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ module Perfm
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Perfm
4
+
5
+ initializer "perfm.gvl_instrumentation" do |app|
6
+ if Perfm.configuration.monitor_gvl?
7
+ app.config.middleware.insert(0, Perfm::Middleware::GvlInstrumentation)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ module Perfm
2
+ module Errors
3
+ class LatencyExceededError < StandardError
4
+ attr_reader :queue, :latency, :expected_latency
5
+
6
+ def initialize(queue:, latency:, expected_latency:)
7
+ @queue = queue
8
+ @latency = latency
9
+ @expected_latency = expected_latency
10
+
11
+ message = "Queue latency exceeded SLA: #{latency.round(2)}s " \
12
+ "(limit: #{expected_latency}s) for queue #{queue}"
13
+ super(message)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,169 @@
1
+ module Perfm
2
+ class GvlMetricsAnalyzer
3
+ class Error < StandardError; end
4
+
5
+ def initialize(start_time:, end_time:, puma_max_threads: nil)
6
+ @start_time = start_time
7
+ @end_time = end_time
8
+ @puma_max_threads = puma_max_threads
9
+ end
10
+
11
+ def analyze
12
+ return empty_results if metrics.empty?
13
+
14
+ {
15
+ summary: calculate_summary(metrics),
16
+ percentiles: calculate_percentiles(metrics),
17
+ action_breakdowns: calculate_action_breakdowns(metrics)
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def metrics
24
+ @_metrics ||= begin
25
+ base_scope = GvlMetric.within_time_range(@start_time, @end_time)
26
+ return base_scope unless @puma_max_threads
27
+
28
+ base_scope.where(puma_max_threads: @puma_max_threads)
29
+ end
30
+ end
31
+
32
+
33
+ def empty_results
34
+ {
35
+ total_io_percentage: 0.0,
36
+ total_stall_percentage: 0.0,
37
+ average_response_time_ms: 0.0,
38
+ average_stall_ms: 0.0,
39
+ average_gc_ms: 0.0,
40
+ request_count: 0,
41
+ time_range: {
42
+ start_time: @start_time,
43
+ end_time: @end_time,
44
+ duration_seconds: (@end_time - @start_time).to_i
45
+ }
46
+ }
47
+ end
48
+
49
+ def calculate_io_percentage(run_ms, idle_ms)
50
+ total_time = run_ms + idle_ms
51
+ return 0.0 if total_time == 0
52
+ ((idle_ms / total_time) * 100.0).round(2)
53
+ end
54
+
55
+ def calculate_avg_response_time(run_ms, idle_ms, stall_ms, count)
56
+ return 0.0 if count == 0
57
+ ((stall_ms + run_ms + idle_ms) / count).round(2)
58
+ end
59
+
60
+ def calculate_summary(metrics)
61
+ total_run_ms = metrics.sum(:run_ms)
62
+ total_idle_ms = metrics.sum(:idle_ms)
63
+ total_stall_ms = metrics.sum(:stall_ms)
64
+ total_gc_ms = metrics.sum(:gc_ms)
65
+ count = metrics.count
66
+
67
+ {
68
+ total_io_percentage: calculate_io_percentage(total_run_ms, total_idle_ms),
69
+ average_response_time_ms: calculate_avg_response_time(total_run_ms, total_idle_ms, total_stall_ms, count),
70
+ average_stall_ms: (total_stall_ms / count).round(2),
71
+ average_gc_ms: (total_gc_ms / count).round(2),
72
+ request_count: count,
73
+ time_range: {
74
+ start_time: @start_time,
75
+ end_time: @end_time,
76
+ duration_seconds: (@end_time - @start_time).to_i
77
+ }
78
+ }
79
+ end
80
+
81
+ def calculate_percentiles(metrics)
82
+ total_count = metrics.size
83
+
84
+ sorted_metrics = if metrics.is_a?(ActiveRecord::Relation)
85
+ metrics.order(Arel.sql("run_ms + idle_ms + stall_ms")).to_a
86
+ else
87
+ metrics.sort_by { |m| m.run_ms + m.idle_ms + m.stall_ms }
88
+ end
89
+
90
+ p10 = (total_count * 0.1).floor
91
+ p50 = (total_count * 0.5).floor
92
+ p60 = (total_count * 0.6).floor
93
+ p90 = (total_count * 0.9).floor
94
+ p99 = (total_count * 0.99).floor
95
+ p999 = (total_count * 0.999).floor
96
+
97
+ percentile_ranges = {
98
+ "p0-10": 0...p10,
99
+ "p50-60": p50...p60,
100
+ "p90-99": p90...p99,
101
+ "p99-99.9": p99...p999,
102
+ "p99.9-100": p999...total_count
103
+ }
104
+
105
+ result = {
106
+ overall: "#{total_count} requests"
107
+ }
108
+
109
+ result.merge!(
110
+ percentile_ranges.transform_values do |range|
111
+ range_metrics = sorted_metrics[range]
112
+ calculate_group_stats_in_memory(range_metrics || [])
113
+ end
114
+ )
115
+
116
+ result
117
+ end
118
+
119
+ def calculate_action_breakdowns(metrics)
120
+ metrics_by_action = metrics.group_by do |metric|
121
+ [metric.controller, metric.action]
122
+ end
123
+
124
+ metrics_by_action.transform_keys do |(controller, action)|
125
+ "#{controller}##{action}"
126
+ end.transform_values do |action_metrics|
127
+ calculate_percentiles(action_metrics)
128
+ end
129
+ end
130
+
131
+ def calculate_group_stats_in_memory(metrics)
132
+ return empty_group_stats if metrics.empty?
133
+
134
+ avg_run_ms = (metrics.sum(&:run_ms) / metrics.size).round(1)
135
+ avg_idle_ms = (metrics.sum(&:idle_ms) / metrics.size).round(1)
136
+ avg_stall_ms = (metrics.sum(&:stall_ms) / metrics.size).round(1)
137
+ avg_gc_ms = (metrics.sum(&:gc_ms) / metrics.size).round(1)
138
+ total_ms = (avg_run_ms + avg_idle_ms + avg_stall_ms).round(1)
139
+
140
+ io_percentage = if (avg_run_ms + avg_idle_ms) > 0
141
+ ((avg_idle_ms / (avg_run_ms + avg_idle_ms)) * 100).round(1)
142
+ else
143
+ 0.0
144
+ end
145
+
146
+ {
147
+ cpu: avg_run_ms,
148
+ io: avg_idle_ms,
149
+ stall: avg_stall_ms,
150
+ gc: avg_gc_ms,
151
+ total: total_ms,
152
+ "io%": "#{io_percentage}%",
153
+ count: metrics.size
154
+ }
155
+ end
156
+
157
+ def empty_group_stats
158
+ {
159
+ cpu: 0.0,
160
+ io: 0.0,
161
+ stall: 0.0,
162
+ gc: 0.0,
163
+ total: 0.0,
164
+ "io%": "0.0%",
165
+ count: 0
166
+ }
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,80 @@
1
+ require "rbtrace"
2
+
3
+ module Perfm
4
+ class HeapDumper
5
+ class Error < StandardError; end
6
+
7
+ class << self
8
+ def generate
9
+ new.generate
10
+ end
11
+ end
12
+
13
+ def generate
14
+ worker_pid = find_worker_pid
15
+ generate_dump(worker_pid)
16
+ end
17
+
18
+ private
19
+
20
+ def find_worker_pid
21
+ pid = PidStore.instance.get_first_worker_pid
22
+ return pid if pid
23
+
24
+ raise Error, "No Puma worker processes available"
25
+ end
26
+
27
+ def generate_dump(worker_pid)
28
+ filename = "heap_dump_pid_#{worker_pid}_time_#{Time.current.to_i}.json"
29
+ temp_path = Rails.root.join("tmp", filename)
30
+ FileUtils.mkdir_p(File.dirname(temp_path))
31
+
32
+ generate_heap_dump(worker_pid, temp_path)
33
+ store_dump(temp_path, filename)
34
+ ensure
35
+ FileUtils.rm_f(temp_path) if defined?(temp_path) && temp_path
36
+ end
37
+
38
+ def generate_heap_dump(worker_pid, output_path)
39
+ cmd = build_rbtrace_command(worker_pid, output_path)
40
+ execute_rbtrace(cmd)
41
+ end
42
+
43
+ def build_rbtrace_command(worker_pid, output_path)
44
+ <<~COMMAND
45
+ rbtrace -p #{worker_pid} -e '
46
+ Thread.new {
47
+ require "objspace"
48
+ ObjectSpace.trace_object_allocations_start
49
+ GC.start
50
+ File.open("#{output_path}", "w") { |f|
51
+ ObjectSpace.dump_all(output: f)
52
+ }
53
+ }.join
54
+ '
55
+ COMMAND
56
+ end
57
+
58
+ def execute_rbtrace(cmd)
59
+ output = `#{cmd} 2>&1`
60
+ return if $?.success?
61
+
62
+ raise Error, "rbtrace failed: #{output}"
63
+ end
64
+
65
+ def store_dump(temp_path, filename)
66
+ File.open(temp_path) do |file|
67
+ blob = ActiveStorage::Blob.create_and_upload!(
68
+ io: file,
69
+ filename: filename,
70
+ content_type: "application/json",
71
+ identify: false
72
+ )
73
+
74
+ puts "Heap dump stored with key: #{blob.key}"
75
+
76
+ blob
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ module Perfm
2
+ module Metrics
3
+ class Sidekiq
4
+ class << self
5
+ def setup
6
+ start_monitoring
7
+ end
8
+
9
+ def start_monitoring
10
+ Thread.new do
11
+ while true
12
+ collect_metrics
13
+ sleep 5
14
+ end
15
+ end
16
+ end
17
+
18
+ def collect_metrics
19
+ return unless defined?(::Sidekiq)
20
+
21
+ ::Sidekiq::Queue.all.each do |queue|
22
+ record_queue_metrics(queue)
23
+
24
+ if monitor_sidekiq_queues? && valid_queue_name?(queue.name)
25
+ check_latency_threshold(queue)
26
+ end
27
+ end
28
+ end
29
+
30
+ def record_queue_metrics(queue)
31
+ # TODO: Replace this with sending metrics to NewRelic as perfm-ingester work is not complete
32
+ Perfm.agent.push_metrics({
33
+ type: "sidekiq_queue",
34
+ queue: queue.name,
35
+ latency: queue.latency,
36
+ size: queue.size,
37
+ })
38
+ end
39
+
40
+ def check_latency_threshold(queue)
41
+ return unless expected_latency = QueueLatency.parse_latency(queue.name)
42
+
43
+ if queue.latency > expected_latency
44
+ handle_exceeded_latency(queue, expected_latency)
45
+ end
46
+ end
47
+
48
+ def handle_exceeded_latency(queue, expected_latency)
49
+ Thread.new do
50
+ # TODO: Prevent flooding the error monitoring tool if there are a lot of latency exceeded errors
51
+ raise Errors::LatencyExceededError.new(
52
+ queue: queue.name,
53
+ latency: queue.latency,
54
+ expected_latency: expected_latency
55
+ )
56
+ end
57
+ end
58
+
59
+ def monitor_sidekiq_queues?
60
+ @monitor_sidekiq_queues ||= Perfm.configuration.monitor_sidekiq_queues?
61
+ end
62
+
63
+ def valid_queue_name?(queue_name)
64
+ QueueLatency.valid_queue_name?(queue_name)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gvl_timing"
4
+
5
+ module Perfm
6
+ module Middleware
7
+ class GvlInstrumentation
8
+ def initialize(app)
9
+ @app = app
10
+ @puma_max_threads = nil
11
+ end
12
+
13
+ def call(env)
14
+ response = nil
15
+ before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ before_gc_time = GC.total_time
17
+
18
+ timer = GVLTiming.measure do
19
+ response = @app.call(env)
20
+ end
21
+
22
+ total_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - before_time
23
+ gc_time = GC.total_time - before_gc_time
24
+
25
+ begin
26
+ @puma_max_threads ||= get_puma_max_threads if defined?(::Puma)
27
+
28
+ data = {
29
+ gc_ms: (gc_time / 1_000_000.0).round(2),
30
+ run_ms: (timer.cpu_duration * 1000.0).round(2),
31
+ idle_ms: (timer.idle_duration * 1000.0).round(2),
32
+ stall_ms: (timer.stalled_duration * 1000.0).round(2),
33
+ io_percent: (timer.idle_duration / total_time * 100.0).round(1),
34
+ method: env["REQUEST_METHOD"],
35
+ controller: nil,
36
+ action: nil,
37
+ puma_max_threads: @puma_max_threads
38
+ }
39
+
40
+ if (controller = env["action_controller.instance"])
41
+ data[:controller] = controller.controller_path
42
+ data[:action] = controller.action_name
43
+ end
44
+
45
+ Perfm.agent.push_metrics(data)
46
+ rescue => e
47
+ puts "GVL metrics collection failed: #{e.message}"
48
+ end
49
+
50
+ response
51
+ end
52
+
53
+ private
54
+
55
+ def get_puma_max_threads
56
+ JSON.parse(Puma.stats)["max_threads"]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,26 @@
1
+ require "singleton"
2
+
3
+ # TODO: Or read from Puma pidfile?
4
+ # TODO: Handle single mode
5
+
6
+ module Perfm
7
+ class PidStore
8
+ include Singleton
9
+
10
+ def initialize
11
+ @worker_pids = Concurrent::Array.new
12
+ end
13
+
14
+ def add_worker_pid(pid)
15
+ @worker_pids << pid
16
+ end
17
+
18
+ def get_first_worker_pid
19
+ @worker_pids.sample
20
+ end
21
+
22
+ def clear
23
+ @worker_pids.clear
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,56 @@
1
+ module Perfm
2
+ class Queue
3
+ FLUSH_INTERVAL = 60
4
+ FLUSH_THRESHOLD = 100
5
+
6
+ def initialize(storage)
7
+ @metrics = []
8
+ @storage = storage
9
+ @mutex = Mutex.new
10
+ Kernel.at_exit { flush }
11
+ start_thread
12
+ end
13
+
14
+ def push_metrics(data)
15
+ mutex.synchronize do
16
+ @metrics.push(data)
17
+ wakeup_thread if @metrics.size >= FLUSH_THRESHOLD || !thread.alive?
18
+ end
19
+ end
20
+
21
+ def collect_pending_metrics
22
+ result = nil
23
+ mutex.synchronize do
24
+ if @metrics.size > 0
25
+ result = @metrics
26
+ @metrics = []
27
+ end
28
+ end
29
+ result
30
+ end
31
+
32
+ def flush
33
+ if data = collect_pending_metrics
34
+ @storage.store(data)
35
+ end
36
+ end
37
+
38
+ def flush_indefinitely
39
+ while true
40
+ sleep(FLUSH_INTERVAL) and flush
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :mutex, :thread
47
+
48
+ def start_thread
49
+ @thread = Thread.new { flush_indefinitely }
50
+ end
51
+
52
+ def wakeup_thread
53
+ (thread && thread.alive?) ? thread.wakeup : start_thread
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ module Perfm
2
+ module QueueLatency
3
+ QUEUE_PATTERN = /\Awithin_(\d+)_(second|seconds|minute|minutes|hour|hours)\z/i
4
+
5
+ def self.parse_latency(queue_name)
6
+ return unless valid_queue_name?(queue_name)
7
+
8
+ match_data = queue_name.match(QUEUE_PATTERN)
9
+ value = match_data[1].to_i
10
+ unit = match_data[2].downcase.sub(/s\z/, '')
11
+
12
+ case unit
13
+ when 'second' then value
14
+ when 'minute' then value * 60
15
+ when 'hour' then value * 3600
16
+ end
17
+ end
18
+
19
+ def self.valid_queue_name?(queue_name)
20
+ queue_name&.match?(QUEUE_PATTERN)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ module Perfm
2
+ module Storage
3
+ class Api < Base
4
+ def initialize(client)
5
+ @client = client
6
+ end
7
+
8
+ def store(metrics)
9
+ @client.post("/metrics", metrics: metrics)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Perfm
2
+ module Storage
3
+ class Base
4
+ def store(metrics)
5
+ raise NotImplementedError
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module Perfm
2
+ module Storage
3
+ class Local < Base
4
+ def store(metrics)
5
+ return if metrics.empty?
6
+
7
+ Perfm::GvlMetric.insert_all(metrics)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module Perfm
2
+ VERSION = "1.0.0"
3
+ end
data/lib/perfm.rb ADDED
@@ -0,0 +1,67 @@
1
+ require "rails/engine"
2
+ require "active_support/all"
3
+ require "anyway_config"
4
+
5
+ require "perfm/version"
6
+ require "perfm/engine"
7
+
8
+ module Perfm
9
+ autoload :Configuration, "perfm/configuration"
10
+ autoload :Client, "perfm/client"
11
+ autoload :Queue, "perfm/queue"
12
+ autoload :Agent, "perfm/agent"
13
+ autoload :GvlMetricsAnalyzer, "perfm/gvl_metrics_analyzer"
14
+
15
+ module Storage
16
+ autoload :Base, "perfm/storage/base"
17
+ autoload :Api, "perfm/storage/api"
18
+ autoload :Local, "perfm/storage/local"
19
+ end
20
+
21
+ module Middleware
22
+ autoload :GvlInstrumentation, "perfm/middleware/gvl_instrumentation"
23
+ end
24
+
25
+ class << self
26
+ attr_writer :configuration
27
+
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield(configuration)
34
+ end
35
+
36
+ def agent
37
+ @agent
38
+ end
39
+
40
+ def setup!
41
+ return unless configuration.enabled?
42
+
43
+ setup_sidekiq if configuration.monitor_sidekiq?
44
+
45
+ storage = if configuration.storage == :local
46
+ Storage::Local.new
47
+ else
48
+ Storage::Api.new(Client.new(configuration))
49
+ end
50
+
51
+ @agent = Agent.new(configuration, storage)
52
+ end
53
+
54
+ def generate_heap_dump
55
+ HeapDumper.generate
56
+ end
57
+
58
+ private
59
+
60
+ def setup_sidekiq
61
+ return unless defined?(::Sidekiq)
62
+ Metrics::Sidekiq.setup
63
+ end
64
+ end
65
+ end
66
+
67
+ require "perfm/engine" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: perfm
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vishnu M
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-05-05 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.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: anyway_config
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '3'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '1.3'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '3'
47
+ - !ruby/object:Gem::Dependency
48
+ name: sidekiq
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '6.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rbtrace
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.5'
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.5'
75
+ - !ruby/object:Gem::Dependency
76
+ name: gvl_timing
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rake
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13.0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.21'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.21'
117
+ description: Monitor Rails application performance metrics
118
+ email:
119
+ - vishnu.m@bigbinary.com
120
+ executables: []
121
+ extensions: []
122
+ extra_rdoc_files: []
123
+ files:
124
+ - LICENSE
125
+ - README.md
126
+ - Rakefile
127
+ - app/models/perfm/application_record.rb
128
+ - app/models/perfm/gvl_metric.rb
129
+ - lib/generators/perfm/install/install_generator.rb
130
+ - lib/generators/perfm/install/templates/create_perfm_gvl_metrics.rb.erb
131
+ - lib/generators/perfm/uninstall/templates/drop_perfm_gvl_metrics.rb.erb
132
+ - lib/generators/perfm/uninstall/uninstall_generator.rb
133
+ - lib/perfm.rb
134
+ - lib/perfm/agent.rb
135
+ - lib/perfm/client.rb
136
+ - lib/perfm/configuration.rb
137
+ - lib/perfm/engine.rb
138
+ - lib/perfm/errors/latency_exceeded_error.rb
139
+ - lib/perfm/gvl_metrics_analyzer.rb
140
+ - lib/perfm/heap_dumper.rb
141
+ - lib/perfm/metrics/sidekiq.rb
142
+ - lib/perfm/middleware/gvl_instrumentation.rb
143
+ - lib/perfm/pid_store.rb
144
+ - lib/perfm/queue.rb
145
+ - lib/perfm/queue_latency.rb
146
+ - lib/perfm/storage/api.rb
147
+ - lib/perfm/storage/base.rb
148
+ - lib/perfm/storage/local.rb
149
+ - lib/perfm/version.rb
150
+ homepage: https://github.com/vishnu-m/perfm
151
+ licenses:
152
+ - MIT
153
+ metadata:
154
+ homepage_uri: https://github.com/vishnu-m/perfm
155
+ source_code_uri: https://github.com/vishnu-m/perfm
156
+ changelog_uri: https://github.com/vishnu-m/perfm/blob/master/CHANGELOG.md
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 3.2.0
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 3.4.1
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: Everything Rails performance monitoring
176
+ test_files: []