rails-profiler 0.24.0 → 0.26.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler.css +24 -0
  3. data/app/assets/builds/profiler.js +994 -26
  4. data/app/controllers/profiler/api/console_controller.rb +46 -0
  5. data/app/controllers/profiler/api/profiles_controller.rb +1 -1
  6. data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
  7. data/app/controllers/profiler/api/tests_controller.rb +46 -0
  8. data/app/controllers/profiler/test_runner_controller.rb +11 -0
  9. data/app/views/profiler/test_runner/index.html.erb +1 -0
  10. data/config/routes.rb +13 -0
  11. data/lib/profiler/collectors/console_collector.rb +57 -0
  12. data/lib/profiler/collectors/database_collector.rb +1 -1
  13. data/lib/profiler/collectors/test_collector.rb +75 -0
  14. data/lib/profiler/configuration.rb +14 -1
  15. data/lib/profiler/console_profiler.rb +102 -0
  16. data/lib/profiler/instrumentation/irb_instrumentation.rb +21 -0
  17. data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
  18. data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
  19. data/lib/profiler/mcp/server.rb +77 -6
  20. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
  21. data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
  22. data/lib/profiler/mcp/tools/run_tests.rb +112 -0
  23. data/lib/profiler/railtie.rb +21 -1
  24. data/lib/profiler/test_helpers/minitest_support.rb +39 -0
  25. data/lib/profiler/test_helpers/reporter.rb +121 -0
  26. data/lib/profiler/test_helpers/rspec_support.rb +33 -0
  27. data/lib/profiler/test_profiler.rb +140 -0
  28. data/lib/profiler/test_runner/discovery.rb +57 -0
  29. data/lib/profiler/test_runner/run_store.rb +120 -0
  30. data/lib/profiler/test_runner/runner.rb +106 -0
  31. data/lib/profiler/version.rb +1 -1
  32. metadata +23 -2
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_profiler"
4
+ require_relative "reporter"
5
+
6
+ module Profiler
7
+ module TestHelpers
8
+ # RSpec integration for the profiler.
9
+ #
10
+ # Usage in spec_helper.rb:
11
+ #
12
+ # require 'profiler/test_helpers/rspec_support'
13
+ # RSpec.configure do |config|
14
+ # Profiler::TestHelpers::RSpecSupport.install(config)
15
+ # end
16
+ module RSpecSupport
17
+ def self.install(config)
18
+ config.around(:each) do |example|
19
+ Profiler::TestProfiler.profile(
20
+ test_name: example.full_description,
21
+ test_file: example.file_path,
22
+ test_line: example.line_number,
23
+ framework: :rspec
24
+ ) { example.run }
25
+ end
26
+
27
+ config.after(:suite) do
28
+ Profiler::TestHelpers::Reporter.print
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/profile"
4
+ require_relative "current_context"
5
+ require_relative "collectors/test_collector"
6
+ require_relative "collectors/database_collector"
7
+ require_relative "collectors/cache_collector"
8
+ require_relative "collectors/exception_collector"
9
+ require_relative "collectors/env_collector"
10
+ require_relative "collectors/flamegraph_collector"
11
+
12
+ module Profiler
13
+ class TestProfiler
14
+ TEST_COLLECTOR_CLASSES = [
15
+ Collectors::DatabaseCollector,
16
+ Collectors::CacheCollector,
17
+ Collectors::ExceptionCollector,
18
+ Collectors::EnvCollector,
19
+ Collectors::FlameGraphCollector
20
+ ].freeze
21
+
22
+ def self.profile(test_name:, test_file:, test_line:, framework:, &block)
23
+ return block.call unless Profiler.enabled?
24
+
25
+ new(
26
+ test_name: test_name,
27
+ test_file: test_file,
28
+ test_line: test_line,
29
+ framework: framework
30
+ ).run(&block)
31
+ end
32
+
33
+ def initialize(test_name:, test_file:, test_line:, framework:)
34
+ @test_name = test_name
35
+ @test_file = test_file
36
+ @test_line = test_line
37
+ @framework = framework
38
+ end
39
+
40
+ def run(&block)
41
+ profile = Models::Profile.new
42
+ profile.profile_type = "test"
43
+ profile.path = @test_file
44
+ profile.method = "TEST"
45
+
46
+ test_collector = Collectors::TestCollector.new(
47
+ profile,
48
+ test_name: @test_name,
49
+ test_file: @test_file,
50
+ test_line: @test_line,
51
+ framework: @framework
52
+ )
53
+
54
+ collectors = [test_collector] + TEST_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
55
+ collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
56
+
57
+ exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
58
+
59
+ memory_before = current_memory if Profiler.configuration.track_memory
60
+
61
+ test_status = "passed"
62
+ error_message = nil
63
+
64
+ previous_token = Profiler::CurrentContext.token
65
+ Profiler::CurrentContext.token = profile.token
66
+
67
+ begin
68
+ result = block.call
69
+
70
+ # Minitest: Test#run catches assertion errors internally and returns self.
71
+ # Failures don't propagate — detect them from the result object.
72
+ if result.respond_to?(:passed?)
73
+ unless result.passed?
74
+ test_status = result.skipped? ? "pending" : "failed"
75
+ msg = result.failure&.message.to_s
76
+ error_message = msg.empty? ? nil : msg
77
+ end
78
+ test_collector.update_extra(
79
+ assertions: result.respond_to?(:assertions) ? result.assertions : nil,
80
+ skip_reason: (result.skipped? && result.failure) ? result.failure.message : nil
81
+ )
82
+ end
83
+
84
+ # RSpec: example.run catches errors internally and stores them in execution_result.
85
+ if result.respond_to?(:execution_result)
86
+ er = result.execution_result
87
+ rspec_status = er.status&.to_s
88
+ if rspec_status && rspec_status != "passed" && test_status == "passed"
89
+ test_status = rspec_status
90
+ msg = er.exception&.message.to_s
91
+ error_message = msg.empty? ? nil : msg
92
+ end
93
+ end
94
+
95
+ result
96
+ rescue Exception => e # rubocop:disable Lint/RescueException
97
+ # Capture all exceptions (including test failures which may subclass Exception)
98
+ test_status = "failed"
99
+ error_message = "#{e.class}: #{e.message}"
100
+ exception_collector&.capture(e) if e.is_a?(StandardError)
101
+ raise
102
+ ensure
103
+ Profiler::CurrentContext.token = previous_token
104
+
105
+ if Profiler.configuration.track_memory
106
+ profile.memory = current_memory - memory_before
107
+ end
108
+
109
+ test_collector.update_status(test_status, error_message)
110
+ profile.finish(test_status == "passed" ? 200 : 500)
111
+
112
+ collectors.each do |collector|
113
+ begin
114
+ collector.collect if collector.respond_to?(:collect)
115
+ profile.add_collector_metadata(collector)
116
+ rescue => e
117
+ warn "Profiler TestProfiler: Collector #{collector.class} failed: #{e.message}"
118
+ end
119
+ end
120
+
121
+ Profiler.storage.save(profile.token, profile)
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def current_memory
128
+ return 0 unless defined?(GC.stat)
129
+
130
+ stats = GC.stat
131
+ if stats.key?(:total_allocated_size)
132
+ stats[:total_allocated_size]
133
+ elsif stats.key?(:total_allocated_objects)
134
+ stats[:total_allocated_objects] * 40
135
+ else
136
+ 0
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module TestRunner
5
+ class Discovery
6
+ SPEC_GLOB = "spec/**/*_spec.rb"
7
+ TEST_GLOB = "test/**/*_test.rb"
8
+
9
+ def self.frameworks
10
+ frameworks = []
11
+ frameworks << :rspec if rspec_available?
12
+ frameworks << :minitest if minitest_available?
13
+ frameworks
14
+ end
15
+
16
+ def self.files(framework: nil)
17
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
18
+ result = {}
19
+
20
+ globs = case framework&.to_sym
21
+ when :rspec then [SPEC_GLOB]
22
+ when :minitest then [TEST_GLOB]
23
+ else [SPEC_GLOB, TEST_GLOB]
24
+ end
25
+
26
+ globs.each do |glob|
27
+ Dir.glob(File.join(root, glob)).each do |path|
28
+ relative = path.sub("#{root}/", "")
29
+ parts = relative.split("/")
30
+ dir = parts[0..-2].join("/")
31
+ result[dir] ||= []
32
+ result[dir] << { path: relative, name: parts.last }
33
+ end
34
+ end
35
+
36
+ result.map do |dir, files|
37
+ {
38
+ directory: dir,
39
+ files: files.sort_by { |f| f[:name] }
40
+ }
41
+ end.sort_by { |d| d[:directory] }
42
+ end
43
+
44
+ def self.rspec_available?
45
+ defined?(RSpec) || Gem.loaded_specs.key?("rspec-core")
46
+ rescue
47
+ false
48
+ end
49
+
50
+ def self.minitest_available?
51
+ defined?(Minitest) || Gem.loaded_specs.key?("minitest")
52
+ rescue
53
+ false
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "securerandom"
5
+
6
+ module Profiler
7
+ module TestRunner
8
+ class RunStore
9
+ TTL = 3600 # 1 hour
10
+
11
+ Run = Struct.new(:id, :status, :pid, :started_at, :finished_at, :output_lines, :exit_code, :files, :framework, keyword_init: true) do
12
+ def to_h
13
+ super.merge(
14
+ started_at: started_at&.iso8601,
15
+ finished_at: finished_at&.iso8601,
16
+ output: output_lines.join,
17
+ duration: finished_at && started_at ? ((finished_at - started_at) * 1000).round(2) : nil
18
+ ).except(:output_lines)
19
+ end
20
+ end
21
+
22
+ TERMINAL_STATUSES = %w[passed failed killed error].freeze
23
+
24
+ def initialize
25
+ @runs = Concurrent::Hash.new
26
+ @locks = Concurrent::Hash.new
27
+ end
28
+
29
+ def create(files:, framework:)
30
+ id = SecureRandom.hex(8)
31
+ run = Run.new(
32
+ id: id,
33
+ status: "pending",
34
+ pid: nil,
35
+ started_at: Time.now,
36
+ finished_at: nil,
37
+ output_lines: Concurrent::Array.new,
38
+ exit_code: nil,
39
+ files: files,
40
+ framework: framework.to_s
41
+ )
42
+ @runs[id] = run
43
+ @locks[id] = { mutex: Mutex.new, cond: ConditionVariable.new }
44
+ cleanup_old_runs
45
+ run
46
+ end
47
+
48
+ def find(id)
49
+ @runs[id]
50
+ end
51
+
52
+ def update(id, **attrs)
53
+ run = @runs[id]
54
+ return unless run
55
+
56
+ attrs.each { |k, v| run.send(:"#{k}=", v) }
57
+ signal(id)
58
+ run
59
+ end
60
+
61
+ def append_output(id, chunk)
62
+ run = @runs[id]
63
+ return unless run
64
+
65
+ run.output_lines.push(chunk)
66
+ signal(id)
67
+ end
68
+
69
+ # Block until new output is available at +position+ or the run terminates.
70
+ # Returns { chunks: [...], status: "...", position: N, finished: bool }.
71
+ def wait_for_output(id, position:, timeout: 10)
72
+ run = @runs[id]
73
+ return { chunks: [], status: "not_found", position: 0, finished: true } unless run
74
+
75
+ lock = @locks[id]
76
+ return snapshot(run, position) unless lock
77
+
78
+ lock[:mutex].synchronize do
79
+ current = run.output_lines.size
80
+ if current <= position && !TERMINAL_STATUSES.include?(run.status)
81
+ lock[:cond].wait(lock[:mutex], timeout)
82
+ end
83
+ end
84
+
85
+ snapshot(run, position)
86
+ end
87
+
88
+ def all
89
+ @runs.values.sort_by { |r| r.started_at || Time.at(0) }.reverse
90
+ end
91
+
92
+ private
93
+
94
+ def signal(id)
95
+ lock = @locks[id]
96
+ return unless lock
97
+ lock[:mutex].synchronize { lock[:cond].broadcast }
98
+ end
99
+
100
+ def snapshot(run, position)
101
+ lines = run.output_lines.dup
102
+ {
103
+ chunks: lines[position..] || [],
104
+ status: run.status,
105
+ position: lines.size,
106
+ finished: TERMINAL_STATUSES.include?(run.status)
107
+ }
108
+ end
109
+
110
+ def cleanup_old_runs
111
+ cutoff = Time.now - TTL
112
+ @runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) }
113
+ end
114
+ end
115
+
116
+ def self.run_store
117
+ @run_store ||= RunStore.new
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_store"
4
+ require_relative "discovery"
5
+
6
+ module Profiler
7
+ module TestRunner
8
+ class Runner
9
+ def self.start(files:, framework:)
10
+ run = Profiler::TestRunner.run_store.create(files: files, framework: framework)
11
+ spawn_async(run)
12
+ run
13
+ end
14
+
15
+ def self.kill(run_id)
16
+ run = Profiler::TestRunner.run_store.find(run_id)
17
+ return false unless run && run.pid && run.status == "running"
18
+
19
+ begin
20
+ Process.kill("TERM", run.pid)
21
+ Profiler::TestRunner.run_store.update(run_id, status: "killed", finished_at: Time.now)
22
+ true
23
+ rescue Errno::ESRCH
24
+ # Process already exited
25
+ false
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def self.spawn_async(run)
32
+ Thread.new do
33
+ run_process(run)
34
+ rescue => e
35
+ Profiler::TestRunner.run_store.update(
36
+ run.id,
37
+ status: "error",
38
+ finished_at: Time.now
39
+ )
40
+ Profiler::TestRunner.run_store.append_output(run.id, "\n[Profiler] Error: #{e.message}\n")
41
+ end
42
+ end
43
+
44
+ def self.run_process(run)
45
+ cmd = build_command(run.files, run.framework)
46
+ env = build_env
47
+
48
+ Profiler::TestRunner.run_store.update(run.id, status: "running", started_at: Time.now)
49
+
50
+ IO.popen([env, *cmd, err: [:child, :out]], "r") do |io|
51
+ Profiler::TestRunner.run_store.update(run.id, pid: io.pid)
52
+
53
+ while (chunk = io.read(256))
54
+ break if chunk.empty?
55
+
56
+ Profiler::TestRunner.run_store.append_output(run.id, chunk)
57
+ end
58
+ end
59
+
60
+ exit_code = $?.exitstatus || 0
61
+ status = exit_code == 0 ? "passed" : "failed"
62
+
63
+ Profiler::TestRunner.run_store.update(
64
+ run.id,
65
+ status: status,
66
+ finished_at: Time.now,
67
+ exit_code: exit_code
68
+ )
69
+ end
70
+
71
+ def self.build_command(files, framework)
72
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
73
+ absolute_files = files.map { |f| File.join(root, f) }
74
+
75
+ case framework.to_sym
76
+ when :rspec
77
+ ["bundle", "exec", "rspec", "--format", "progress", "--color", *absolute_files]
78
+ when :minitest
79
+ ["bundle", "exec", "rails", "test", *absolute_files]
80
+ else
81
+ ["bundle", "exec", "rspec", "--format", "progress", "--color", *absolute_files]
82
+ end
83
+ end
84
+
85
+ BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE].freeze
86
+
87
+ def self.build_env
88
+ base = ENV.to_h
89
+
90
+ # Inject env var overrides configured in the profiler — skip blocked keys
91
+ overrides = Profiler.env_override_store.all_overrides
92
+ overrides.each do |key, entry|
93
+ next if BLOCKED_ENV_KEYS.include?(key.upcase)
94
+ value = entry.is_a?(Hash) ? entry["value"] : entry
95
+ base[key] = value
96
+ end
97
+
98
+ # Ensure test environment regardless of overrides
99
+ base["RAILS_ENV"] = "test"
100
+ base["RACK_ENV"] = "test"
101
+
102
+ base
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.24.0"
4
+ VERSION = "0.26.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-01 00:00:00.000000000 Z
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -107,26 +107,32 @@ files:
107
107
  - app/assets/builds/profiler.css
108
108
  - app/assets/builds/profiler.js
109
109
  - app/controllers/profiler/api/ajax_controller.rb
110
+ - app/controllers/profiler/api/console_controller.rb
110
111
  - app/controllers/profiler/api/env_vars_controller.rb
111
112
  - app/controllers/profiler/api/explain_controller.rb
112
113
  - app/controllers/profiler/api/function_profiling_controller.rb
113
114
  - app/controllers/profiler/api/jobs_controller.rb
114
115
  - app/controllers/profiler/api/outbound_http_controller.rb
115
116
  - app/controllers/profiler/api/profiles_controller.rb
117
+ - app/controllers/profiler/api/test_runner_controller.rb
118
+ - app/controllers/profiler/api/tests_controller.rb
116
119
  - app/controllers/profiler/api/toolbar_controller.rb
117
120
  - app/controllers/profiler/application_controller.rb
118
121
  - app/controllers/profiler/assets_controller.rb
119
122
  - app/controllers/profiler/profiles_controller.rb
123
+ - app/controllers/profiler/test_runner_controller.rb
120
124
  - app/views/layouts/profiler/application.html.erb
121
125
  - app/views/layouts/profiler/embedded.html.erb
122
126
  - app/views/profiler/profiles/index.html.erb
123
127
  - app/views/profiler/profiles/show.html.erb
128
+ - app/views/profiler/test_runner/index.html.erb
124
129
  - config/routes.rb
125
130
  - exe/profiler-mcp
126
131
  - lib/profiler.rb
127
132
  - lib/profiler/collectors/ajax_collector.rb
128
133
  - lib/profiler/collectors/base_collector.rb
129
134
  - lib/profiler/collectors/cache_collector.rb
135
+ - lib/profiler/collectors/console_collector.rb
130
136
  - lib/profiler/collectors/database_collector.rb
131
137
  - lib/profiler/collectors/dump_collector.rb
132
138
  - lib/profiler/collectors/env_collector.rb
@@ -140,13 +146,16 @@ files:
140
146
  - lib/profiler/collectors/mailer_collector.rb
141
147
  - lib/profiler/collectors/request_collector.rb
142
148
  - lib/profiler/collectors/routes_collector.rb
149
+ - lib/profiler/collectors/test_collector.rb
143
150
  - lib/profiler/collectors/view_collector.rb
144
151
  - lib/profiler/configuration.rb
152
+ - lib/profiler/console_profiler.rb
145
153
  - lib/profiler/current_context.rb
146
154
  - lib/profiler/engine.rb
147
155
  - lib/profiler/env_override_store.rb
148
156
  - lib/profiler/explain_runner.rb
149
157
  - lib/profiler/instrumentation/active_job_instrumentation.rb
158
+ - lib/profiler/instrumentation/irb_instrumentation.rb
150
159
  - lib/profiler/instrumentation/net_http_instrumentation.rb
151
160
  - lib/profiler/instrumentation/sidekiq_middleware.rb
152
161
  - lib/profiler/instrumentation/thread_context_propagation.rb
@@ -154,10 +163,12 @@ files:
154
163
  - lib/profiler/mcp/body_formatter.rb
155
164
  - lib/profiler/mcp/file_cache.rb
156
165
  - lib/profiler/mcp/path_extractor.rb
166
+ - lib/profiler/mcp/resources/failing_tests.rb
157
167
  - lib/profiler/mcp/resources/n1_patterns.rb
158
168
  - lib/profiler/mcp/resources/recent_jobs.rb
159
169
  - lib/profiler/mcp/resources/recent_requests.rb
160
170
  - lib/profiler/mcp/resources/slow_queries.rb
171
+ - lib/profiler/mcp/resources/slow_tests.rb
161
172
  - lib/profiler/mcp/server.rb
162
173
  - lib/profiler/mcp/tools/analyze_queries.rb
163
174
  - lib/profiler/mcp/tools/clear_profiles.rb
@@ -167,12 +178,15 @@ files:
167
178
  - lib/profiler/mcp/tools/get_profile_detail.rb
168
179
  - lib/profiler/mcp/tools/get_profile_dumps.rb
169
180
  - lib/profiler/mcp/tools/get_profile_http.rb
181
+ - lib/profiler/mcp/tools/get_test_profile_detail.rb
170
182
  - lib/profiler/mcp/tools/list_env_vars.rb
171
183
  - lib/profiler/mcp/tools/query_jobs.rb
172
184
  - lib/profiler/mcp/tools/query_mailers.rb
173
185
  - lib/profiler/mcp/tools/query_profiles.rb
186
+ - lib/profiler/mcp/tools/query_test_profiles.rb
174
187
  - lib/profiler/mcp/tools/reset_all_env_vars.rb
175
188
  - lib/profiler/mcp/tools/reset_env_var.rb
189
+ - lib/profiler/mcp/tools/run_tests.rb
176
190
  - lib/profiler/mcp/tools/set_env_var.rb
177
191
  - lib/profiler/middleware/cors_middleware.rb
178
192
  - lib/profiler/middleware/profiler_middleware.rb
@@ -188,6 +202,13 @@ files:
188
202
  - lib/profiler/storage/redis_store.rb
189
203
  - lib/profiler/storage/sqlite_store.rb
190
204
  - lib/profiler/tasks/profiler.rake
205
+ - lib/profiler/test_helpers/minitest_support.rb
206
+ - lib/profiler/test_helpers/reporter.rb
207
+ - lib/profiler/test_helpers/rspec_support.rb
208
+ - lib/profiler/test_profiler.rb
209
+ - lib/profiler/test_runner/discovery.rb
210
+ - lib/profiler/test_runner/run_store.rb
211
+ - lib/profiler/test_runner/runner.rb
191
212
  - lib/profiler/version.rb
192
213
  homepage: https://git.duplessy.eu/sebastien/rails-profiler-gem
193
214
  licenses: