rails-profiler 0.1.4
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 +7 -0
- data/app/assets/builds/profiler-toolbar.js +1191 -0
- data/app/assets/builds/profiler.css +2668 -0
- data/app/assets/builds/profiler.js +2772 -0
- data/app/controllers/profiler/api/ajax_controller.rb +36 -0
- data/app/controllers/profiler/api/jobs_controller.rb +39 -0
- data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
- data/app/controllers/profiler/api/profiles_controller.rb +60 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
- data/app/controllers/profiler/application_controller.rb +19 -0
- data/app/controllers/profiler/assets_controller.rb +29 -0
- data/app/controllers/profiler/profiles_controller.rb +107 -0
- data/app/views/layouts/profiler/application.html.erb +16 -0
- data/app/views/layouts/profiler/embedded.html.erb +34 -0
- data/app/views/profiler/profiles/index.html.erb +1 -0
- data/app/views/profiler/profiles/show.html.erb +4 -0
- data/config/routes.rb +36 -0
- data/exe/profiler-mcp +8 -0
- data/lib/profiler/collectors/ajax_collector.rb +109 -0
- data/lib/profiler/collectors/base_collector.rb +92 -0
- data/lib/profiler/collectors/cache_collector.rb +96 -0
- data/lib/profiler/collectors/database_collector.rb +113 -0
- data/lib/profiler/collectors/dump_collector.rb +98 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
- data/lib/profiler/collectors/http_collector.rb +112 -0
- data/lib/profiler/collectors/job_collector.rb +50 -0
- data/lib/profiler/collectors/performance_collector.rb +103 -0
- data/lib/profiler/collectors/request_collector.rb +80 -0
- data/lib/profiler/collectors/view_collector.rb +79 -0
- data/lib/profiler/configuration.rb +81 -0
- data/lib/profiler/engine.rb +17 -0
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
- data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
- data/lib/profiler/job_profiler.rb +118 -0
- data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
- data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
- data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
- data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
- data/lib/profiler/mcp/server.rb +217 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
- data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
- data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
- data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
- data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
- data/lib/profiler/middleware/cors_middleware.rb +55 -0
- data/lib/profiler/middleware/profiler_middleware.rb +151 -0
- data/lib/profiler/middleware/toolbar_injector.rb +378 -0
- data/lib/profiler/models/profile.rb +182 -0
- data/lib/profiler/models/sql_query.rb +48 -0
- data/lib/profiler/models/timeline_event.rb +40 -0
- data/lib/profiler/railtie.rb +75 -0
- data/lib/profiler/storage/base_store.rb +41 -0
- data/lib/profiler/storage/blob_store.rb +46 -0
- data/lib/profiler/storage/file_store.rb +119 -0
- data/lib/profiler/storage/memory_store.rb +94 -0
- data/lib/profiler/storage/redis_store.rb +98 -0
- data/lib/profiler/storage/sqlite_store.rb +272 -0
- data/lib/profiler/tasks/profiler.rake +79 -0
- data/lib/profiler/version.rb +5 -0
- data/lib/profiler.rb +68 -0
- metadata +194 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module MCP
|
|
8
|
+
module Tools
|
|
9
|
+
class GetProfileDetail
|
|
10
|
+
def self.call(params)
|
|
11
|
+
token = params["token"]
|
|
12
|
+
unless token
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
type: "text",
|
|
16
|
+
text: "Error: token parameter is required"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
profile = Profiler.storage.load(token)
|
|
22
|
+
unless profile
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
type: "text",
|
|
26
|
+
text: "Profile not found: #{token}"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
text = format_profile_detail(profile)
|
|
32
|
+
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
type: "text",
|
|
36
|
+
text: text
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def self.format_profile_detail(profile)
|
|
44
|
+
lines = []
|
|
45
|
+
lines << "# Profile Details: #{profile.token}\n"
|
|
46
|
+
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
47
|
+
lines << "**Status:** #{profile.status}"
|
|
48
|
+
lines << "**Duration:** #{profile.duration.round(2)} ms"
|
|
49
|
+
lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
|
|
50
|
+
lines << "**Time:** #{profile.started_at}\n"
|
|
51
|
+
|
|
52
|
+
# Job section
|
|
53
|
+
job_data = profile.collector_data("job")
|
|
54
|
+
if job_data && job_data["job_class"]
|
|
55
|
+
lines << "## Job"
|
|
56
|
+
lines << "- Class: #{job_data['job_class']}"
|
|
57
|
+
lines << "- Job ID: #{job_data['job_id']}"
|
|
58
|
+
lines << "- Queue: #{job_data['queue']}"
|
|
59
|
+
lines << "- Executions: #{job_data['executions']}"
|
|
60
|
+
lines << "- Status: #{job_data['status']}"
|
|
61
|
+
lines << "- Error: #{job_data['error']}" if job_data['error']
|
|
62
|
+
if job_data['arguments'] && !job_data['arguments'].empty?
|
|
63
|
+
lines << "- Arguments: #{job_data['arguments'].map(&:to_s).join(', ')}"
|
|
64
|
+
end
|
|
65
|
+
lines << ""
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Request section
|
|
69
|
+
req_data = profile.collector_data("request")
|
|
70
|
+
if req_data
|
|
71
|
+
params = req_data["params"]
|
|
72
|
+
headers = req_data["headers"]
|
|
73
|
+
|
|
74
|
+
if params && !params.empty?
|
|
75
|
+
lines << "## Request Params"
|
|
76
|
+
params.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
77
|
+
lines << ""
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if headers && !headers.empty?
|
|
81
|
+
lines << "## Request Headers"
|
|
82
|
+
headers.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
83
|
+
lines << ""
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
req_body = req_data["request_body"]
|
|
87
|
+
if req_body && !req_body.empty?
|
|
88
|
+
enc = req_data["request_body_encoding"]
|
|
89
|
+
lines << "## Request Body"
|
|
90
|
+
if enc == "base64"
|
|
91
|
+
lines << "_[binary, base64-encoded]_"
|
|
92
|
+
else
|
|
93
|
+
lines << "```"
|
|
94
|
+
lines << req_body
|
|
95
|
+
lines << "```"
|
|
96
|
+
end
|
|
97
|
+
lines << ""
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Response Headers section
|
|
102
|
+
if profile.response_headers&.any?
|
|
103
|
+
lines << "## Response Headers"
|
|
104
|
+
profile.response_headers.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
105
|
+
lines << ""
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Response Body section
|
|
109
|
+
resp_body = profile.response_body
|
|
110
|
+
if resp_body && !resp_body.empty?
|
|
111
|
+
enc = profile.response_body_encoding
|
|
112
|
+
lines << "## Response Body"
|
|
113
|
+
if enc == "base64"
|
|
114
|
+
lines << "_[binary, base64-encoded]_"
|
|
115
|
+
else
|
|
116
|
+
lines << "```"
|
|
117
|
+
lines << resp_body
|
|
118
|
+
lines << "```"
|
|
119
|
+
end
|
|
120
|
+
lines << ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Curl command
|
|
124
|
+
req_data_for_curl = profile.collector_data("request")
|
|
125
|
+
lines << "## Curl Command"
|
|
126
|
+
lines << "```bash"
|
|
127
|
+
lines << generate_curl(profile, req_data_for_curl)
|
|
128
|
+
lines << "```"
|
|
129
|
+
lines << ""
|
|
130
|
+
|
|
131
|
+
# Database section
|
|
132
|
+
db_data = profile.collector_data("database")
|
|
133
|
+
if db_data && db_data["total_queries"]
|
|
134
|
+
lines << "## Database"
|
|
135
|
+
lines << "- Total Queries: #{db_data['total_queries']}"
|
|
136
|
+
lines << "- Total Duration: #{db_data['total_duration'].round(2)} ms"
|
|
137
|
+
lines << "- Slow Queries: #{db_data['slow_queries']}"
|
|
138
|
+
lines << "- Cached Queries: #{db_data['cached_queries']}\n"
|
|
139
|
+
|
|
140
|
+
if db_data["queries"] && !db_data["queries"].empty?
|
|
141
|
+
lines << "### Query Details"
|
|
142
|
+
db_data["queries"].each_with_index do |query, index|
|
|
143
|
+
lines << "\n**Query #{index + 1}** (#{query['duration'].round(2)}ms):"
|
|
144
|
+
lines << "```sql"
|
|
145
|
+
lines << query['sql']
|
|
146
|
+
lines << "```"
|
|
147
|
+
if query['backtrace'] && !query['backtrace'].empty?
|
|
148
|
+
lines << "_Backtrace:_"
|
|
149
|
+
query['backtrace'].first(3).each { |frame| lines << " #{frame}" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
lines << ""
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Performance section
|
|
157
|
+
perf_data = profile.collector_data("performance")
|
|
158
|
+
if perf_data && perf_data["total_events"]
|
|
159
|
+
lines << "## Performance Timeline"
|
|
160
|
+
lines << "- Total Events: #{perf_data['total_events']}"
|
|
161
|
+
lines << "- Total Duration: #{perf_data['total_duration'].round(2)} ms\n"
|
|
162
|
+
|
|
163
|
+
if perf_data["events"] && !perf_data["events"].empty?
|
|
164
|
+
lines << "### Events"
|
|
165
|
+
perf_data["events"].each do |event|
|
|
166
|
+
lines << "- **#{event['name']}**: #{event['duration'].round(2)} ms"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
lines << ""
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Views section
|
|
173
|
+
view_data = profile.collector_data("view")
|
|
174
|
+
if view_data && (view_data["total_views"] || view_data["total_partials"])
|
|
175
|
+
lines << "## View Rendering"
|
|
176
|
+
lines << "- Templates: #{view_data['total_views']}"
|
|
177
|
+
lines << "- Partials: #{view_data['total_partials']}"
|
|
178
|
+
lines << "- Total Duration: #{view_data['total_duration'].round(2)} ms\n"
|
|
179
|
+
|
|
180
|
+
if view_data["views"] && !view_data["views"].empty?
|
|
181
|
+
lines << "### Templates"
|
|
182
|
+
view_data["views"].each do |view|
|
|
183
|
+
lines << "- `#{view['identifier']}` — #{view['duration'].round(2)} ms"
|
|
184
|
+
end
|
|
185
|
+
lines << ""
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if view_data["partials"] && !view_data["partials"].empty?
|
|
189
|
+
lines << "### Partials"
|
|
190
|
+
view_data["partials"].each do |partial|
|
|
191
|
+
lines << "- `#{partial['identifier']}` — #{partial['duration'].round(2)} ms"
|
|
192
|
+
end
|
|
193
|
+
lines << ""
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Cache section
|
|
198
|
+
cache_data = profile.collector_data("cache")
|
|
199
|
+
if cache_data && cache_data["total_reads"]
|
|
200
|
+
lines << "## Cache"
|
|
201
|
+
lines << "- Reads: #{cache_data['total_reads']}"
|
|
202
|
+
lines << "- Writes: #{cache_data['total_writes']}"
|
|
203
|
+
lines << "- Deletes: #{cache_data['total_deletes']}"
|
|
204
|
+
lines << "- Hit Rate: #{cache_data['hit_rate']}%\n"
|
|
205
|
+
|
|
206
|
+
if cache_data["reads"] && !cache_data["reads"].empty?
|
|
207
|
+
lines << "### Cache Reads"
|
|
208
|
+
cache_data["reads"].each do |op|
|
|
209
|
+
hit_label = op['hit'] ? "HIT" : "MISS"
|
|
210
|
+
lines << "- [#{hit_label}] `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
211
|
+
end
|
|
212
|
+
lines << ""
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
if cache_data["writes"] && !cache_data["writes"].empty?
|
|
216
|
+
lines << "### Cache Writes"
|
|
217
|
+
cache_data["writes"].each do |op|
|
|
218
|
+
lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
219
|
+
end
|
|
220
|
+
lines << ""
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if cache_data["deletes"] && !cache_data["deletes"].empty?
|
|
224
|
+
lines << "### Cache Deletes"
|
|
225
|
+
cache_data["deletes"].each do |op|
|
|
226
|
+
lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
227
|
+
end
|
|
228
|
+
lines << ""
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Ajax section
|
|
233
|
+
ajax_data = profile.collector_data("ajax")
|
|
234
|
+
if ajax_data && ajax_data["total_requests"].to_i > 0
|
|
235
|
+
lines << "## AJAX Requests"
|
|
236
|
+
lines << "- Total: #{ajax_data['total_requests']}"
|
|
237
|
+
lines << "- Total Duration: #{ajax_data['total_duration'].round(2)} ms\n"
|
|
238
|
+
|
|
239
|
+
if ajax_data["requests"] && !ajax_data["requests"].empty?
|
|
240
|
+
lines << "### Request List"
|
|
241
|
+
ajax_data["requests"].each do |req|
|
|
242
|
+
lines << "- **#{req['method']} #{req['path']}** — #{req['status']} — #{req['duration'].round(2)} ms (token: #{req['token']})"
|
|
243
|
+
end
|
|
244
|
+
lines << ""
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# HTTP section
|
|
249
|
+
http_data = profile.collector_data("http")
|
|
250
|
+
if http_data && http_data["total_requests"].to_i > 0
|
|
251
|
+
threshold = Profiler.configuration.slow_http_threshold
|
|
252
|
+
lines << "## Outbound HTTP"
|
|
253
|
+
lines << "- Total: #{http_data['total_requests']}"
|
|
254
|
+
lines << "- Total Duration: #{http_data['total_duration'].round(2)} ms"
|
|
255
|
+
lines << "- Slow (>#{threshold}ms): #{http_data['slow_requests']}"
|
|
256
|
+
lines << "- Errors: #{http_data['error_requests']}\n"
|
|
257
|
+
|
|
258
|
+
if http_data["requests"] && !http_data["requests"].empty?
|
|
259
|
+
lines << "### Request List"
|
|
260
|
+
http_data["requests"].each do |req|
|
|
261
|
+
flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
262
|
+
err = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
263
|
+
lines << "- **#{req['method']} #{req['url']}** — #{req['status'] == 0 ? 'error' : req['status']} — #{req['duration'].round(2)} ms#{flag}#{err}"
|
|
264
|
+
end
|
|
265
|
+
lines << ""
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Dumps section
|
|
270
|
+
dump_data = profile.collector_data("dump")
|
|
271
|
+
if dump_data && dump_data["count"].to_i > 0
|
|
272
|
+
lines << "## Variable Dumps"
|
|
273
|
+
lines << "- Count: #{dump_data['count']}\n"
|
|
274
|
+
|
|
275
|
+
dump_data["dumps"]&.each_with_index do |dump, index|
|
|
276
|
+
label = dump['label'] || "Dump #{index + 1}"
|
|
277
|
+
location = [dump['file'], dump['line']].compact.join(':')
|
|
278
|
+
lines << "### #{label}"
|
|
279
|
+
lines << "_Source: #{location}_" unless location.empty?
|
|
280
|
+
lines << "```"
|
|
281
|
+
lines << (dump['formatted'] || dump['value'].inspect)
|
|
282
|
+
lines << "```"
|
|
283
|
+
end
|
|
284
|
+
lines << ""
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
lines.join("\n")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def self.generate_curl(profile, req_data)
|
|
291
|
+
headers = req_data&.dig("headers") || {}
|
|
292
|
+
params = req_data&.dig("params") || {}
|
|
293
|
+
req_body = req_data&.dig("request_body")
|
|
294
|
+
|
|
295
|
+
parts = ["curl -X #{profile.method}"]
|
|
296
|
+
|
|
297
|
+
headers.reject { |k, _| k == "User-Agent" }.each do |k, v|
|
|
298
|
+
parts << " -H #{Shellwords.shellescape("#{k}: #{v}")}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
if %w[POST PUT PATCH].include?(profile.method)
|
|
302
|
+
if req_body && !req_body.empty?
|
|
303
|
+
parts << " -d #{Shellwords.shellescape(req_body)}"
|
|
304
|
+
elsif !params.empty?
|
|
305
|
+
ct = headers["Content-Type"].to_s
|
|
306
|
+
if ct.include?("application/json")
|
|
307
|
+
parts << " -d #{Shellwords.shellescape(params.to_json)}"
|
|
308
|
+
else
|
|
309
|
+
params.each { |k, v| parts << " --data-urlencode #{Shellwords.shellescape("#{k}=#{v}")}" }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
url = "http://localhost:3000#{profile.path}"
|
|
315
|
+
if profile.method == "GET" && !params.empty?
|
|
316
|
+
qs = params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
317
|
+
url += "?#{qs}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
parts << " #{Shellwords.shellescape(url)}"
|
|
321
|
+
parts.join(" \\\n")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetProfileDumps
|
|
7
|
+
def self.call(params)
|
|
8
|
+
token = params["token"]
|
|
9
|
+
unless token
|
|
10
|
+
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
profile = Profiler.storage.load(token)
|
|
14
|
+
unless profile
|
|
15
|
+
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
dump_data = profile.collector_data("dump")
|
|
19
|
+
unless dump_data && dump_data["count"].to_i > 0
|
|
20
|
+
return [{ type: "text", text: "No variable dumps found in this profile" }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
[{ type: "text", text: format_dumps(profile, dump_data) }]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def self.format_dumps(profile, dump_data)
|
|
29
|
+
lines = []
|
|
30
|
+
lines << "# Variable Dumps: #{profile.token}\n"
|
|
31
|
+
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
32
|
+
lines << "**Total Dumps:** #{dump_data['count']}\n"
|
|
33
|
+
|
|
34
|
+
dump_data["dumps"]&.each_with_index do |dump, index|
|
|
35
|
+
label = dump['label'] || "Dump #{index + 1}"
|
|
36
|
+
location = [dump['file'], dump['line']].compact.join(':')
|
|
37
|
+
|
|
38
|
+
lines << "## #{label}"
|
|
39
|
+
lines << "- **Source:** #{location}" unless location.empty?
|
|
40
|
+
lines << "- **Timestamp:** #{dump['timestamp']}" if dump['timestamp']
|
|
41
|
+
lines << "\n```"
|
|
42
|
+
lines << (dump['formatted'] || dump['value'].inspect)
|
|
43
|
+
lines << "```\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
lines.join("\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetProfileHttp
|
|
7
|
+
def self.call(params)
|
|
8
|
+
token = params["token"]
|
|
9
|
+
unless token
|
|
10
|
+
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
profile = Profiler.storage.load(token)
|
|
14
|
+
unless profile
|
|
15
|
+
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
http_data = profile.collector_data("http")
|
|
19
|
+
unless http_data && http_data["total_requests"].to_i > 0
|
|
20
|
+
return [{ type: "text", text: "No outbound HTTP requests found in this profile" }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
[{ type: "text", text: format_http(profile, http_data) }]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def self.format_http(profile, http_data)
|
|
29
|
+
threshold = Profiler.configuration.slow_http_threshold
|
|
30
|
+
lines = []
|
|
31
|
+
lines << "# Outbound HTTP Analysis: #{profile.token}\n"
|
|
32
|
+
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
33
|
+
lines << "**Total Outbound Requests:** #{http_data['total_requests']}"
|
|
34
|
+
lines << "**Total Duration:** #{http_data['total_duration'].round(2)} ms"
|
|
35
|
+
lines << "**Slow Requests (>#{threshold}ms):** #{http_data['slow_requests']}"
|
|
36
|
+
lines << "**Error Requests:** #{http_data['error_requests']}\n"
|
|
37
|
+
|
|
38
|
+
if http_data["by_host"] && !http_data["by_host"].empty?
|
|
39
|
+
lines << "## By Host"
|
|
40
|
+
http_data["by_host"].each { |host, count| lines << "- **#{host}**: #{count}" }
|
|
41
|
+
lines << ""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if http_data["by_status"] && !http_data["by_status"].empty?
|
|
45
|
+
lines << "## By Status"
|
|
46
|
+
http_data["by_status"].each { |status, count| lines << "- **#{status}**: #{count}" }
|
|
47
|
+
lines << ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if http_data["requests"] && !http_data["requests"].empty?
|
|
51
|
+
lines << "## Request Details"
|
|
52
|
+
http_data["requests"].each_with_index do |req, i|
|
|
53
|
+
slow_flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
54
|
+
err_flag = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
55
|
+
lines << "\n### Request #{i + 1}#{slow_flag}#{err_flag}"
|
|
56
|
+
lines << "- **Method:** #{req['method']}"
|
|
57
|
+
lines << "- **URL:** #{req['url']}"
|
|
58
|
+
lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
|
|
59
|
+
lines << "- **Duration:** #{req['duration'].round(2)} ms"
|
|
60
|
+
lines << "- **Request Size:** #{req['request_size']} bytes"
|
|
61
|
+
lines << "- **Response Size:** #{req['response_size']} bytes"
|
|
62
|
+
lines << "- **Error:** #{req['error']}" if req["error"]
|
|
63
|
+
if req["request_headers"] && !req["request_headers"].empty?
|
|
64
|
+
lines << "- **Request Headers:**"
|
|
65
|
+
req["request_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
66
|
+
end
|
|
67
|
+
if req["request_body"] && !req["request_body"].empty?
|
|
68
|
+
lines << "- **Request Body:**"
|
|
69
|
+
if req["request_body_encoding"] == "base64"
|
|
70
|
+
lines << " *(binary content, base64 encoded — #{req['request_body'].bytesize} chars)*"
|
|
71
|
+
else
|
|
72
|
+
lines << " ```"
|
|
73
|
+
lines << " #{req['request_body'].lines.first(5).join(' ')}"
|
|
74
|
+
lines << " ```"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
if req["response_headers"] && !req["response_headers"].empty?
|
|
78
|
+
lines << "- **Response Headers:**"
|
|
79
|
+
req["response_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
80
|
+
end
|
|
81
|
+
if req["response_body"] && !req["response_body"].empty?
|
|
82
|
+
lines << "- **Response Body:**"
|
|
83
|
+
if req["response_body_encoding"] == "base64"
|
|
84
|
+
lines << " *(binary content, base64 encoded — #{req['response_body'].bytesize} chars)*"
|
|
85
|
+
else
|
|
86
|
+
lines << " ```"
|
|
87
|
+
lines << " #{req['response_body'].lines.first(10).join(' ')}"
|
|
88
|
+
lines << " ```"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
if req["backtrace"] && !req["backtrace"].empty?
|
|
92
|
+
lines << "- **Called from:**"
|
|
93
|
+
req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
lines << ""
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
lines.join("\n")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryJobs
|
|
7
|
+
def self.call(params)
|
|
8
|
+
limit = params["limit"]&.to_i || 20
|
|
9
|
+
profiles = Profiler.storage.list(limit: [limit * 5, 200].min)
|
|
10
|
+
|
|
11
|
+
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
12
|
+
|
|
13
|
+
if params["queue"]
|
|
14
|
+
jobs = jobs.select do |p|
|
|
15
|
+
job_data = p.collector_data("job")
|
|
16
|
+
job_data && job_data["queue"] == params["queue"]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if params["status"]
|
|
21
|
+
jobs = jobs.select do |p|
|
|
22
|
+
job_data = p.collector_data("job")
|
|
23
|
+
job_data && job_data["status"] == params["status"]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
jobs = jobs.first(limit)
|
|
28
|
+
|
|
29
|
+
text = format_jobs_table(jobs)
|
|
30
|
+
|
|
31
|
+
[{ type: "text", text: text }]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def self.format_jobs_table(jobs)
|
|
37
|
+
if jobs.empty?
|
|
38
|
+
return "No job profiles found matching the criteria."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
lines = []
|
|
42
|
+
lines << "# Background Job Profiles\n"
|
|
43
|
+
lines << "Found #{jobs.size} jobs:\n"
|
|
44
|
+
lines << "| Time | Job Class | Queue | Status | Duration | Token |"
|
|
45
|
+
lines << "|------|-----------|-------|--------|----------|-------|"
|
|
46
|
+
|
|
47
|
+
jobs.each do |profile|
|
|
48
|
+
job_data = profile.collector_data("job") || {}
|
|
49
|
+
job_class = job_data["job_class"] || profile.path
|
|
50
|
+
queue = job_data["queue"] || "-"
|
|
51
|
+
status = job_data["status"] || "-"
|
|
52
|
+
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{job_class} | #{queue} | #{status} | #{profile.duration.round(2)}ms | #{profile.token} |"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines.join("\n")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryProfiles
|
|
7
|
+
def self.call(params)
|
|
8
|
+
limit = params["limit"]&.to_i || 20
|
|
9
|
+
profiles = Profiler.storage.list(limit: limit)
|
|
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
|
|
19
|
+
|
|
20
|
+
if params["min_duration"]
|
|
21
|
+
min_dur = params["min_duration"].to_f
|
|
22
|
+
profiles = profiles.select { |p| p.duration && p.duration >= min_dur }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if params["profile_type"]
|
|
26
|
+
profiles = profiles.select { |p| p.profile_type == params["profile_type"] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Format as markdown table
|
|
30
|
+
text = format_profiles_table(profiles)
|
|
31
|
+
|
|
32
|
+
[
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: text
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def self.format_profiles_table(profiles)
|
|
43
|
+
if profiles.empty?
|
|
44
|
+
return "No profiles found matching the criteria."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
lines = []
|
|
48
|
+
lines << "# Profiled Requests\n"
|
|
49
|
+
lines << "Found #{profiles.size} profiles:\n"
|
|
50
|
+
lines << "| Time | Type | Method | Path | Duration | Queries | Status | Token |"
|
|
51
|
+
lines << "|------|------|--------|------|----------|---------|--------|-------|"
|
|
52
|
+
|
|
53
|
+
profiles.each do |profile|
|
|
54
|
+
db_data = profile.collector_data("database")
|
|
55
|
+
query_count = db_data ? db_data["total_queries"] : 0
|
|
56
|
+
type = profile.profile_type || "http"
|
|
57
|
+
|
|
58
|
+
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Middleware
|
|
5
|
+
class CorsMiddleware
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
# Apply CORS and frame options to all profiler endpoints
|
|
12
|
+
if env['PATH_INFO'].start_with?('/_profiler/')
|
|
13
|
+
# Handle OPTIONS preflight request
|
|
14
|
+
if env['REQUEST_METHOD'] == 'OPTIONS'
|
|
15
|
+
return [
|
|
16
|
+
200,
|
|
17
|
+
cors_headers,
|
|
18
|
+
['']
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
status, headers, body = @app.call(env)
|
|
23
|
+
|
|
24
|
+
# Add CORS headers
|
|
25
|
+
cors_headers.each do |key, value|
|
|
26
|
+
headers[key] = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Allow embedding in iframes from Chrome extensions
|
|
30
|
+
headers.delete('X-Frame-Options')
|
|
31
|
+
# Don't set CSP if controller requested to skip it
|
|
32
|
+
unless env['profiler.skip_csp']
|
|
33
|
+
# Allow embedding from standard web origins
|
|
34
|
+
headers['Content-Security-Policy'] = "frame-ancestors 'self' http: https:"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
[status, headers, body]
|
|
38
|
+
else
|
|
39
|
+
@app.call(env)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def cors_headers
|
|
46
|
+
{
|
|
47
|
+
'Access-Control-Allow-Origin' => '*',
|
|
48
|
+
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
|
49
|
+
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With, Accept',
|
|
50
|
+
'Access-Control-Expose-Headers' => 'X-Profiler-Token'
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|