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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler.css +24 -0
  3. data/app/assets/builds/profiler.js +739 -31
  4. data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
  5. data/app/controllers/profiler/api/tests_controller.rb +46 -0
  6. data/app/controllers/profiler/test_runner_controller.rb +11 -0
  7. data/app/views/profiler/test_runner/index.html.erb +1 -0
  8. data/config/routes.rb +10 -0
  9. data/lib/profiler/collectors/database_collector.rb +1 -1
  10. data/lib/profiler/collectors/test_collector.rb +75 -0
  11. data/lib/profiler/configuration.rb +12 -1
  12. data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
  13. data/lib/profiler/mcp/resources/recent_console.rb +36 -0
  14. data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
  15. data/lib/profiler/mcp/server.rb +122 -8
  16. data/lib/profiler/mcp/tools/clear_profiles.rb +3 -3
  17. data/lib/profiler/mcp/tools/get_profile_detail.rb +123 -1
  18. data/lib/profiler/mcp/tools/get_profile_mailers.rb +147 -0
  19. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
  20. data/lib/profiler/mcp/tools/query_console_profiles.rb +95 -0
  21. data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
  22. data/lib/profiler/mcp/tools/run_tests.rb +112 -0
  23. data/lib/profiler/railtie.rb +13 -1
  24. data/lib/profiler/test_helpers/minitest_support.rb +39 -0
  25. data/lib/profiler/test_helpers/reporter.rb +121 -0
  26. data/lib/profiler/test_helpers/rspec_support.rb +33 -0
  27. data/lib/profiler/test_profiler.rb +140 -0
  28. data/lib/profiler/test_runner/discovery.rb +57 -0
  29. data/lib/profiler/test_runner/run_store.rb +120 -0
  30. data/lib/profiler/test_runner/runner.rb +106 -0
  31. data/lib/profiler/version.rb +1 -1
  32. 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
- lines << "**Type:** #{profile.profile_type == 'job' ? 'Job' : 'HTTP Request'}"
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