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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fefcb92ccab8794de23348d51ad9ccadda8eb3dc1bb7685e84ed839139980eeb
4
- data.tar.gz: 54eb100ac45f723e263827eff0ea83cf1025399b9c545b1ba6901d3e97b5017a
3
+ metadata.gz: 9261c8669a547a21104b2129bf64f37f6e4e41cb7b0799c8b220bfa9e2dbb5f2
4
+ data.tar.gz: 2366a02a1a7e5f5aa2def53f1bc0e1ff3c823b567b1a02df814f23c059cc25a1
5
5
  SHA512:
6
- metadata.gz: ebeb648915208f87c523f20e74d8930f69c275245d4fe8dabb9e0919d3c74f88a893cfce66d4e8824dd1186e75d61a7c0fb032383b85b43387364af821bc6f9e
7
- data.tar.gz: 58550aeee3140c4bcde15e4192bd864b29feb49e40df8a13e761ff24013371e43e7aab47544765d4e6335c4133f2d868591d880c994787bf5e9219c4c450a9f4
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
@@ -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'/'job'/'test' to clear only that type.",
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" => Resources::RecentRequests,
342
- "profiler://slow-queries" => Resources::SlowQueries,
343
- "profiler://n1-patterns" => Resources::N1Patterns,
344
- "profiler://recent-jobs" => Resources::RecentJobs,
345
- "profiler://slow-tests" => Resources::SlowTests,
346
- "profiler://failing-tests" => Resources::FailingTests
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 'job'" }]
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 (requests and jobs)"
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
- 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,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.26.0"
4
+ VERSION = "0.27.0"
5
5
  end
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.26.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