rails-profiler 0.25.0 → 0.27.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 +739 -31
  4. data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
  5. data/app/controllers/profiler/api/tests_controller.rb +46 -0
  6. data/app/controllers/profiler/test_runner_controller.rb +11 -0
  7. data/app/views/profiler/test_runner/index.html.erb +1 -0
  8. data/config/routes.rb +10 -0
  9. data/lib/profiler/collectors/database_collector.rb +1 -1
  10. data/lib/profiler/collectors/test_collector.rb +75 -0
  11. data/lib/profiler/configuration.rb +12 -1
  12. data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
  13. data/lib/profiler/mcp/resources/recent_console.rb +36 -0
  14. data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
  15. data/lib/profiler/mcp/server.rb +122 -8
  16. data/lib/profiler/mcp/tools/clear_profiles.rb +3 -3
  17. data/lib/profiler/mcp/tools/get_profile_detail.rb +123 -1
  18. data/lib/profiler/mcp/tools/get_profile_mailers.rb +147 -0
  19. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
  20. data/lib/profiler/mcp/tools/query_console_profiles.rb +95 -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 +13 -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 +22 -2
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "profiler/test_runner/discovery"
4
+ require "profiler/test_runner/runner"
5
+
6
+ module Profiler
7
+ module Api
8
+ class TestRunnerController < ApplicationController
9
+ include ActionController::Live
10
+
11
+ skip_before_action :verify_authenticity_token
12
+
13
+ def files
14
+ framework = params[:framework]
15
+ tree = Profiler::TestRunner::Discovery.files(framework: framework)
16
+ frameworks = Profiler::TestRunner::Discovery.frameworks
17
+
18
+ render json: {
19
+ frameworks: frameworks,
20
+ tree: tree
21
+ }
22
+ end
23
+
24
+ def create
25
+ files = Array(params[:files])
26
+ framework = params[:framework] || detect_framework
27
+
28
+ if files.empty?
29
+ return render json: { error: "No files selected" }, status: :unprocessable_entity
30
+ end
31
+
32
+ # Validate paths are within Rails root (prevent path traversal)
33
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
34
+ files.each do |f|
35
+ expanded = File.expand_path(File.join(root, f))
36
+ unless expanded.start_with?(root)
37
+ return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
42
+ render json: run.to_h, status: :created
43
+ end
44
+
45
+ def show
46
+ run = Profiler::TestRunner.run_store.find(params[:id])
47
+ return render json: { error: "Run not found" }, status: :not_found unless run
48
+
49
+ render json: run.to_h
50
+ end
51
+
52
+ # SSE endpoint — streams output chunks as server-sent events.
53
+ # Replaces polling for live test output in the frontend.
54
+ def stream
55
+ run = Profiler::TestRunner.run_store.find(params[:id])
56
+ unless run
57
+ render json: { error: "Run not found" }, status: :not_found
58
+ return
59
+ end
60
+
61
+ response.headers["Content-Type"] = "text/event-stream"
62
+ response.headers["Cache-Control"] = "no-cache"
63
+ response.headers["X-Accel-Buffering"] = "no"
64
+
65
+ sse = SSE.new(response.stream, retry: 1000, event: "output")
66
+ position = 0
67
+
68
+ begin
69
+ loop do
70
+ result = Profiler::TestRunner.run_store.wait_for_output(
71
+ params[:id], position: position, timeout: 15
72
+ )
73
+
74
+ result[:chunks].each do |chunk|
75
+ sse.write({ chunk: chunk })
76
+ end
77
+ position = result[:position]
78
+
79
+ if result[:finished]
80
+ current_run = Profiler::TestRunner.run_store.find(params[:id])
81
+ sse.write(
82
+ { status: result[:status], exit_code: current_run&.exit_code },
83
+ event: "done"
84
+ )
85
+ break
86
+ end
87
+ end
88
+ rescue ActionController::Live::ClientDisconnected, IOError
89
+ # Client navigated away — normal exit
90
+ ensure
91
+ sse.close
92
+ end
93
+ end
94
+
95
+ def destroy
96
+ killed = Profiler::TestRunner::Runner.kill(params[:id])
97
+ if killed
98
+ head :no_content
99
+ else
100
+ render json: { error: "Run not found or not running" }, status: :not_found
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def detect_framework
107
+ if Profiler::TestRunner::Discovery.rspec_available?
108
+ "rspec"
109
+ else
110
+ "minitest"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class TestsController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ limit = (params[:limit] || 50).to_i
10
+ offset = (params[:offset] || 0).to_i
11
+ all = Profiler.storage.list(limit: 1000, offset: 0)
12
+ tests = all.select { |p| p.profile_type == "test" }
13
+ page = tests.drop(offset).first(limit + 1)
14
+ render json: {
15
+ profiles: page.first(limit).map(&:to_h),
16
+ limit: limit,
17
+ offset: offset,
18
+ has_more: page.size > limit
19
+ }
20
+ end
21
+
22
+ def show
23
+ profile = Profiler.storage.load(params[:id])
24
+
25
+ unless profile && profile.profile_type == "test"
26
+ return render json: { error: "Test profile not found" }, status: :not_found
27
+ end
28
+
29
+ render json: profile.to_h
30
+ end
31
+
32
+ def destroy
33
+ profile = Profiler.storage.load(params[:id])
34
+ return render json: { error: "Test profile not found" }, status: :not_found unless profile&.profile_type == "test"
35
+
36
+ Profiler.storage.delete(params[:id])
37
+ head :no_content
38
+ end
39
+
40
+ def clear
41
+ Profiler.storage.clear(type: "test")
42
+ head :no_content
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class TestRunnerController < ApplicationController
5
+ layout "profiler/application"
6
+
7
+ def index
8
+ redirect_to profiler.root_path(section: "runner"), allow_other_host: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ <div id="profiler-test-runner"></div>
data/config/routes.rb CHANGED
@@ -22,6 +22,8 @@ Profiler::Engine.routes.draw do
22
22
  get "assets/profiler.js", to: "assets#main_js"
23
23
  get "assets/profiler.css", to: "assets#main_css"
24
24
 
25
+ get "test_runner", to: "test_runner#index"
26
+
25
27
  namespace :api do
26
28
  resources :profiles, only: [:index, :show, :destroy] do
27
29
  collection { delete :clear }
@@ -32,6 +34,9 @@ Profiler::Engine.routes.draw do
32
34
  resources :console, only: [:index, :show, :destroy] do
33
35
  collection { delete :clear }
34
36
  end
37
+ resources :tests, only: [:index, :show, :destroy] do
38
+ collection { delete :clear }
39
+ end
35
40
  resources :outbound_http, only: [:index]
36
41
  get "toolbar/:token", to: "toolbar#show"
37
42
  post "ajax/link", to: "ajax#link"
@@ -40,5 +45,10 @@ Profiler::Engine.routes.draw do
40
45
  resource :env_vars, only: [:show, :update], controller: "env_vars"
41
46
  delete "env_vars/reset", to: "env_vars#reset_override"
42
47
  delete "env_vars/reset_all", to: "env_vars#reset_all"
48
+ get "test_runner/files", to: "test_runner#files"
49
+ post "test_runner/runs", to: "test_runner#create"
50
+ get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
51
+ get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
52
+ delete "test_runner/runs/:id", to: "test_runner#destroy"
43
53
  end
44
54
  end
@@ -39,7 +39,7 @@ module Profiler
39
39
 
40
40
  # Skip schema queries and internal Rails queries
41
41
  next if payload[:name] == "SCHEMA"
42
- next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
42
+ next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
43
43
 
44
44
  query = Models::SqlQuery.new(
45
45
  sql: payload[:sql],
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class TestCollector < BaseCollector
8
+ def initialize(profile, test_name:, test_file:, test_line:, framework:)
9
+ super(profile)
10
+ @test_name = test_name
11
+ @test_file = test_file
12
+ @test_line = test_line
13
+ @framework = framework
14
+ @status = "running"
15
+ @exception_message = nil
16
+ end
17
+
18
+ def icon
19
+ "🧪"
20
+ end
21
+
22
+ def priority
23
+ 5
24
+ end
25
+
26
+ def tab_config
27
+ {
28
+ key: "test",
29
+ label: "Test",
30
+ icon: icon,
31
+ priority: priority,
32
+ enabled: true,
33
+ default_active: true
34
+ }
35
+ end
36
+
37
+ def update_status(status, exception_message = nil)
38
+ @status = status
39
+ @exception_message = exception_message
40
+ end
41
+
42
+ def update_extra(assertions: nil, skip_reason: nil)
43
+ @assertions = assertions
44
+ @skip_reason = skip_reason
45
+ end
46
+
47
+ def collect
48
+ store_data({
49
+ test_name: @test_name,
50
+ test_file: @test_file,
51
+ test_line: @test_line,
52
+ framework: @framework.to_s,
53
+ status: @status,
54
+ exception_message: @exception_message,
55
+ assertions: @assertions,
56
+ skip_reason: @skip_reason
57
+ })
58
+ end
59
+
60
+ def has_data?
61
+ true
62
+ end
63
+
64
+ def toolbar_summary
65
+ color = case @status
66
+ when "passed" then "green"
67
+ when "failed" then "red"
68
+ when "pending" then "orange"
69
+ else "gray"
70
+ end
71
+ { text: @status, color: color }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Profiler
4
4
  class Configuration
5
- attr_accessor :enabled, :storage, :storage_options, :collectors,
5
+ attr_accessor :enabled, :storage_options, :collectors,
6
6
  :skip_paths, :slow_query_threshold, :max_queries_warning,
7
7
  :track_memory, :memory_warning_threshold,
8
8
  :mcp_enabled, :mcp_transport, :mcp_port,
@@ -12,6 +12,7 @@ module Profiler
12
12
  :track_http, :slow_http_threshold, :http_skip_hosts,
13
13
  :track_jobs,
14
14
  :track_console,
15
+ :track_tests,
15
16
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
16
17
  :compress_bodies, :compress_body_threshold
17
18
 
@@ -44,6 +45,7 @@ module Profiler
44
45
  @http_skip_hosts = []
45
46
  @track_jobs = true
46
47
  @track_console = true
48
+ @track_tests = false
47
49
  @track_mailers = true
48
50
  @capture_mail_body = false
49
51
  @sanitize_mailer_recipients = false
@@ -72,6 +74,15 @@ module Profiler
72
74
  end
73
75
  end
74
76
 
77
+ def storage
78
+ @storage
79
+ end
80
+
81
+ def storage=(value)
82
+ @storage = value
83
+ @storage_backend = nil
84
+ end
85
+
75
86
  def storage_backend
76
87
  @storage_backend ||= build_storage_backend
77
88
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class FailingTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ failing = profiles.select do |p|
10
+ next false unless p.profile_type == "test"
11
+ test_data = p.collector_data("test") || {}
12
+ test_data["status"] == "failed"
13
+ end.first(20)
14
+
15
+ data = failing.map do |profile|
16
+ test_data = profile.collector_data("test") || {}
17
+ db_data = profile.collector_data("database") || {}
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ framework: test_data["framework"],
23
+ file: "#{test_data["test_file"]}:#{test_data["test_line"]}",
24
+ exception: test_data["exception_message"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ timestamp: profile.started_at&.iso8601
28
+ }
29
+ end
30
+
31
+ {
32
+ uri: "profiler://failing-tests",
33
+ mimeType: "application/json",
34
+ text: JSON.pretty_generate({ total: data.size, failing_tests: data })
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class RecentConsole
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 200)
9
+ consoles = profiles.select { |p| p.profile_type == "console" }.first(50)
10
+
11
+ data = consoles.map do |profile|
12
+ console_data = profile.collector_data("console") || {}
13
+ {
14
+ token: profile.token,
15
+ expression: console_data["expression"],
16
+ return_value: console_data["return_value"],
17
+ status: profile.status == 200 ? "completed" : "failed",
18
+ duration: profile.duration&.round(2),
19
+ query_count: profile.collector_data("database")&.dig("total_queries") || 0,
20
+ timestamp: profile.started_at&.iso8601
21
+ }
22
+ end
23
+
24
+ {
25
+ uri: "profiler://recent-console",
26
+ mimeType: "application/json",
27
+ text: JSON.pretty_generate({
28
+ total: data.size,
29
+ console_executions: data
30
+ })
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class SlowTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ tests = profiles.select { |p| p.profile_type == "test" }
10
+ .sort_by { |p| -p.duration }
11
+ .first(10)
12
+
13
+ data = tests.map do |profile|
14
+ test_data = profile.collector_data("test") || {}
15
+ db_data = profile.collector_data("database") || {}
16
+ queries = db_data["queries"] || []
17
+ n1 = queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ status: test_data["status"],
23
+ framework: test_data["framework"],
24
+ file: test_data["test_file"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ n1_detected: n1,
28
+ timestamp: profile.started_at&.iso8601
29
+ }
30
+ end
31
+
32
+ {
33
+ uri: "profiler://slow-tests",
34
+ mimeType: "application/json",
35
+ text: JSON.pretty_generate({ total: data.size, slow_tests: data })
36
+ }
37
+ end
38
+
39
+ def self.normalize_sql(sql)
40
+ sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -69,8 +69,13 @@ module Profiler
69
69
  require_relative "tools/get_profile_ajax"
70
70
  require_relative "tools/get_profile_dumps"
71
71
  require_relative "tools/get_profile_http"
72
+ require_relative "tools/get_profile_mailers"
72
73
  require_relative "tools/query_jobs"
73
74
  require_relative "tools/query_mailers"
75
+ require_relative "tools/query_test_profiles"
76
+ require_relative "tools/get_test_profile_detail"
77
+ require_relative "tools/run_tests"
78
+ require_relative "tools/query_console_profiles"
74
79
  require_relative "tools/clear_profiles"
75
80
  require_relative "tools/list_env_vars"
76
81
  require_relative "tools/set_env_var"
@@ -101,11 +106,13 @@ module Profiler
101
106
  input_schema: {
102
107
  properties: {
103
108
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
104
- sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps. Omit for all." },
109
+ sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, console, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps, logs, env, i18n, related_jobs. Omit for all." },
105
110
  save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
106
111
  max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
107
112
  json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
108
- xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
113
+ xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." },
114
+ log_min_level: { type: "string", description: "Minimum log level to include in the logs section: DEBUG, INFO, WARN, ERROR, FATAL. Only applied when 'logs' section is requested." },
115
+ env_filter: { type: "string", description: "Required when requesting the env section. Case-insensitive substring filter on ENV key name (e.g. 'RAILS', 'DATABASE')." }
109
116
  },
110
117
  required: ["token"]
111
118
  },
@@ -173,6 +180,22 @@ module Profiler
173
180
  },
174
181
  handler: Tools::GetProfileHttp
175
182
  ),
183
+ define_tool(
184
+ name: "get_profile_mailers",
185
+ description: "Get detailed mailer activity for a profile: delivered emails, errors, and queued deliveries — including email bodies when capture_mail_body is enabled.",
186
+ input_schema: {
187
+ properties: {
188
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
189
+ mailer_class: { type: "string", description: "Filter by mailer class name (partial match, e.g. 'UserMailer')" },
190
+ action: { type: "string", description: "Filter by mailer action (partial match, e.g. 'welcome_email')" },
191
+ delivery_mode: { type: "string", description: "Filter by delivery mode: 'deliver_now', 'deliver_later', or 'queued'" },
192
+ save_bodies: { type: "boolean", description: "Save email bodies to temp files and return paths instead of inlining content." },
193
+ max_body_size: { type: "number", description: "Truncate inlined body content at N characters." }
194
+ },
195
+ required: ["token"]
196
+ },
197
+ handler: Tools::GetProfileMailers
198
+ ),
176
199
  define_tool(
177
200
  name: "query_jobs",
178
201
  description: "Search and filter background job profiles by queue, status, etc.",
@@ -203,12 +226,79 @@ module Profiler
203
226
  },
204
227
  handler: Tools::QueryMailers
205
228
  ),
229
+ define_tool(
230
+ name: "query_test_profiles",
231
+ description: "Search and filter test profiles (RSpec/Minitest) by test name, status, or duration.",
232
+ input_schema: {
233
+ properties: {
234
+ test_name: { type: "string", description: "Filter by test name (partial match)" },
235
+ status: { type: "string", description: "Filter by status: 'passed', 'failed', or 'pending'" },
236
+ min_duration: { type: "number", description: "Minimum duration in milliseconds" },
237
+ limit: { type: "number", description: "Maximum number of results (default 20)" },
238
+ fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, test_name, status, duration, queries, n1, token. Omit for all." },
239
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
240
+ }
241
+ },
242
+ handler: Tools::QueryTestProfiles
243
+ ),
244
+ define_tool(
245
+ name: "get_test_profile",
246
+ description: "Get detailed data for a test profile: metadata, SQL queries, N+1 patterns, cache, exception. Use 'latest' as token for the most recent test.",
247
+ input_schema: {
248
+ properties: {
249
+ token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" }
250
+ },
251
+ required: ["token"]
252
+ },
253
+ handler: Tools::GetTestProfileDetail
254
+ ),
255
+ define_tool(
256
+ name: "run_tests",
257
+ description: "Run test files and wait for results. Returns output, status, duration, and tokens of test profiles created. Synchronous with configurable timeout (default 120s).",
258
+ input_schema: {
259
+ properties: {
260
+ files: {
261
+ type: "array",
262
+ items: { type: "string" },
263
+ description: "Relative paths of test files to run (e.g. ['spec/models/user_spec.rb']). Omit to run all discovered tests."
264
+ },
265
+ framework: {
266
+ type: "string",
267
+ description: "Test framework: 'rspec' or 'minitest'. Auto-detected if omitted."
268
+ },
269
+ timeout_seconds: {
270
+ type: "number",
271
+ description: "Maximum seconds to wait for tests to finish (default: 120)."
272
+ },
273
+ max_output: {
274
+ type: "number",
275
+ description: "Maximum characters of output to return (tail). Default: 4000."
276
+ }
277
+ }
278
+ },
279
+ handler: Tools::RunTests
280
+ ),
281
+ define_tool(
282
+ name: "query_console_profiles",
283
+ description: "Search and filter Rails console profiling sessions (IRB/rails console executions).",
284
+ input_schema: {
285
+ properties: {
286
+ expression: { type: "string", description: "Filter by expression content (partial match, e.g. 'User.find')" },
287
+ status: { type: "string", description: "Filter by status: 'completed' or 'failed'" },
288
+ min_duration: { type: "number", description: "Minimum duration in milliseconds" },
289
+ limit: { type: "number", description: "Maximum number of results (default 20)" },
290
+ fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, expression, return_value, status, duration, queries, token. Omit for all." },
291
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
292
+ }
293
+ },
294
+ handler: Tools::QueryConsoleProfiles
295
+ ),
206
296
  define_tool(
207
297
  name: "clear_profiles",
208
- description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
298
+ description: "Clear profiler history. Omit type to clear everything, or pass 'http', 'job', 'test', or 'console' to clear only that type.",
209
299
  input_schema: {
210
300
  properties: {
211
- type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs" }
301
+ type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles, 'console' to clear only console sessions" }
212
302
  }
213
303
  },
214
304
  handler: Tools::ClearProfiles
@@ -279,12 +369,18 @@ module Profiler
279
369
  require_relative "resources/slow_queries"
280
370
  require_relative "resources/n1_patterns"
281
371
  require_relative "resources/recent_jobs"
372
+ require_relative "resources/slow_tests"
373
+ require_relative "resources/failing_tests"
374
+ require_relative "resources/recent_console"
282
375
 
283
376
  handlers = {
284
- "profiler://recent" => Resources::RecentRequests,
285
- "profiler://slow-queries" => Resources::SlowQueries,
286
- "profiler://n1-patterns" => Resources::N1Patterns,
287
- "profiler://recent-jobs" => Resources::RecentJobs
377
+ "profiler://recent" => Resources::RecentRequests,
378
+ "profiler://slow-queries" => Resources::SlowQueries,
379
+ "profiler://n1-patterns" => Resources::N1Patterns,
380
+ "profiler://recent-jobs" => Resources::RecentJobs,
381
+ "profiler://slow-tests" => Resources::SlowTests,
382
+ "profiler://failing-tests" => Resources::FailingTests,
383
+ "profiler://recent-console" => Resources::RecentConsole
288
384
  }
289
385
 
290
386
  resources = [
@@ -311,6 +407,24 @@ module Profiler
311
407
  name: "Recent Jobs",
312
408
  description: "List of recently profiled background jobs",
313
409
  mime_type: "application/json"
410
+ ),
411
+ ::MCP::Resource.new(
412
+ uri: "profiler://slow-tests",
413
+ name: "Slow Tests",
414
+ description: "Top 10 slowest test profiles with query counts and N+1 detection",
415
+ mime_type: "application/json"
416
+ ),
417
+ ::MCP::Resource.new(
418
+ uri: "profiler://failing-tests",
419
+ name: "Failing Tests",
420
+ description: "Recent test profiles with status 'failed', including exception messages",
421
+ mime_type: "application/json"
422
+ ),
423
+ ::MCP::Resource.new(
424
+ uri: "profiler://recent-console",
425
+ name: "Recent Console Sessions",
426
+ description: "List of recently profiled Rails console (IRB) executions",
427
+ mime_type: "application/json"
314
428
  )
315
429
  ]
316
430
 
@@ -7,13 +7,13 @@ module Profiler
7
7
  def self.call(params)
8
8
  type = params["type"]
9
9
 
10
- if type && !%w[http job].include?(type)
11
- return [{ type: "text", text: "Error: type must be 'http' or 'job'" }]
10
+ if type && !%w[http job test console].include?(type)
11
+ return [{ type: "text", text: "Error: type must be 'http', 'job', 'test', or 'console'" }]
12
12
  end
13
13
 
14
14
  Profiler.storage.clear(type: type)
15
15
 
16
- label = type ? "#{type} profiles" : "all profiles (requests and jobs)"
16
+ label = type ? "#{type} profiles" : "all profiles"
17
17
  [{ type: "text", text: "Cleared #{label}." }]
18
18
  end
19
19
  end