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.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler.css +24 -0
- data/app/assets/builds/profiler.js +994 -26
- data/app/controllers/profiler/api/console_controller.rb +46 -0
- data/app/controllers/profiler/api/profiles_controller.rb +1 -1
- 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 +13 -0
- data/lib/profiler/collectors/console_collector.rb +57 -0
- data/lib/profiler/collectors/database_collector.rb +1 -1
- data/lib/profiler/collectors/test_collector.rb +75 -0
- data/lib/profiler/configuration.rb +14 -1
- data/lib/profiler/console_profiler.rb +102 -0
- data/lib/profiler/instrumentation/irb_instrumentation.rb +21 -0
- data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
- data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
- data/lib/profiler/mcp/server.rb +77 -6
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -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 +21 -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 +23 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Api
|
|
5
|
+
class ConsoleController < 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
|
+
console = all.select { |p| p.profile_type == "console" }
|
|
13
|
+
page = console.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 == "console"
|
|
26
|
+
return render json: { error: "Console 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: "Console profile not found" }, status: :not_found unless profile&.profile_type == "console"
|
|
35
|
+
|
|
36
|
+
Profiler.storage.delete(params[:id])
|
|
37
|
+
head :no_content
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clear
|
|
41
|
+
Profiler.storage.clear(type: "console")
|
|
42
|
+
head :no_content
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -9,7 +9,7 @@ module Profiler
|
|
|
9
9
|
limit = (params[:limit] || 50).to_i
|
|
10
10
|
offset = (params[:offset] || 0).to_i
|
|
11
11
|
all = Profiler.storage.list(limit: 1000, offset: 0)
|
|
12
|
-
http = all.
|
|
12
|
+
http = all.select { |p| p.profile_type == "http" }
|
|
13
13
|
page = http.drop(offset).first(limit + 1)
|
|
14
14
|
render json: {
|
|
15
15
|
profiles: page.first(limit).map(&:to_h),
|
|
@@ -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 }
|
|
@@ -29,6 +31,12 @@ Profiler::Engine.routes.draw do
|
|
|
29
31
|
resources :jobs, only: [:index, :show, :destroy] do
|
|
30
32
|
collection { delete :clear }
|
|
31
33
|
end
|
|
34
|
+
resources :console, only: [:index, :show, :destroy] do
|
|
35
|
+
collection { delete :clear }
|
|
36
|
+
end
|
|
37
|
+
resources :tests, only: [:index, :show, :destroy] do
|
|
38
|
+
collection { delete :clear }
|
|
39
|
+
end
|
|
32
40
|
resources :outbound_http, only: [:index]
|
|
33
41
|
get "toolbar/:token", to: "toolbar#show"
|
|
34
42
|
post "ajax/link", to: "ajax#link"
|
|
@@ -37,5 +45,10 @@ Profiler::Engine.routes.draw do
|
|
|
37
45
|
resource :env_vars, only: [:show, :update], controller: "env_vars"
|
|
38
46
|
delete "env_vars/reset", to: "env_vars#reset_override"
|
|
39
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"
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
class ConsoleCollector < BaseCollector
|
|
8
|
+
def initialize(profile, expression:)
|
|
9
|
+
super(profile)
|
|
10
|
+
@expression = expression
|
|
11
|
+
@return_value = nil
|
|
12
|
+
@return_value_captured = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def set_return_value(value)
|
|
16
|
+
@return_value = value.inspect.slice(0, 10_000)
|
|
17
|
+
@return_value_captured = true
|
|
18
|
+
rescue
|
|
19
|
+
@return_value = "(uninspectable)"
|
|
20
|
+
@return_value_captured = true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def icon
|
|
24
|
+
">_"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def priority
|
|
28
|
+
5
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tab_config
|
|
32
|
+
{
|
|
33
|
+
key: "console",
|
|
34
|
+
label: "Console",
|
|
35
|
+
icon: icon,
|
|
36
|
+
priority: priority,
|
|
37
|
+
enabled: true,
|
|
38
|
+
default_active: true
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def collect
|
|
43
|
+
data = { expression: @expression }
|
|
44
|
+
data[:return_value] = @return_value if @return_value_captured
|
|
45
|
+
store_data(data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def has_data?
|
|
49
|
+
@expression.to_s.length > 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def toolbar_summary
|
|
53
|
+
{ text: @expression.to_s[0, 30], color: "blue" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
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,
|
|
@@ -11,6 +11,8 @@ module Profiler
|
|
|
11
11
|
:track_ajax, :ajax_skip_paths,
|
|
12
12
|
:track_http, :slow_http_threshold, :http_skip_hosts,
|
|
13
13
|
:track_jobs,
|
|
14
|
+
:track_console,
|
|
15
|
+
:track_tests,
|
|
14
16
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
15
17
|
:compress_bodies, :compress_body_threshold
|
|
16
18
|
|
|
@@ -42,6 +44,8 @@ module Profiler
|
|
|
42
44
|
@slow_http_threshold = 500 # milliseconds
|
|
43
45
|
@http_skip_hosts = []
|
|
44
46
|
@track_jobs = true
|
|
47
|
+
@track_console = true
|
|
48
|
+
@track_tests = false
|
|
45
49
|
@track_mailers = true
|
|
46
50
|
@capture_mail_body = false
|
|
47
51
|
@sanitize_mailer_recipients = false
|
|
@@ -70,6 +74,15 @@ module Profiler
|
|
|
70
74
|
end
|
|
71
75
|
end
|
|
72
76
|
|
|
77
|
+
def storage
|
|
78
|
+
@storage
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def storage=(value)
|
|
82
|
+
@storage = value
|
|
83
|
+
@storage_backend = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
def storage_backend
|
|
74
87
|
@storage_backend ||= build_storage_backend
|
|
75
88
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/profile"
|
|
4
|
+
require_relative "current_context"
|
|
5
|
+
require_relative "collectors/console_collector"
|
|
6
|
+
require_relative "collectors/database_collector"
|
|
7
|
+
require_relative "collectors/cache_collector"
|
|
8
|
+
require_relative "collectors/http_collector"
|
|
9
|
+
require_relative "collectors/dump_collector"
|
|
10
|
+
require_relative "collectors/log_collector"
|
|
11
|
+
require_relative "collectors/exception_collector"
|
|
12
|
+
require_relative "collectors/env_collector"
|
|
13
|
+
require_relative "collectors/flamegraph_collector"
|
|
14
|
+
|
|
15
|
+
module Profiler
|
|
16
|
+
class ConsoleProfiler
|
|
17
|
+
CONSOLE_COLLECTOR_CLASSES = [
|
|
18
|
+
Collectors::DatabaseCollector,
|
|
19
|
+
Collectors::CacheCollector,
|
|
20
|
+
Collectors::HttpCollector,
|
|
21
|
+
Collectors::DumpCollector,
|
|
22
|
+
Collectors::LogCollector,
|
|
23
|
+
Collectors::ExceptionCollector,
|
|
24
|
+
Collectors::EnvCollector,
|
|
25
|
+
Collectors::FlameGraphCollector
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def self.profile(expression:, &block)
|
|
29
|
+
return block.call unless Profiler.enabled? && Profiler.configuration.track_console
|
|
30
|
+
|
|
31
|
+
new(expression: expression).run(&block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(expression:)
|
|
35
|
+
@expression = expression
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(&block)
|
|
39
|
+
profile = Models::Profile.new
|
|
40
|
+
profile.profile_type = "console"
|
|
41
|
+
profile.gem_version = Profiler::VERSION
|
|
42
|
+
profile.path = @expression.length > 200 ? "#{@expression[0, 200]}..." : @expression
|
|
43
|
+
profile.method = "CONSOLE"
|
|
44
|
+
|
|
45
|
+
console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
|
|
46
|
+
collectors = [console_collector] + CONSOLE_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
47
|
+
collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
|
|
48
|
+
|
|
49
|
+
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
50
|
+
|
|
51
|
+
memory_before = current_memory if Profiler.configuration.track_memory
|
|
52
|
+
|
|
53
|
+
console_status = "completed"
|
|
54
|
+
|
|
55
|
+
previous_token = Profiler::CurrentContext.token
|
|
56
|
+
Profiler::CurrentContext.token = profile.token
|
|
57
|
+
result = nil
|
|
58
|
+
begin
|
|
59
|
+
result = block.call
|
|
60
|
+
console_collector.set_return_value(result)
|
|
61
|
+
result
|
|
62
|
+
rescue => e
|
|
63
|
+
console_status = "failed"
|
|
64
|
+
exception_collector&.capture(e)
|
|
65
|
+
raise
|
|
66
|
+
ensure
|
|
67
|
+
Profiler::CurrentContext.token = previous_token
|
|
68
|
+
if Profiler.configuration.track_memory
|
|
69
|
+
profile.memory = current_memory - memory_before
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
profile.finish(console_status == "completed" ? 200 : 500)
|
|
73
|
+
|
|
74
|
+
collectors.each do |collector|
|
|
75
|
+
begin
|
|
76
|
+
collector.collect if collector.respond_to?(:collect)
|
|
77
|
+
profile.add_collector_metadata(collector)
|
|
78
|
+
rescue => e
|
|
79
|
+
warn "Profiler ConsoleProfiler: Collector #{collector.class} failed: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Profiler.storage.save(profile.token, profile)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def current_memory
|
|
90
|
+
return 0 unless defined?(GC.stat)
|
|
91
|
+
|
|
92
|
+
stats = GC.stat
|
|
93
|
+
if stats.key?(:total_allocated_size)
|
|
94
|
+
stats[:total_allocated_size]
|
|
95
|
+
elsif stats.key?(:total_allocated_objects)
|
|
96
|
+
stats[:total_allocated_objects] * 40
|
|
97
|
+
else
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module IrbInstrumentation
|
|
6
|
+
IRB_SKIP_COMMANDS = %w[exit quit exit! irb help].freeze
|
|
7
|
+
|
|
8
|
+
def evaluate(line, line_no, *args)
|
|
9
|
+
Profiler.env_override_store.apply!
|
|
10
|
+
stripped = line.to_s.strip
|
|
11
|
+
skip = !Profiler.enabled? ||
|
|
12
|
+
!Profiler.configuration.track_console ||
|
|
13
|
+
stripped.empty? ||
|
|
14
|
+
IRB_SKIP_COMMANDS.include?(stripped)
|
|
15
|
+
return super if skip
|
|
16
|
+
|
|
17
|
+
Profiler::ConsoleProfiler.profile(expression: stripped) { super }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
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,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
|