rails-profiler 0.4.0 → 0.6.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/lib/profiler/instrumentation/active_job_instrumentation.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +7 -0
- data/lib/profiler/mcp/body_formatter.rb +50 -0
- data/lib/profiler/mcp/file_cache.rb +31 -0
- data/lib/profiler/mcp/path_extractor.rb +30 -0
- data/lib/profiler/mcp/server.rb +30 -12
- data/lib/profiler/mcp/tools/analyze_queries.rb +46 -45
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +276 -194
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +69 -21
- data/lib/profiler/mcp/tools/query_jobs.rb +37 -14
- data/lib/profiler/mcp/tools/query_profiles.rb +42 -27
- data/lib/profiler/models/profile.rb +8 -0
- data/lib/profiler/version.rb +1 -1
- metadata +5 -2
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
3
6
|
module Profiler
|
|
4
7
|
module MCP
|
|
5
8
|
module Tools
|
|
@@ -10,7 +13,11 @@ module Profiler
|
|
|
10
13
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
14
|
end
|
|
12
15
|
|
|
13
|
-
profile =
|
|
16
|
+
profile = if token == "latest"
|
|
17
|
+
Profiler.storage.list(limit: 1).first
|
|
18
|
+
else
|
|
19
|
+
Profiler.storage.load(token)
|
|
20
|
+
end
|
|
14
21
|
unless profile
|
|
15
22
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
23
|
end
|
|
@@ -20,39 +27,57 @@ module Profiler
|
|
|
20
27
|
return [{ type: "text", text: "No outbound HTTP requests found in this profile" }]
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
domain_filter = params["domain"]
|
|
31
|
+
[{ type: "text", text: format_http(profile, http_data, domain_filter, params) }]
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
27
35
|
|
|
28
|
-
def self.format_http(profile, http_data)
|
|
36
|
+
def self.format_http(profile, http_data, domain_filter, params)
|
|
29
37
|
threshold = Profiler.configuration.slow_http_threshold
|
|
38
|
+
requests = http_data["requests"] || []
|
|
39
|
+
|
|
40
|
+
if domain_filter && !domain_filter.empty?
|
|
41
|
+
requests = requests.select do |req|
|
|
42
|
+
host = begin
|
|
43
|
+
URI.parse(req["url"]).host.to_s
|
|
44
|
+
rescue URI::InvalidURIError
|
|
45
|
+
""
|
|
46
|
+
end
|
|
47
|
+
host.include?(domain_filter)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
30
51
|
lines = []
|
|
31
52
|
lines << "# Outbound HTTP Analysis: #{profile.token}\n"
|
|
32
53
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
54
|
+
lines << "**Started at:** #{profile.started_at&.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
33
55
|
lines << "**Total Outbound Requests:** #{http_data['total_requests']}"
|
|
56
|
+
lines << "**Showing:** #{requests.size} request(s)#{domain_filter ? " (filtered by domain: #{domain_filter})" : ""}"
|
|
34
57
|
lines << "**Total Duration:** #{http_data['total_duration'].round(2)} ms"
|
|
35
58
|
lines << "**Slow Requests (>#{threshold}ms):** #{http_data['slow_requests']}"
|
|
36
59
|
lines << "**Error Requests:** #{http_data['error_requests']}\n"
|
|
37
60
|
|
|
38
|
-
if http_data["by_host"] && !http_data["by_host"].empty?
|
|
61
|
+
if http_data["by_host"] && !http_data["by_host"].empty? && !domain_filter
|
|
39
62
|
lines << "## By Host"
|
|
40
63
|
http_data["by_host"].each { |host, count| lines << "- **#{host}**: #{count}" }
|
|
41
64
|
lines << ""
|
|
42
65
|
end
|
|
43
66
|
|
|
44
|
-
if http_data["by_status"] && !http_data["by_status"].empty?
|
|
67
|
+
if http_data["by_status"] && !http_data["by_status"].empty? && !domain_filter
|
|
45
68
|
lines << "## By Status"
|
|
46
69
|
http_data["by_status"].each { |status, count| lines << "- **#{status}**: #{count}" }
|
|
47
70
|
lines << ""
|
|
48
71
|
end
|
|
49
72
|
|
|
50
|
-
if
|
|
73
|
+
if requests && !requests.empty?
|
|
51
74
|
lines << "## Request Details"
|
|
52
|
-
|
|
75
|
+
requests.each_with_index do |req, i|
|
|
53
76
|
slow_flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
54
77
|
err_flag = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
55
78
|
lines << "\n### Request #{i + 1}#{slow_flag}#{err_flag}"
|
|
79
|
+
lines << "- **ID:** #{req['id']}" if req["id"]
|
|
80
|
+
lines << "- **Started at:** #{req['started_at']}" if req["started_at"]
|
|
56
81
|
lines << "- **Method:** #{req['method']}"
|
|
57
82
|
lines << "- **URL:** #{req['url']}"
|
|
58
83
|
lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
|
|
@@ -60,44 +85,67 @@ module Profiler
|
|
|
60
85
|
lines << "- **Request Size:** #{req['request_size']} bytes"
|
|
61
86
|
lines << "- **Response Size:** #{req['response_size']} bytes"
|
|
62
87
|
lines << "- **Error:** #{req['error']}" if req["error"]
|
|
88
|
+
|
|
63
89
|
if req["request_headers"] && !req["request_headers"].empty?
|
|
64
90
|
lines << "- **Request Headers:**"
|
|
65
91
|
req["request_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
66
92
|
end
|
|
93
|
+
|
|
67
94
|
if req["request_body"] && !req["request_body"].empty?
|
|
68
95
|
lines << "- **Request Body:**"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
formatted = BodyFormatter.format_body(
|
|
97
|
+
profile.token,
|
|
98
|
+
"http_#{i}_request_body",
|
|
99
|
+
req["request_body"],
|
|
100
|
+
req["request_body_encoding"],
|
|
101
|
+
params
|
|
102
|
+
)
|
|
103
|
+
lines << formatted if formatted
|
|
76
104
|
end
|
|
105
|
+
|
|
77
106
|
if req["response_headers"] && !req["response_headers"].empty?
|
|
78
107
|
lines << "- **Response Headers:**"
|
|
79
108
|
req["response_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
80
109
|
end
|
|
110
|
+
|
|
81
111
|
if req["response_body"] && !req["response_body"].empty?
|
|
82
112
|
lines << "- **Response Body:**"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
formatted = BodyFormatter.format_body(
|
|
114
|
+
profile.token,
|
|
115
|
+
"http_#{i}_response_body",
|
|
116
|
+
req["response_body"],
|
|
117
|
+
req["response_body_encoding"],
|
|
118
|
+
params
|
|
119
|
+
)
|
|
120
|
+
lines << formatted if formatted
|
|
90
121
|
end
|
|
122
|
+
|
|
91
123
|
if req["backtrace"] && !req["backtrace"].empty?
|
|
92
124
|
lines << "- **Called from:**"
|
|
93
125
|
req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
|
|
94
126
|
end
|
|
127
|
+
lines << "- **Curl:**"
|
|
128
|
+
lines << " ```bash"
|
|
129
|
+
lines << " #{generate_curl_for_outbound(req)}"
|
|
130
|
+
lines << " ```"
|
|
95
131
|
end
|
|
96
132
|
lines << ""
|
|
97
133
|
end
|
|
98
134
|
|
|
99
135
|
lines.join("\n")
|
|
100
136
|
end
|
|
137
|
+
|
|
138
|
+
def self.generate_curl_for_outbound(req)
|
|
139
|
+
parts = ["curl -X #{req['method']}"]
|
|
140
|
+
req["request_headers"]
|
|
141
|
+
&.reject { |k, _| k.downcase == "user-agent" }
|
|
142
|
+
&.each { |k, v| parts << " -H #{Shellwords.shellescape("#{k}: #{v}")}" }
|
|
143
|
+
if req["request_body"] && !req["request_body"].empty? && req["request_body_encoding"] != "base64"
|
|
144
|
+
parts << " -d #{Shellwords.shellescape(req['request_body'])}"
|
|
145
|
+
end
|
|
146
|
+
parts << " #{Shellwords.shellescape(req['url'])}"
|
|
147
|
+
parts.join(" \\\n")
|
|
148
|
+
end
|
|
101
149
|
end
|
|
102
150
|
end
|
|
103
151
|
end
|
|
@@ -4,9 +4,12 @@ module Profiler
|
|
|
4
4
|
module MCP
|
|
5
5
|
module Tools
|
|
6
6
|
class QueryJobs
|
|
7
|
+
ALL_FIELDS = %w[time job_class queue status duration token].freeze
|
|
8
|
+
|
|
7
9
|
def self.call(params)
|
|
8
10
|
limit = params["limit"]&.to_i || 20
|
|
9
|
-
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
10
13
|
|
|
11
14
|
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
12
15
|
|
|
@@ -24,32 +27,52 @@ module Profiler
|
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
if params["cursor"]
|
|
31
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
32
|
+
jobs = jobs.select { |p| p.started_at < cutoff } if cutoff
|
|
33
|
+
end
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
jobs = jobs.first(limit)
|
|
36
|
+
fields = params["fields"]&.map(&:to_s)
|
|
30
37
|
|
|
31
|
-
[{ type: "text", text:
|
|
38
|
+
[{ type: "text", text: format_jobs_table(jobs, fields, limit) }]
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
private
|
|
35
42
|
|
|
36
|
-
def self.format_jobs_table(jobs)
|
|
37
|
-
if jobs.empty?
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
def self.format_jobs_table(jobs, fields, limit)
|
|
44
|
+
return "No job profiles found matching the criteria." if jobs.empty?
|
|
45
|
+
|
|
46
|
+
fields ||= ALL_FIELDS
|
|
47
|
+
fields = fields & ALL_FIELDS
|
|
40
48
|
|
|
41
49
|
lines = []
|
|
42
50
|
lines << "# Background Job Profiles\n"
|
|
43
51
|
lines << "Found #{jobs.size} jobs:\n"
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
|
|
53
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
54
|
+
separator = fields.map { |_| "------" }.join("|")
|
|
55
|
+
lines << "| #{header} |"
|
|
56
|
+
lines << "|#{separator}|"
|
|
46
57
|
|
|
47
58
|
jobs.each do |profile|
|
|
48
59
|
job_data = profile.collector_data("job") || {}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
row = fields.map do |f|
|
|
61
|
+
case f
|
|
62
|
+
when "time" then profile.started_at.strftime("%H:%M:%S")
|
|
63
|
+
when "job_class" then job_data["job_class"] || profile.path
|
|
64
|
+
when "queue" then job_data["queue"] || "-"
|
|
65
|
+
when "status" then job_data["status"] || "-"
|
|
66
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
67
|
+
when "token" then profile.token.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
lines << "| #{row.join(' | ')} |"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if jobs.size == limit
|
|
74
|
+
lines << ""
|
|
75
|
+
lines << "*Next cursor: #{jobs.last.started_at.iso8601}*"
|
|
53
76
|
end
|
|
54
77
|
|
|
55
78
|
lines.join("\n")
|
|
@@ -4,58 +4,73 @@ module Profiler
|
|
|
4
4
|
module MCP
|
|
5
5
|
module Tools
|
|
6
6
|
class QueryProfiles
|
|
7
|
+
ALL_FIELDS = %w[time type method path duration queries status token].freeze
|
|
8
|
+
|
|
7
9
|
def self.call(params)
|
|
8
10
|
limit = params["limit"]&.to_i || 20
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# Apply filters
|
|
12
|
-
if params["path"]
|
|
13
|
-
profiles = profiles.select { |p| p.path&.include?(params["path"]) }
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
if params["method"]
|
|
17
|
-
profiles = profiles.select { |p| p.method == params["method"]&.upcase }
|
|
18
|
-
end
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
19
13
|
|
|
14
|
+
profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
|
|
15
|
+
profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
|
|
20
16
|
if params["min_duration"]
|
|
21
17
|
min_dur = params["min_duration"].to_f
|
|
22
18
|
profiles = profiles.select { |p| p.duration && p.duration >= min_dur }
|
|
23
19
|
end
|
|
20
|
+
profiles = profiles.select { |p| p.profile_type == params["profile_type"] } if params["profile_type"]
|
|
24
21
|
|
|
25
|
-
if params["
|
|
26
|
-
|
|
22
|
+
if params["cursor"]
|
|
23
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
24
|
+
profiles = profiles.select { |p| p.started_at < cutoff } if cutoff
|
|
27
25
|
end
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
profiles = profiles.first(limit)
|
|
28
|
+
fields = params["fields"]&.map(&:to_s)
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
type: "text",
|
|
35
|
-
text: text
|
|
36
|
-
}
|
|
37
|
-
]
|
|
30
|
+
text = format_profiles_table(profiles, fields, limit)
|
|
31
|
+
[{ type: "text", text: text }]
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
private
|
|
41
35
|
|
|
42
|
-
def self.format_profiles_table(profiles)
|
|
43
|
-
if profiles.empty?
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
def self.format_profiles_table(profiles, fields, limit)
|
|
37
|
+
return "No profiles found matching the criteria." if profiles.empty?
|
|
38
|
+
|
|
39
|
+
fields ||= ALL_FIELDS
|
|
40
|
+
fields = fields & ALL_FIELDS # only allow valid fields
|
|
46
41
|
|
|
47
42
|
lines = []
|
|
48
43
|
lines << "# Profiled Requests\n"
|
|
49
44
|
lines << "Found #{profiles.size} profiles:\n"
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
|
|
46
|
+
header = fields.map { |f| f.capitalize }.join(" | ")
|
|
47
|
+
separator = fields.map { |_| "------" }.join("|")
|
|
48
|
+
lines << "| #{header} |"
|
|
49
|
+
lines << "|#{separator}|"
|
|
52
50
|
|
|
53
51
|
profiles.each do |profile|
|
|
54
52
|
db_data = profile.collector_data("database")
|
|
55
53
|
query_count = db_data ? db_data["total_queries"] : 0
|
|
56
54
|
type = profile.profile_type || "http"
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
row = fields.map do |f|
|
|
57
|
+
case f
|
|
58
|
+
when "time" then profile.started_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
59
|
+
when "type" then type
|
|
60
|
+
when "method" then profile.method.to_s
|
|
61
|
+
when "path" then profile.path.to_s
|
|
62
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
63
|
+
when "queries" then query_count.to_s
|
|
64
|
+
when "status" then profile.status.to_s
|
|
65
|
+
when "token" then profile.token.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
lines << "| #{row.join(' | ')} |"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if profiles.size == limit
|
|
72
|
+
lines << ""
|
|
73
|
+
lines << "*Next cursor: #{profiles.last.started_at.iso8601}*"
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
lines.join("\n")
|
|
@@ -173,9 +173,17 @@ module Profiler
|
|
|
173
173
|
params.to_h.except("password", "password_confirmation", "token", "secret")
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
ALLOWED_HEADERS = %w[
|
|
177
|
+
Accept Accept-Charset Accept-Encoding Accept-Language
|
|
178
|
+
Authorization Cache-Control Connection Content-Length Content-Type
|
|
179
|
+
Cookie Host If-Modified-Since If-None-Match Origin
|
|
180
|
+
Referer User-Agent
|
|
181
|
+
].freeze
|
|
182
|
+
|
|
176
183
|
def extract_headers(env)
|
|
177
184
|
env.select { |k, _| k.start_with?("HTTP_") }
|
|
178
185
|
.transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
|
|
186
|
+
.select { |k, _| ALLOWED_HEADERS.include?(k) }
|
|
179
187
|
end
|
|
180
188
|
end
|
|
181
189
|
end
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.6.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-04-
|
|
11
|
+
date: 2026-04-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -142,6 +142,9 @@ files:
|
|
|
142
142
|
- lib/profiler/instrumentation/net_http_instrumentation.rb
|
|
143
143
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
144
144
|
- lib/profiler/job_profiler.rb
|
|
145
|
+
- lib/profiler/mcp/body_formatter.rb
|
|
146
|
+
- lib/profiler/mcp/file_cache.rb
|
|
147
|
+
- lib/profiler/mcp/path_extractor.rb
|
|
145
148
|
- lib/profiler/mcp/resources/n1_patterns.rb
|
|
146
149
|
- lib/profiler/mcp/resources/recent_jobs.rb
|
|
147
150
|
- lib/profiler/mcp/resources/recent_requests.rb
|