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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/builds/profiler-toolbar.js +1191 -0
  3. data/app/assets/builds/profiler.css +2668 -0
  4. data/app/assets/builds/profiler.js +2772 -0
  5. data/app/controllers/profiler/api/ajax_controller.rb +36 -0
  6. data/app/controllers/profiler/api/jobs_controller.rb +39 -0
  7. data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
  8. data/app/controllers/profiler/api/profiles_controller.rb +60 -0
  9. data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
  10. data/app/controllers/profiler/application_controller.rb +19 -0
  11. data/app/controllers/profiler/assets_controller.rb +29 -0
  12. data/app/controllers/profiler/profiles_controller.rb +107 -0
  13. data/app/views/layouts/profiler/application.html.erb +16 -0
  14. data/app/views/layouts/profiler/embedded.html.erb +34 -0
  15. data/app/views/profiler/profiles/index.html.erb +1 -0
  16. data/app/views/profiler/profiles/show.html.erb +4 -0
  17. data/config/routes.rb +36 -0
  18. data/exe/profiler-mcp +8 -0
  19. data/lib/profiler/collectors/ajax_collector.rb +109 -0
  20. data/lib/profiler/collectors/base_collector.rb +92 -0
  21. data/lib/profiler/collectors/cache_collector.rb +96 -0
  22. data/lib/profiler/collectors/database_collector.rb +113 -0
  23. data/lib/profiler/collectors/dump_collector.rb +98 -0
  24. data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
  25. data/lib/profiler/collectors/http_collector.rb +112 -0
  26. data/lib/profiler/collectors/job_collector.rb +50 -0
  27. data/lib/profiler/collectors/performance_collector.rb +103 -0
  28. data/lib/profiler/collectors/request_collector.rb +80 -0
  29. data/lib/profiler/collectors/view_collector.rb +79 -0
  30. data/lib/profiler/configuration.rb +81 -0
  31. data/lib/profiler/engine.rb +17 -0
  32. data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
  33. data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
  34. data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
  35. data/lib/profiler/job_profiler.rb +118 -0
  36. data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
  37. data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
  38. data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
  39. data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
  40. data/lib/profiler/mcp/server.rb +217 -0
  41. data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
  42. data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
  43. data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
  44. data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
  45. data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
  46. data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
  47. data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
  48. data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
  49. data/lib/profiler/middleware/cors_middleware.rb +55 -0
  50. data/lib/profiler/middleware/profiler_middleware.rb +151 -0
  51. data/lib/profiler/middleware/toolbar_injector.rb +378 -0
  52. data/lib/profiler/models/profile.rb +182 -0
  53. data/lib/profiler/models/sql_query.rb +48 -0
  54. data/lib/profiler/models/timeline_event.rb +40 -0
  55. data/lib/profiler/railtie.rb +75 -0
  56. data/lib/profiler/storage/base_store.rb +41 -0
  57. data/lib/profiler/storage/blob_store.rb +46 -0
  58. data/lib/profiler/storage/file_store.rb +119 -0
  59. data/lib/profiler/storage/memory_store.rb +94 -0
  60. data/lib/profiler/storage/redis_store.rb +98 -0
  61. data/lib/profiler/storage/sqlite_store.rb +272 -0
  62. data/lib/profiler/tasks/profiler.rake +79 -0
  63. data/lib/profiler/version.rb +5 -0
  64. data/lib/profiler.rb +68 -0
  65. 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