rails-profiler 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/profiler/mcp/resources/recent_console.rb +36 -0
- data/lib/profiler/mcp/server.rb +53 -10
- data/lib/profiler/mcp/tools/clear_profiles.rb +3 -3
- data/lib/profiler/mcp/tools/get_profile_detail.rb +123 -1
- data/lib/profiler/mcp/tools/get_profile_mailers.rb +147 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +95 -0
- data/lib/profiler/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9261c8669a547a21104b2129bf64f37f6e4e41cb7b0799c8b220bfa9e2dbb5f2
|
|
4
|
+
data.tar.gz: 2366a02a1a7e5f5aa2def53f1bc0e1ff3c823b567b1a02df814f23c059cc25a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd6ccc09e7f25b46e85180525630a9ffd0d6e379b177548ba6b9d671c99b50a805589379944c797084757e4c62d99f43616095e356fddf50f599c78fcefd21a6
|
|
7
|
+
data.tar.gz: 6f04f17edd4b2f9bd3afc2692bce41169ee3aab50cb47cf510254b25f362bb8ef390958184a6b18b0a461ffdc7a645016ad31fa59766214adbb847aae7230a9c
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
class RecentConsole
|
|
7
|
+
def self.call
|
|
8
|
+
profiles = Profiler.storage.list(limit: 200)
|
|
9
|
+
consoles = profiles.select { |p| p.profile_type == "console" }.first(50)
|
|
10
|
+
|
|
11
|
+
data = consoles.map do |profile|
|
|
12
|
+
console_data = profile.collector_data("console") || {}
|
|
13
|
+
{
|
|
14
|
+
token: profile.token,
|
|
15
|
+
expression: console_data["expression"],
|
|
16
|
+
return_value: console_data["return_value"],
|
|
17
|
+
status: profile.status == 200 ? "completed" : "failed",
|
|
18
|
+
duration: profile.duration&.round(2),
|
|
19
|
+
query_count: profile.collector_data("database")&.dig("total_queries") || 0,
|
|
20
|
+
timestamp: profile.started_at&.iso8601
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
uri: "profiler://recent-console",
|
|
26
|
+
mimeType: "application/json",
|
|
27
|
+
text: JSON.pretty_generate({
|
|
28
|
+
total: data.size,
|
|
29
|
+
console_executions: data
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -69,11 +69,13 @@ module Profiler
|
|
|
69
69
|
require_relative "tools/get_profile_ajax"
|
|
70
70
|
require_relative "tools/get_profile_dumps"
|
|
71
71
|
require_relative "tools/get_profile_http"
|
|
72
|
+
require_relative "tools/get_profile_mailers"
|
|
72
73
|
require_relative "tools/query_jobs"
|
|
73
74
|
require_relative "tools/query_mailers"
|
|
74
75
|
require_relative "tools/query_test_profiles"
|
|
75
76
|
require_relative "tools/get_test_profile_detail"
|
|
76
77
|
require_relative "tools/run_tests"
|
|
78
|
+
require_relative "tools/query_console_profiles"
|
|
77
79
|
require_relative "tools/clear_profiles"
|
|
78
80
|
require_relative "tools/list_env_vars"
|
|
79
81
|
require_relative "tools/set_env_var"
|
|
@@ -104,11 +106,13 @@ module Profiler
|
|
|
104
106
|
input_schema: {
|
|
105
107
|
properties: {
|
|
106
108
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
107
|
-
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps. Omit for all." },
|
|
109
|
+
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, console, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps, logs, env, i18n, related_jobs. Omit for all." },
|
|
108
110
|
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
109
111
|
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
110
112
|
json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
111
|
-
xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
|
|
113
|
+
xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." },
|
|
114
|
+
log_min_level: { type: "string", description: "Minimum log level to include in the logs section: DEBUG, INFO, WARN, ERROR, FATAL. Only applied when 'logs' section is requested." },
|
|
115
|
+
env_filter: { type: "string", description: "Required when requesting the env section. Case-insensitive substring filter on ENV key name (e.g. 'RAILS', 'DATABASE')." }
|
|
112
116
|
},
|
|
113
117
|
required: ["token"]
|
|
114
118
|
},
|
|
@@ -176,6 +180,22 @@ module Profiler
|
|
|
176
180
|
},
|
|
177
181
|
handler: Tools::GetProfileHttp
|
|
178
182
|
),
|
|
183
|
+
define_tool(
|
|
184
|
+
name: "get_profile_mailers",
|
|
185
|
+
description: "Get detailed mailer activity for a profile: delivered emails, errors, and queued deliveries — including email bodies when capture_mail_body is enabled.",
|
|
186
|
+
input_schema: {
|
|
187
|
+
properties: {
|
|
188
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
189
|
+
mailer_class: { type: "string", description: "Filter by mailer class name (partial match, e.g. 'UserMailer')" },
|
|
190
|
+
action: { type: "string", description: "Filter by mailer action (partial match, e.g. 'welcome_email')" },
|
|
191
|
+
delivery_mode: { type: "string", description: "Filter by delivery mode: 'deliver_now', 'deliver_later', or 'queued'" },
|
|
192
|
+
save_bodies: { type: "boolean", description: "Save email bodies to temp files and return paths instead of inlining content." },
|
|
193
|
+
max_body_size: { type: "number", description: "Truncate inlined body content at N characters." }
|
|
194
|
+
},
|
|
195
|
+
required: ["token"]
|
|
196
|
+
},
|
|
197
|
+
handler: Tools::GetProfileMailers
|
|
198
|
+
),
|
|
179
199
|
define_tool(
|
|
180
200
|
name: "query_jobs",
|
|
181
201
|
description: "Search and filter background job profiles by queue, status, etc.",
|
|
@@ -258,12 +278,27 @@ module Profiler
|
|
|
258
278
|
},
|
|
259
279
|
handler: Tools::RunTests
|
|
260
280
|
),
|
|
281
|
+
define_tool(
|
|
282
|
+
name: "query_console_profiles",
|
|
283
|
+
description: "Search and filter Rails console profiling sessions (IRB/rails console executions).",
|
|
284
|
+
input_schema: {
|
|
285
|
+
properties: {
|
|
286
|
+
expression: { type: "string", description: "Filter by expression content (partial match, e.g. 'User.find')" },
|
|
287
|
+
status: { type: "string", description: "Filter by status: 'completed' or 'failed'" },
|
|
288
|
+
min_duration: { type: "number", description: "Minimum duration in milliseconds" },
|
|
289
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
290
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, expression, return_value, status, duration, queries, token. Omit for all." },
|
|
291
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
handler: Tools::QueryConsoleProfiles
|
|
295
|
+
),
|
|
261
296
|
define_tool(
|
|
262
297
|
name: "clear_profiles",
|
|
263
|
-
description: "Clear profiler history. Omit type to clear everything, or pass 'http'
|
|
298
|
+
description: "Clear profiler history. Omit type to clear everything, or pass 'http', 'job', 'test', or 'console' to clear only that type.",
|
|
264
299
|
input_schema: {
|
|
265
300
|
properties: {
|
|
266
|
-
type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles" }
|
|
301
|
+
type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles, 'console' to clear only console sessions" }
|
|
267
302
|
}
|
|
268
303
|
},
|
|
269
304
|
handler: Tools::ClearProfiles
|
|
@@ -336,14 +371,16 @@ module Profiler
|
|
|
336
371
|
require_relative "resources/recent_jobs"
|
|
337
372
|
require_relative "resources/slow_tests"
|
|
338
373
|
require_relative "resources/failing_tests"
|
|
374
|
+
require_relative "resources/recent_console"
|
|
339
375
|
|
|
340
376
|
handlers = {
|
|
341
|
-
"profiler://recent"
|
|
342
|
-
"profiler://slow-queries"
|
|
343
|
-
"profiler://n1-patterns"
|
|
344
|
-
"profiler://recent-jobs"
|
|
345
|
-
"profiler://slow-tests"
|
|
346
|
-
"profiler://failing-tests"
|
|
377
|
+
"profiler://recent" => Resources::RecentRequests,
|
|
378
|
+
"profiler://slow-queries" => Resources::SlowQueries,
|
|
379
|
+
"profiler://n1-patterns" => Resources::N1Patterns,
|
|
380
|
+
"profiler://recent-jobs" => Resources::RecentJobs,
|
|
381
|
+
"profiler://slow-tests" => Resources::SlowTests,
|
|
382
|
+
"profiler://failing-tests" => Resources::FailingTests,
|
|
383
|
+
"profiler://recent-console" => Resources::RecentConsole
|
|
347
384
|
}
|
|
348
385
|
|
|
349
386
|
resources = [
|
|
@@ -382,6 +419,12 @@ module Profiler
|
|
|
382
419
|
name: "Failing Tests",
|
|
383
420
|
description: "Recent test profiles with status 'failed', including exception messages",
|
|
384
421
|
mime_type: "application/json"
|
|
422
|
+
),
|
|
423
|
+
::MCP::Resource.new(
|
|
424
|
+
uri: "profiler://recent-console",
|
|
425
|
+
name: "Recent Console Sessions",
|
|
426
|
+
description: "List of recently profiled Rails console (IRB) executions",
|
|
427
|
+
mime_type: "application/json"
|
|
385
428
|
)
|
|
386
429
|
]
|
|
387
430
|
|
|
@@ -7,13 +7,13 @@ module Profiler
|
|
|
7
7
|
def self.call(params)
|
|
8
8
|
type = params["type"]
|
|
9
9
|
|
|
10
|
-
if type && !%w[http job].include?(type)
|
|
11
|
-
return [{ type: "text", text: "Error: type must be 'http' or '
|
|
10
|
+
if type && !%w[http job test console].include?(type)
|
|
11
|
+
return [{ type: "text", text: "Error: type must be 'http', 'job', 'test', or 'console'" }]
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
Profiler.storage.clear(type: type)
|
|
15
15
|
|
|
16
|
-
label = type ? "#{type} profiles" : "all profiles
|
|
16
|
+
label = type ? "#{type} profiles" : "all profiles"
|
|
17
17
|
[{ type: "text", text: "Cleared #{label}." }]
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -52,6 +52,7 @@ module Profiler
|
|
|
52
52
|
lines += section_overview(profile) if want.("overview")
|
|
53
53
|
lines += section_exception(profile) if want.("exception")
|
|
54
54
|
lines += section_job(profile) if want.("job")
|
|
55
|
+
lines += section_console(profile) if want.("console")
|
|
55
56
|
lines += section_request(profile, params) if want.("request")
|
|
56
57
|
lines += section_response(profile, params) if want.("response")
|
|
57
58
|
lines += section_curl(profile) if want.("curl")
|
|
@@ -64,6 +65,9 @@ module Profiler
|
|
|
64
65
|
lines += section_mailers(profile) if want.("mailers")
|
|
65
66
|
lines += section_routes(profile) if want.("routes")
|
|
66
67
|
lines += section_dumps(profile) if want.("dumps")
|
|
68
|
+
lines += section_logs(profile, params) if want.("logs")
|
|
69
|
+
lines += section_env(profile, params) if want.("env")
|
|
70
|
+
lines += section_i18n(profile) if want.("i18n")
|
|
67
71
|
lines += section_related_jobs(profile) if want.("related_jobs")
|
|
68
72
|
lines.join("\n")
|
|
69
73
|
end
|
|
@@ -71,7 +75,12 @@ module Profiler
|
|
|
71
75
|
def self.section_overview(profile)
|
|
72
76
|
lines = []
|
|
73
77
|
lines << "# Profile Details: #{profile.token}\n"
|
|
74
|
-
|
|
78
|
+
type_label = case profile.profile_type
|
|
79
|
+
when "job" then "Job"
|
|
80
|
+
when "console" then "Console"
|
|
81
|
+
else "HTTP Request"
|
|
82
|
+
end
|
|
83
|
+
lines << "**Type:** #{type_label}"
|
|
75
84
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
76
85
|
lines << "**Status:** #{profile.status}"
|
|
77
86
|
lines << "**Duration:** #{profile.duration.round(2)} ms"
|
|
@@ -129,6 +138,26 @@ module Profiler
|
|
|
129
138
|
lines
|
|
130
139
|
end
|
|
131
140
|
|
|
141
|
+
def self.section_console(profile)
|
|
142
|
+
lines = []
|
|
143
|
+
console_data = profile.collector_data("console")
|
|
144
|
+
return lines unless console_data && console_data["expression"]
|
|
145
|
+
|
|
146
|
+
lines << "## Console"
|
|
147
|
+
lines << "**Expression:**"
|
|
148
|
+
lines << "```ruby"
|
|
149
|
+
lines << console_data["expression"].to_s
|
|
150
|
+
lines << "```"
|
|
151
|
+
if console_data.key?("return_value")
|
|
152
|
+
lines << "**Return Value:**"
|
|
153
|
+
lines << "```"
|
|
154
|
+
lines << console_data["return_value"].to_s
|
|
155
|
+
lines << "```"
|
|
156
|
+
end
|
|
157
|
+
lines << ""
|
|
158
|
+
lines
|
|
159
|
+
end
|
|
160
|
+
|
|
132
161
|
def self.section_request(profile, params)
|
|
133
162
|
lines = []
|
|
134
163
|
req_data = profile.collector_data("request")
|
|
@@ -477,6 +506,99 @@ module Profiler
|
|
|
477
506
|
lines
|
|
478
507
|
end
|
|
479
508
|
|
|
509
|
+
def self.section_logs(profile, params = {})
|
|
510
|
+
lines = []
|
|
511
|
+
log_data = profile.collector_data("logs")
|
|
512
|
+
return lines unless log_data && log_data["count"].to_i > 0
|
|
513
|
+
|
|
514
|
+
logs = log_data["logs"] || []
|
|
515
|
+
|
|
516
|
+
min_level = params["log_min_level"]&.upcase
|
|
517
|
+
if min_level
|
|
518
|
+
severity_order = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN]
|
|
519
|
+
min_idx = severity_order.index(min_level) || 0
|
|
520
|
+
logs = logs.select { |l| (severity_order.index(l["level"]) || 0) >= min_idx }
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
return lines if logs.empty?
|
|
524
|
+
|
|
525
|
+
lines << "## Logs (#{log_data['count']} total, #{log_data['errors']} errors, #{log_data['warnings']} warnings)\n"
|
|
526
|
+
|
|
527
|
+
logs.each do |entry|
|
|
528
|
+
level = entry["level"] || "INFO"
|
|
529
|
+
prefix = case level
|
|
530
|
+
when "ERROR", "FATAL" then "❌"
|
|
531
|
+
when "WARN" then "⚠️"
|
|
532
|
+
when "DEBUG" then "🔍"
|
|
533
|
+
else "ℹ️"
|
|
534
|
+
end
|
|
535
|
+
lines << "- #{prefix} **#{level}** #{entry['message']}"
|
|
536
|
+
end
|
|
537
|
+
lines << ""
|
|
538
|
+
lines
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def self.section_env(profile, params = {})
|
|
542
|
+
lines = []
|
|
543
|
+
env_data = profile.collector_data("env")
|
|
544
|
+
return lines unless env_data
|
|
545
|
+
|
|
546
|
+
filter = params["env_filter"]
|
|
547
|
+
unless filter && !filter.strip.empty?
|
|
548
|
+
lines << "## ENV Variables"
|
|
549
|
+
lines << "_Pass `env_filter` parameter to filter by key name (e.g. `RAILS`, `DATABASE`). #{env_data['total']} variables captured._"
|
|
550
|
+
lines << ""
|
|
551
|
+
return lines
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
variables = env_data["variables"] || {}
|
|
555
|
+
term = filter.downcase
|
|
556
|
+
matches = variables.select { |k, _| k.downcase.include?(term) }
|
|
557
|
+
|
|
558
|
+
lines << "## ENV Variables (filter: #{filter})\n"
|
|
559
|
+
if matches.empty?
|
|
560
|
+
lines << "_No variables matching '#{filter}'._"
|
|
561
|
+
else
|
|
562
|
+
matches.each { |k, v| lines << "- `#{k}` = `#{v}`" }
|
|
563
|
+
end
|
|
564
|
+
lines << ""
|
|
565
|
+
lines
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def self.section_i18n(profile)
|
|
569
|
+
lines = []
|
|
570
|
+
i18n_data = profile.collector_data("i18n")
|
|
571
|
+
return lines unless i18n_data && i18n_data["total"].to_i > 0
|
|
572
|
+
|
|
573
|
+
lookups = i18n_data["lookups"] || []
|
|
574
|
+
missing = lookups.select { |l| l["missing"] }
|
|
575
|
+
|
|
576
|
+
lines << "## I18n"
|
|
577
|
+
lines << "- **Locale:** #{i18n_data['locale']}"
|
|
578
|
+
lines << "- **Total lookups:** #{i18n_data['total']}"
|
|
579
|
+
lines << "- **Missing translations:** #{i18n_data['missing_count']}"
|
|
580
|
+
|
|
581
|
+
if missing.any?
|
|
582
|
+
lines << ""
|
|
583
|
+
lines << "### Missing Translations"
|
|
584
|
+
missing.each { |l| lines << "- `#{l['key']}` (#{l['locale']})" }
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
top = lookups.group_by { |l| l["key"] }
|
|
588
|
+
.map { |k, ls| [k, ls.size] }
|
|
589
|
+
.sort_by { |_, c| -c }
|
|
590
|
+
.first(10)
|
|
591
|
+
|
|
592
|
+
if top.any?
|
|
593
|
+
lines << ""
|
|
594
|
+
lines << "### Most Called Keys (top #{top.size})"
|
|
595
|
+
top.each { |key, count| lines << "- `#{key}`: #{count}×" }
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
lines << ""
|
|
599
|
+
lines
|
|
600
|
+
end
|
|
601
|
+
|
|
480
602
|
def self.section_related_jobs(profile)
|
|
481
603
|
lines = []
|
|
482
604
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetProfileMailers
|
|
7
|
+
SEVERITY_ICONS = { "deliver_now" => "✅", "deliver_later" => "📬", "queued" => "⏳" }.freeze
|
|
8
|
+
|
|
9
|
+
def self.call(params)
|
|
10
|
+
token = params["token"]
|
|
11
|
+
unless token
|
|
12
|
+
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
profile = if token == "latest"
|
|
16
|
+
Profiler.storage.list(limit: 1).first
|
|
17
|
+
else
|
|
18
|
+
Profiler.storage.load(token)
|
|
19
|
+
end
|
|
20
|
+
unless profile
|
|
21
|
+
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
mailer_data = profile.collector_data("mailer")
|
|
25
|
+
unless mailer_data && mailer_data["total"].to_i + mailer_data["queued_count"].to_i > 0
|
|
26
|
+
return [{ type: "text", text: "No mailer activity found in this profile" }]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
[{ type: "text", text: format_mailers(profile, mailer_data, params) }]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def self.format_mailers(profile, mailer_data, params)
|
|
35
|
+
mailer_filter = params["mailer_class"]&.downcase
|
|
36
|
+
action_filter = params["action"]&.downcase
|
|
37
|
+
mode_filter = params["delivery_mode"]
|
|
38
|
+
|
|
39
|
+
emails = filter_entries(mailer_data["emails"] || [], mailer_filter, action_filter, mode_filter)
|
|
40
|
+
errors = filter_entries(mailer_data["errors"] || [], mailer_filter, action_filter, mode_filter)
|
|
41
|
+
queued = filter_entries(mailer_data["queued"] || [], mailer_filter, action_filter, mode_filter)
|
|
42
|
+
|
|
43
|
+
lines = []
|
|
44
|
+
lines << "# Mailer Activity: #{profile.token}\n"
|
|
45
|
+
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
46
|
+
lines << "**Delivered:** #{mailer_data['total']} email(s)"
|
|
47
|
+
lines << "**Queued (deliver_later):** #{mailer_data['queued_count']} email(s)"
|
|
48
|
+
lines << "**Errors:** #{mailer_data['failed']}"
|
|
49
|
+
lines << "**Body captured:** #{mailer_data.dig('emails', 0, 'body_captured') ? 'yes' : 'no (set capture_mail_body: true)'}"
|
|
50
|
+
|
|
51
|
+
loop_warnings = mailer_data["loop_warnings"] || []
|
|
52
|
+
if loop_warnings.any?
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << "⚠️ **Loop warnings:**"
|
|
55
|
+
loop_warnings.each { |w| lines << " - #{w['message']}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
append_email_section(lines, "Delivered Emails", emails, profile, params) if emails.any?
|
|
59
|
+
append_email_section(lines, "Errors", errors, profile, params) if errors.any?
|
|
60
|
+
append_queued_section(lines, queued) if queued.any?
|
|
61
|
+
|
|
62
|
+
lines.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.filter_entries(entries, mailer_filter, action_filter, mode_filter)
|
|
66
|
+
entries = entries.select { |e| e["mailer_class"].to_s.downcase.include?(mailer_filter) } if mailer_filter
|
|
67
|
+
entries = entries.select { |e| e["action"].to_s.downcase.include?(action_filter) } if action_filter
|
|
68
|
+
entries = entries.select { |e| e["delivery_mode"].to_s == mode_filter } if mode_filter
|
|
69
|
+
entries
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.append_email_section(lines, title, emails, profile, params)
|
|
73
|
+
lines << ""
|
|
74
|
+
lines << "## #{title}\n"
|
|
75
|
+
|
|
76
|
+
emails.each_with_index do |email, i|
|
|
77
|
+
lines << "### Email #{i + 1}: #{email['mailer_class']}##{email['action']}"
|
|
78
|
+
lines << "- **Subject:** #{email['subject']}"
|
|
79
|
+
lines << "- **To:** #{Array(email['to']).join(', ')}"
|
|
80
|
+
lines << "- **From:** #{Array(email['from']).join(', ')}"
|
|
81
|
+
lines << "- **CC:** #{Array(email['cc']).join(', ')}" unless Array(email['cc']).empty?
|
|
82
|
+
lines << "- **BCC:** #{Array(email['bcc']).join(', ')}" unless Array(email['bcc']).empty?
|
|
83
|
+
lines << "- **Reply-To:** #{Array(email['reply_to']).join(', ')}" unless Array(email['reply_to']).empty?
|
|
84
|
+
lines << "- **Message-ID:** #{email['message_id']}" if email['message_id']
|
|
85
|
+
lines << "- **Delivery method:** #{email['delivery_method']}"
|
|
86
|
+
lines << "- **Mode:** #{email['delivery_mode']}"
|
|
87
|
+
lines << "- **Render duration:** #{email['duration_ms']}ms" if email['duration_ms']
|
|
88
|
+
lines << "- **Delivery duration:** #{email['delivery_ms']}ms" if email['delivery_ms']
|
|
89
|
+
lines << "- **Error:** #{email['error']}" if email['error']
|
|
90
|
+
|
|
91
|
+
parts = Array(email['parts'])
|
|
92
|
+
lines << "- **Parts:** #{parts.join(', ')}" if parts.any?
|
|
93
|
+
|
|
94
|
+
attachments = Array(email['attachments'])
|
|
95
|
+
if attachments.any?
|
|
96
|
+
lines << "- **Attachments:**"
|
|
97
|
+
attachments.each { |a| lines << " - #{a['filename']} (#{a['size']} bytes)" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
assigns = email['assigns'] || {}
|
|
101
|
+
if assigns.any?
|
|
102
|
+
lines << "- **Template assigns:**"
|
|
103
|
+
assigns.each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if email['body_captured']
|
|
107
|
+
if email['body_html'] && !email['body_html'].empty?
|
|
108
|
+
lines << "- **HTML body:**"
|
|
109
|
+
formatted = BodyFormatter.format_body(
|
|
110
|
+
profile.token, "mailer_#{i}_html", email['body_html'], nil, params
|
|
111
|
+
)
|
|
112
|
+
lines << formatted if formatted
|
|
113
|
+
end
|
|
114
|
+
if email['body_text'] && !email['body_text'].empty?
|
|
115
|
+
lines << "- **Text body:**"
|
|
116
|
+
formatted = BodyFormatter.format_body(
|
|
117
|
+
profile.token, "mailer_#{i}_text", email['body_text'], nil, params
|
|
118
|
+
)
|
|
119
|
+
lines << formatted if formatted
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
lines << "- **Body:** not captured (enable `capture_mail_body: true` in initializer)"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
lines << ""
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.append_queued_section(lines, queued)
|
|
130
|
+
lines << ""
|
|
131
|
+
lines << "## Queued (deliver_later)\n"
|
|
132
|
+
queued.each_with_index do |email, i|
|
|
133
|
+
lines << "### Queued #{i + 1}: #{email['mailer_class']}##{email['action']}"
|
|
134
|
+
lines << "- **Delivery method:** #{email['delivery_method']}"
|
|
135
|
+
lines << "- **Render duration:** #{email['duration_ms']}ms" if email['duration_ms']
|
|
136
|
+
assigns = email['assigns'] || {}
|
|
137
|
+
if assigns.any?
|
|
138
|
+
lines << "- **Template assigns:**"
|
|
139
|
+
assigns.each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
140
|
+
end
|
|
141
|
+
lines << ""
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryConsoleProfiles
|
|
7
|
+
ALL_FIELDS = %w[time expression return_value status duration queries token].freeze
|
|
8
|
+
|
|
9
|
+
def self.call(params)
|
|
10
|
+
limit = params["limit"]&.to_i || 20
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
13
|
+
|
|
14
|
+
consoles = profiles.select { |p| p.profile_type == "console" }
|
|
15
|
+
|
|
16
|
+
if params["expression"]
|
|
17
|
+
term = params["expression"].downcase
|
|
18
|
+
consoles = consoles.select do |p|
|
|
19
|
+
console_data = p.collector_data("console")
|
|
20
|
+
console_data && console_data["expression"].to_s.downcase.include?(term)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if params["status"]
|
|
25
|
+
consoles = consoles.select do |p|
|
|
26
|
+
case params["status"]
|
|
27
|
+
when "completed" then p.status == 200
|
|
28
|
+
when "failed" then p.status != 200
|
|
29
|
+
else false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if params["min_duration"]
|
|
35
|
+
min_ms = params["min_duration"].to_f
|
|
36
|
+
consoles = consoles.select { |p| p.duration >= min_ms }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if params["cursor"]
|
|
40
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
41
|
+
consoles = consoles.select { |p| p.started_at < cutoff } if cutoff
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
consoles = consoles.first(limit)
|
|
45
|
+
fields = params["fields"]&.map(&:to_s)
|
|
46
|
+
|
|
47
|
+
[{ type: "text", text: format_console_table(consoles, fields, limit) }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def self.format_console_table(consoles, fields, limit)
|
|
53
|
+
return "No console profiles found matching the criteria." if consoles.empty?
|
|
54
|
+
|
|
55
|
+
fields ||= ALL_FIELDS
|
|
56
|
+
fields = fields & ALL_FIELDS
|
|
57
|
+
|
|
58
|
+
lines = []
|
|
59
|
+
lines << "# Console Profiles\n"
|
|
60
|
+
lines << "Found #{consoles.size} console execution#{consoles.size > 1 ? "s" : ""}:\n"
|
|
61
|
+
|
|
62
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
63
|
+
separator = fields.map { "------" }.join("|")
|
|
64
|
+
lines << "| #{header} |"
|
|
65
|
+
lines << "|#{separator}|"
|
|
66
|
+
|
|
67
|
+
consoles.each do |profile|
|
|
68
|
+
console_data = profile.collector_data("console") || {}
|
|
69
|
+
db_data = profile.collector_data("database") || {}
|
|
70
|
+
|
|
71
|
+
row = fields.map do |f|
|
|
72
|
+
case f
|
|
73
|
+
when "time" then profile.started_at.strftime("%H:%M:%S")
|
|
74
|
+
when "expression" then console_data["expression"].to_s.then { |e| e.length > 60 ? "#{e[0, 57]}..." : e }
|
|
75
|
+
when "return_value" then console_data["return_value"].to_s.then { |v| v.length > 80 ? "#{v[0, 77]}..." : v }
|
|
76
|
+
when "status" then profile.status == 200 ? "completed" : "failed"
|
|
77
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
78
|
+
when "queries" then db_data["total_queries"].to_i.to_s
|
|
79
|
+
when "token" then profile.token.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
lines << "| #{row.join(' | ')} |"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if consoles.size == limit
|
|
86
|
+
lines << ""
|
|
87
|
+
lines << "*Next cursor: #{consoles.last.started_at.iso8601}*"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -165,6 +165,7 @@ files:
|
|
|
165
165
|
- lib/profiler/mcp/path_extractor.rb
|
|
166
166
|
- lib/profiler/mcp/resources/failing_tests.rb
|
|
167
167
|
- lib/profiler/mcp/resources/n1_patterns.rb
|
|
168
|
+
- lib/profiler/mcp/resources/recent_console.rb
|
|
168
169
|
- lib/profiler/mcp/resources/recent_jobs.rb
|
|
169
170
|
- lib/profiler/mcp/resources/recent_requests.rb
|
|
170
171
|
- lib/profiler/mcp/resources/slow_queries.rb
|
|
@@ -178,8 +179,10 @@ files:
|
|
|
178
179
|
- lib/profiler/mcp/tools/get_profile_detail.rb
|
|
179
180
|
- lib/profiler/mcp/tools/get_profile_dumps.rb
|
|
180
181
|
- lib/profiler/mcp/tools/get_profile_http.rb
|
|
182
|
+
- lib/profiler/mcp/tools/get_profile_mailers.rb
|
|
181
183
|
- lib/profiler/mcp/tools/get_test_profile_detail.rb
|
|
182
184
|
- lib/profiler/mcp/tools/list_env_vars.rb
|
|
185
|
+
- lib/profiler/mcp/tools/query_console_profiles.rb
|
|
183
186
|
- lib/profiler/mcp/tools/query_jobs.rb
|
|
184
187
|
- lib/profiler/mcp/tools/query_mailers.rb
|
|
185
188
|
- lib/profiler/mcp/tools/query_profiles.rb
|