rails-profiler 0.25.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/app/assets/builds/profiler.css +24 -0
- data/app/assets/builds/profiler.js +739 -31
- data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
- data/app/controllers/profiler/api/tests_controller.rb +46 -0
- data/app/controllers/profiler/test_runner_controller.rb +11 -0
- data/app/views/profiler/test_runner/index.html.erb +1 -0
- data/config/routes.rb +10 -0
- data/lib/profiler/collectors/database_collector.rb +1 -1
- data/lib/profiler/collectors/test_collector.rb +75 -0
- data/lib/profiler/configuration.rb +12 -1
- data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
- data/lib/profiler/mcp/resources/recent_console.rb +36 -0
- data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
- data/lib/profiler/mcp/server.rb +122 -8
- 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/get_test_profile_detail.rb +126 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +95 -0
- data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
- data/lib/profiler/mcp/tools/run_tests.rb +112 -0
- data/lib/profiler/railtie.rb +13 -1
- data/lib/profiler/test_helpers/minitest_support.rb +39 -0
- data/lib/profiler/test_helpers/reporter.rb +121 -0
- data/lib/profiler/test_helpers/rspec_support.rb +33 -0
- data/lib/profiler/test_profiler.rb +140 -0
- data/lib/profiler/test_runner/discovery.rb +57 -0
- data/lib/profiler/test_runner/run_store.rb +120 -0
- data/lib/profiler/test_runner/runner.rb +106 -0
- data/lib/profiler/version.rb +1 -1
- metadata +22 -2
|
@@ -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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetTestProfileDetail
|
|
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 = if token == "latest"
|
|
14
|
+
profiles = Profiler.storage.list(limit: 200)
|
|
15
|
+
profiles.find { |p| p.profile_type == "test" }
|
|
16
|
+
else
|
|
17
|
+
Profiler.storage.load(token)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
unless profile && profile.profile_type == "test"
|
|
21
|
+
return [{ type: "text", text: "Test profile not found: #{token}" }]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
[{ type: "text", text: format_test_detail(profile) }]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def self.format_test_detail(profile)
|
|
30
|
+
test_data = profile.collector_data("test") || {}
|
|
31
|
+
db_data = profile.collector_data("database") || {}
|
|
32
|
+
cache_data = profile.collector_data("cache") || {}
|
|
33
|
+
exc_data = profile.collector_data("exception") || {}
|
|
34
|
+
|
|
35
|
+
queries = db_data["queries"] || []
|
|
36
|
+
n1_count = count_n1_patterns(queries)
|
|
37
|
+
|
|
38
|
+
lines = []
|
|
39
|
+
lines << "# Test Profile Detail\n"
|
|
40
|
+
|
|
41
|
+
# Overview
|
|
42
|
+
lines << "## Overview"
|
|
43
|
+
lines << "| Field | Value |"
|
|
44
|
+
lines << "|-------|-------|"
|
|
45
|
+
lines << "| Token | `#{profile.token}` |"
|
|
46
|
+
lines << "| Test name | #{test_data["test_name"] || profile.path} |"
|
|
47
|
+
lines << "| Status | #{test_data["status"] || "unknown"} |"
|
|
48
|
+
lines << "| Framework | #{test_data["framework"]} |"
|
|
49
|
+
lines << "| File | #{test_data["test_file"]}:#{test_data["test_line"]} |"
|
|
50
|
+
lines << "| Duration | #{profile.duration&.round(2)}ms |"
|
|
51
|
+
lines << "| Assertions | #{test_data["assertions"] || "-"} |"
|
|
52
|
+
lines << "| Memory delta | #{profile.memory ? "#{(profile.memory.to_f / 1024 / 1024).round(2)} MB" : "-"} |"
|
|
53
|
+
lines << "| Time | #{profile.started_at&.strftime("%H:%M:%S")} |"
|
|
54
|
+
|
|
55
|
+
# Exception / skip
|
|
56
|
+
if test_data["exception_message"]
|
|
57
|
+
lines << "\n## Exception"
|
|
58
|
+
lines << "```\n#{test_data["exception_message"]}\n```"
|
|
59
|
+
elsif test_data["skip_reason"]
|
|
60
|
+
lines << "\n## Skip reason"
|
|
61
|
+
lines << test_data["skip_reason"].to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Unhandled exception (ExceptionCollector)
|
|
65
|
+
if exc_data["exception_class"]
|
|
66
|
+
lines << "\n## Unhandled Exception"
|
|
67
|
+
lines << "**#{exc_data["exception_class"]}**: #{exc_data["exception_message"]}"
|
|
68
|
+
if (backtrace = exc_data["backtrace"]).is_a?(Array) && backtrace.any?
|
|
69
|
+
lines << "\n```"
|
|
70
|
+
backtrace.first(5).each { |l| lines << l }
|
|
71
|
+
lines << "```"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Database
|
|
76
|
+
lines << "\n## Database (#{db_data["total_queries"].to_i} queries · #{db_data["total_duration"].to_f.round(2)}ms · #{n1_count} N+1 patterns)"
|
|
77
|
+
if queries.any?
|
|
78
|
+
slow_threshold = Profiler.configuration.slow_query_threshold
|
|
79
|
+
slow = queries.select { |q| q["duration"].to_f >= slow_threshold }
|
|
80
|
+
show = slow.any? ? slow.first(10) : queries.first(10)
|
|
81
|
+
caption = slow.any? ? "Slowest queries:" : "First queries:"
|
|
82
|
+
lines << caption
|
|
83
|
+
lines << "| # | Duration | SQL |"
|
|
84
|
+
lines << "|---|----------|-----|"
|
|
85
|
+
show.each_with_index do |q, i|
|
|
86
|
+
sql = q["sql"].to_s.gsub("|", "\\|").then { |s| s.length > 100 ? s[0, 97] + "..." : s }
|
|
87
|
+
lines << "| #{i + 1} | #{q["duration"].to_f.round(2)}ms | `#{sql}` |"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if n1_count > 0
|
|
91
|
+
lines << "\n### N+1 Patterns Detected"
|
|
92
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }
|
|
93
|
+
.select { |_, qs| qs.size >= 3 }
|
|
94
|
+
.each do |pattern, qs|
|
|
95
|
+
lines << "- `#{pattern[0, 120]}` (×#{qs.size})"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
else
|
|
99
|
+
lines << "_No SQL queries recorded._"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Cache
|
|
103
|
+
total_cache = cache_data["total_reads"].to_i + cache_data["total_writes"].to_i + cache_data["total_deletes"].to_i
|
|
104
|
+
if total_cache > 0
|
|
105
|
+
lines << "\n## Cache (#{total_cache} operations)"
|
|
106
|
+
lines << "- Reads: #{cache_data["total_reads"].to_i}"
|
|
107
|
+
lines << "- Writes: #{cache_data["total_writes"].to_i}"
|
|
108
|
+
lines << "- Deletes: #{cache_data["total_deletes"].to_i}"
|
|
109
|
+
lines << "- Misses: #{cache_data["total_misses"].to_i}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.count_n1_patterns(queries)
|
|
116
|
+
return 0 if queries.size < 3
|
|
117
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }.count { |_, qs| qs.size >= 3 }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.normalize_sql(sql)
|
|
121
|
+
sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryTestProfiles
|
|
7
|
+
ALL_FIELDS = %w[time test_name status duration queries n1 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
|
+
tests = profiles.select { |p| p.profile_type == "test" }
|
|
15
|
+
|
|
16
|
+
if params["test_name"]
|
|
17
|
+
term = params["test_name"].downcase
|
|
18
|
+
tests = tests.select do |p|
|
|
19
|
+
test_data = p.collector_data("test")
|
|
20
|
+
(test_data&.dig("test_name") || p.path).to_s.downcase.include?(term)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if params["status"]
|
|
25
|
+
tests = tests.select do |p|
|
|
26
|
+
test_data = p.collector_data("test")
|
|
27
|
+
test_data && test_data["status"] == params["status"]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if params["min_duration"]
|
|
32
|
+
min_ms = params["min_duration"].to_f
|
|
33
|
+
tests = tests.select { |p| p.duration >= min_ms }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if params["cursor"]
|
|
37
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
38
|
+
tests = tests.select { |p| p.started_at < cutoff } if cutoff
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
tests = tests.first(limit)
|
|
42
|
+
fields = params["fields"]&.map(&:to_s)
|
|
43
|
+
|
|
44
|
+
[{ type: "text", text: format_tests_table(tests, fields, limit) }]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def self.format_tests_table(tests, fields, limit)
|
|
50
|
+
return "No test profiles found matching the criteria." if tests.empty?
|
|
51
|
+
|
|
52
|
+
fields ||= ALL_FIELDS
|
|
53
|
+
fields = fields & ALL_FIELDS
|
|
54
|
+
|
|
55
|
+
lines = []
|
|
56
|
+
lines << "# Test Profiles\n"
|
|
57
|
+
lines << "Found #{tests.size} tests:\n"
|
|
58
|
+
|
|
59
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
60
|
+
separator = fields.map { "------" }.join("|")
|
|
61
|
+
lines << "| #{header} |"
|
|
62
|
+
lines << "|#{separator}|"
|
|
63
|
+
|
|
64
|
+
tests.each do |profile|
|
|
65
|
+
test_data = profile.collector_data("test") || {}
|
|
66
|
+
db_data = profile.collector_data("database") || {}
|
|
67
|
+
queries = db_data["queries"] || []
|
|
68
|
+
n1_count = count_n1_patterns(queries)
|
|
69
|
+
|
|
70
|
+
row = fields.map do |f|
|
|
71
|
+
case f
|
|
72
|
+
when "time" then profile.started_at.strftime("%H:%M:%S")
|
|
73
|
+
when "test_name" then (test_data["test_name"] || profile.path).to_s.then { |n| n.length > 60 ? n[0, 57] + "..." : n }
|
|
74
|
+
when "status" then test_data["status"] || "-"
|
|
75
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
76
|
+
when "queries" then db_data["total_queries"].to_i.to_s
|
|
77
|
+
when "n1" then n1_count > 0 ? "⚠ #{n1_count}" : "✓"
|
|
78
|
+
when "token" then profile.token.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
lines << "| #{row.join(' | ')} |"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if tests.size == limit
|
|
85
|
+
lines << ""
|
|
86
|
+
lines << "*Next cursor: #{tests.last.started_at.iso8601}*"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.count_n1_patterns(queries)
|
|
93
|
+
return 0 if queries.size < 3
|
|
94
|
+
|
|
95
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }
|
|
96
|
+
.count { |_, qs| qs.size >= 3 }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.normalize_sql(sql)
|
|
100
|
+
sql.gsub(/\$\d+/, "?")
|
|
101
|
+
.gsub(/\b\d+\b/, "?")
|
|
102
|
+
.gsub(/'[^']*'/, "?")
|
|
103
|
+
.gsub(/"[^"]*"/, "?")
|
|
104
|
+
.strip
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|