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.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler.css +24 -0
- data/app/assets/builds/profiler.js +739 -31
- data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
- data/app/controllers/profiler/api/tests_controller.rb +46 -0
- data/app/controllers/profiler/test_runner_controller.rb +11 -0
- data/app/views/profiler/test_runner/index.html.erb +1 -0
- data/config/routes.rb +10 -0
- data/lib/profiler/collectors/database_collector.rb +1 -1
- data/lib/profiler/collectors/test_collector.rb +75 -0
- data/lib/profiler/configuration.rb +12 -1
- data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
- data/lib/profiler/mcp/resources/recent_console.rb +36 -0
- data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
- data/lib/profiler/mcp/server.rb +122 -8
- data/lib/profiler/mcp/tools/clear_profiles.rb +3 -3
- data/lib/profiler/mcp/tools/get_profile_detail.rb +123 -1
- data/lib/profiler/mcp/tools/get_profile_mailers.rb +147 -0
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +95 -0
- data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
- data/lib/profiler/mcp/tools/run_tests.rb +112 -0
- data/lib/profiler/railtie.rb +13 -1
- data/lib/profiler/test_helpers/minitest_support.rb +39 -0
- data/lib/profiler/test_helpers/reporter.rb +121 -0
- data/lib/profiler/test_helpers/rspec_support.rb +33 -0
- data/lib/profiler/test_profiler.rb +140 -0
- data/lib/profiler/test_runner/discovery.rb +57 -0
- data/lib/profiler/test_runner/run_store.rb +120 -0
- data/lib/profiler/test_runner/runner.rb +106 -0
- data/lib/profiler/version.rb +1 -1
- 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 @@
|
|
|
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, :
|
|
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
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -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'
|
|
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"
|
|
285
|
-
"profiler://slow-queries"
|
|
286
|
-
"profiler://n1-patterns"
|
|
287
|
-
"profiler://recent-jobs"
|
|
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 '
|
|
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
|
|
16
|
+
label = type ? "#{type} profiles" : "all profiles"
|
|
17
17
|
[{ type: "text", text: "Cleared #{label}." }]
|
|
18
18
|
end
|
|
19
19
|
end
|