rails-profiler 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/builds/profiler-toolbar.js +1191 -0
  3. data/app/assets/builds/profiler.css +2668 -0
  4. data/app/assets/builds/profiler.js +2772 -0
  5. data/app/controllers/profiler/api/ajax_controller.rb +36 -0
  6. data/app/controllers/profiler/api/jobs_controller.rb +39 -0
  7. data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
  8. data/app/controllers/profiler/api/profiles_controller.rb +60 -0
  9. data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
  10. data/app/controllers/profiler/application_controller.rb +19 -0
  11. data/app/controllers/profiler/assets_controller.rb +29 -0
  12. data/app/controllers/profiler/profiles_controller.rb +107 -0
  13. data/app/views/layouts/profiler/application.html.erb +16 -0
  14. data/app/views/layouts/profiler/embedded.html.erb +34 -0
  15. data/app/views/profiler/profiles/index.html.erb +1 -0
  16. data/app/views/profiler/profiles/show.html.erb +4 -0
  17. data/config/routes.rb +36 -0
  18. data/exe/profiler-mcp +8 -0
  19. data/lib/profiler/collectors/ajax_collector.rb +109 -0
  20. data/lib/profiler/collectors/base_collector.rb +92 -0
  21. data/lib/profiler/collectors/cache_collector.rb +96 -0
  22. data/lib/profiler/collectors/database_collector.rb +113 -0
  23. data/lib/profiler/collectors/dump_collector.rb +98 -0
  24. data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
  25. data/lib/profiler/collectors/http_collector.rb +112 -0
  26. data/lib/profiler/collectors/job_collector.rb +50 -0
  27. data/lib/profiler/collectors/performance_collector.rb +103 -0
  28. data/lib/profiler/collectors/request_collector.rb +80 -0
  29. data/lib/profiler/collectors/view_collector.rb +79 -0
  30. data/lib/profiler/configuration.rb +81 -0
  31. data/lib/profiler/engine.rb +17 -0
  32. data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
  33. data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
  34. data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
  35. data/lib/profiler/job_profiler.rb +118 -0
  36. data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
  37. data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
  38. data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
  39. data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
  40. data/lib/profiler/mcp/server.rb +217 -0
  41. data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
  42. data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
  43. data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
  44. data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
  45. data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
  46. data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
  47. data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
  48. data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
  49. data/lib/profiler/middleware/cors_middleware.rb +55 -0
  50. data/lib/profiler/middleware/profiler_middleware.rb +151 -0
  51. data/lib/profiler/middleware/toolbar_injector.rb +378 -0
  52. data/lib/profiler/models/profile.rb +182 -0
  53. data/lib/profiler/models/sql_query.rb +48 -0
  54. data/lib/profiler/models/timeline_event.rb +40 -0
  55. data/lib/profiler/railtie.rb +75 -0
  56. data/lib/profiler/storage/base_store.rb +41 -0
  57. data/lib/profiler/storage/blob_store.rb +46 -0
  58. data/lib/profiler/storage/file_store.rb +119 -0
  59. data/lib/profiler/storage/memory_store.rb +94 -0
  60. data/lib/profiler/storage/redis_store.rb +98 -0
  61. data/lib/profiler/storage/sqlite_store.rb +272 -0
  62. data/lib/profiler/tasks/profiler.rake +79 -0
  63. data/lib/profiler/version.rb +5 -0
  64. data/lib/profiler.rb +68 -0
  65. metadata +194 -0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class N1Patterns
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 100)
9
+
10
+ # Map normalized SQL -> list of occurrences across profiles
11
+ pattern_map = Hash.new { |h, k| h[k] = [] }
12
+
13
+ profiles.each do |profile|
14
+ db_data = profile.collector_data("database")
15
+ next unless db_data && db_data["queries"]
16
+
17
+ query_counts = db_data["queries"].group_by { |q| normalize_sql(q["sql"]) }
18
+ .transform_values(&:count)
19
+
20
+ query_counts.each do |normalized_sql, count|
21
+ pattern_map[normalized_sql] << {
22
+ token: profile.token,
23
+ path: profile.path,
24
+ count: count,
25
+ timestamp: profile.started_at&.iso8601
26
+ }
27
+ end
28
+ end
29
+
30
+ # Keep only patterns that appear in more than one profile OR appear multiple times in a single profile
31
+ n1_patterns = pattern_map.select do |_, occurrences|
32
+ occurrences.size > 1 || occurrences.any? { |o| o[:count] > 1 }
33
+ end
34
+
35
+ # Sort by total occurrence count descending
36
+ sorted = n1_patterns.map do |sql, occurrences|
37
+ total = occurrences.sum { |o| o[:count] }
38
+ { sql: sql, total_occurrences: total, profiles: occurrences }
39
+ end.sort_by { |p| -p[:total_occurrences] }.first(20)
40
+
41
+ {
42
+ uri: "profiler://n1-patterns",
43
+ mimeType: "application/json",
44
+ text: JSON.pretty_generate({
45
+ scanned_profiles: profiles.size,
46
+ total_patterns: sorted.size,
47
+ patterns: sorted
48
+ })
49
+ }
50
+ end
51
+
52
+ def self.normalize_sql(sql)
53
+ sql.gsub(/\$\d+/, '?')
54
+ .gsub(/\b\d+\b/, '?')
55
+ .gsub(/'[^']*'/, '?')
56
+ .gsub(/"[^"]*"/, '?')
57
+ .strip
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class RecentJobs
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 200)
9
+ jobs = profiles.select { |p| p.profile_type == "job" }.first(50)
10
+
11
+ data = jobs.map do |profile|
12
+ job_data = profile.collector_data("job") || {}
13
+ {
14
+ token: profile.token,
15
+ job_class: job_data["job_class"] || profile.path,
16
+ job_id: job_data["job_id"],
17
+ queue: job_data["queue"],
18
+ status: job_data["status"],
19
+ duration: profile.duration&.round(2),
20
+ executions: job_data["executions"],
21
+ error: job_data["error"],
22
+ timestamp: profile.started_at&.iso8601,
23
+ query_count: profile.collector_data("database")&.dig("total_queries") || 0
24
+ }
25
+ end
26
+
27
+ {
28
+ uri: "profiler://recent-jobs",
29
+ mimeType: "application/json",
30
+ text: JSON.pretty_generate({
31
+ total: data.size,
32
+ jobs: data
33
+ })
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class RecentRequests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 50)
9
+
10
+ data = profiles.map do |profile|
11
+ {
12
+ token: profile.token,
13
+ path: profile.path,
14
+ method: profile.method,
15
+ status: profile.status,
16
+ duration: profile.duration&.round(2),
17
+ memory: profile.memory ? (profile.memory / 1024.0 / 1024.0).round(2) : nil,
18
+ timestamp: profile.started_at&.iso8601,
19
+ query_count: profile.collector_data("database")&.dig("total_queries") || 0
20
+ }
21
+ end
22
+
23
+ {
24
+ uri: "profiler://recent",
25
+ mimeType: "application/json",
26
+ text: JSON.pretty_generate({
27
+ total: data.size,
28
+ profiles: data
29
+ })
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class SlowQueries
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 100)
9
+ slow_threshold = Profiler.configuration.slow_query_threshold
10
+
11
+ slow_queries = []
12
+
13
+ profiles.each do |profile|
14
+ db_data = profile.collector_data("database")
15
+ next unless db_data && db_data["queries"]
16
+
17
+ db_data["queries"].each do |query|
18
+ if query["duration"] > slow_threshold
19
+ slow_queries << {
20
+ profile_token: profile.token,
21
+ profile_path: profile.path,
22
+ sql: query["sql"],
23
+ duration: query["duration"].round(2),
24
+ timestamp: profile.started_at&.iso8601
25
+ }
26
+ end
27
+ end
28
+ end
29
+
30
+ # Sort by duration descending
31
+ slow_queries.sort_by! { |q| -q[:duration] }
32
+ slow_queries = slow_queries.first(50)
33
+
34
+ {
35
+ uri: "profiler://slow-queries",
36
+ mimeType: "application/json",
37
+ text: JSON.pretty_generate({
38
+ threshold: slow_threshold,
39
+ total: slow_queries.size,
40
+ queries: slow_queries
41
+ })
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Profiler
6
+ module MCP
7
+ class Server
8
+ class << self
9
+ def instance
10
+ @instance ||= new
11
+ end
12
+
13
+ def rack_app
14
+ # Memoized at class level — survives route reloads in development
15
+ @rack_app ||= ->(env) { instance.http_transport.handle_request(Rack::Request.new(env)) }
16
+ end
17
+ end
18
+
19
+ def initialize
20
+ tools = build_tools
21
+ resources, @resource_handlers = build_resources
22
+
23
+ @server = ::MCP::Server.new(
24
+ name: "rails-profiler",
25
+ version: Profiler::VERSION,
26
+ tools: tools,
27
+ resources: resources
28
+ )
29
+
30
+ @server.resources_read_handler do |params|
31
+ uri = params[:uri]
32
+ handler = @resource_handlers[uri]
33
+ next [{ uri: uri, mimeType: "application/json", text: "Resource not found: #{uri}" }] unless handler
34
+
35
+ result = handler.call
36
+ [{ uri: result[:uri], mimeType: result[:mimeType], text: result[:text] }]
37
+ end
38
+ end
39
+
40
+ def http_transport
41
+ @http_transport ||= begin
42
+ t = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
43
+ @server.transport = t
44
+ t
45
+ end
46
+ end
47
+
48
+ def start(transport: :stdio)
49
+ case transport
50
+ when :stdio
51
+ ::MCP::Server::Transports::StdioTransport.new(@server).open
52
+ when :http
53
+ $stderr.puts "MCP HTTP transport active — endpoint: /_profiler/mcp"
54
+ else
55
+ raise Error, "Unknown transport: #{transport}"
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def build_tools
62
+ require_relative "tools/query_profiles"
63
+ require_relative "tools/get_profile_detail"
64
+ require_relative "tools/analyze_queries"
65
+ require_relative "tools/get_profile_ajax"
66
+ require_relative "tools/get_profile_dumps"
67
+ require_relative "tools/get_profile_http"
68
+ require_relative "tools/query_jobs"
69
+ require_relative "tools/clear_profiles"
70
+
71
+ [
72
+ define_tool(
73
+ name: "query_profiles",
74
+ description: "Search and filter profiled requests by path, method, duration, etc.",
75
+ input_schema: {
76
+ properties: {
77
+ path: { type: "string", description: "Filter by request path (partial match)" },
78
+ method: { type: "string", description: "Filter by HTTP method (GET, POST, etc.)" },
79
+ min_duration: { type: "number", description: "Minimum duration in milliseconds" },
80
+ profile_type: { type: "string", description: "Filter by type: 'http' or 'job'" },
81
+ limit: { type: "number", description: "Maximum number of results" }
82
+ }
83
+ },
84
+ handler: Tools::QueryProfiles
85
+ ),
86
+ define_tool(
87
+ name: "get_profile",
88
+ description: "Get detailed profile data by token",
89
+ input_schema: {
90
+ properties: {
91
+ token: { type: "string", description: "Profile token (required)" }
92
+ },
93
+ required: ["token"]
94
+ },
95
+ handler: Tools::GetProfileDetail
96
+ ),
97
+ define_tool(
98
+ name: "analyze_queries",
99
+ description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries",
100
+ input_schema: {
101
+ properties: {
102
+ token: { type: "string", description: "Profile token (required)" }
103
+ },
104
+ required: ["token"]
105
+ },
106
+ handler: Tools::AnalyzeQueries
107
+ ),
108
+ define_tool(
109
+ name: "get_profile_ajax",
110
+ description: "Get detailed AJAX sub-request breakdown for a profile",
111
+ input_schema: {
112
+ properties: {
113
+ token: { type: "string", description: "Profile token (required)" }
114
+ },
115
+ required: ["token"]
116
+ },
117
+ handler: Tools::GetProfileAjax
118
+ ),
119
+ define_tool(
120
+ name: "get_profile_dumps",
121
+ description: "Get variable dumps captured during a profile",
122
+ input_schema: {
123
+ properties: {
124
+ token: { type: "string", description: "Profile token (required)" }
125
+ },
126
+ required: ["token"]
127
+ },
128
+ handler: Tools::GetProfileDumps
129
+ ),
130
+ define_tool(
131
+ name: "get_profile_http",
132
+ description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request)",
133
+ input_schema: {
134
+ properties: {
135
+ token: { type: "string", description: "Profile token (required)" }
136
+ },
137
+ required: ["token"]
138
+ },
139
+ handler: Tools::GetProfileHttp
140
+ ),
141
+ define_tool(
142
+ name: "query_jobs",
143
+ description: "Search and filter background job profiles by queue, status, etc.",
144
+ input_schema: {
145
+ properties: {
146
+ queue: { type: "string", description: "Filter by queue name" },
147
+ status: { type: "string", description: "Filter by status (completed, failed)" },
148
+ limit: { type: "number", description: "Maximum number of results" }
149
+ }
150
+ },
151
+ handler: Tools::QueryJobs
152
+ ),
153
+ define_tool(
154
+ name: "clear_profiles",
155
+ description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
156
+ input_schema: {
157
+ properties: {
158
+ type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs" }
159
+ }
160
+ },
161
+ handler: Tools::ClearProfiles
162
+ )
163
+ ]
164
+ end
165
+
166
+ def define_tool(name:, description:, input_schema:, handler:)
167
+ ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
168
+ result = handler.call(args.transform_keys(&:to_s))
169
+ ::MCP::Tool::Response.new(result)
170
+ end
171
+ end
172
+
173
+ def build_resources
174
+ require_relative "resources/recent_requests"
175
+ require_relative "resources/slow_queries"
176
+ require_relative "resources/n1_patterns"
177
+ require_relative "resources/recent_jobs"
178
+
179
+ handlers = {
180
+ "profiler://recent" => Resources::RecentRequests,
181
+ "profiler://slow-queries" => Resources::SlowQueries,
182
+ "profiler://n1-patterns" => Resources::N1Patterns,
183
+ "profiler://recent-jobs" => Resources::RecentJobs
184
+ }
185
+
186
+ resources = [
187
+ ::MCP::Resource.new(
188
+ uri: "profiler://recent",
189
+ name: "Recent Requests",
190
+ description: "List of recently profiled requests",
191
+ mime_type: "application/json"
192
+ ),
193
+ ::MCP::Resource.new(
194
+ uri: "profiler://slow-queries",
195
+ name: "Slow SQL Queries",
196
+ description: "List of slow database queries across all profiles",
197
+ mime_type: "application/json"
198
+ ),
199
+ ::MCP::Resource.new(
200
+ uri: "profiler://n1-patterns",
201
+ name: "N+1 Query Patterns",
202
+ description: "Cross-profile N+1 query pattern detection across the last 100 profiles",
203
+ mime_type: "application/json"
204
+ ),
205
+ ::MCP::Resource.new(
206
+ uri: "profiler://recent-jobs",
207
+ name: "Recent Jobs",
208
+ description: "List of recently profiled background jobs",
209
+ mime_type: "application/json"
210
+ )
211
+ ]
212
+
213
+ [resources, handlers]
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class AnalyzeQueries
7
+ def self.call(params)
8
+ token = params["token"]
9
+ unless token
10
+ return [
11
+ {
12
+ type: "text",
13
+ text: "Error: token parameter is required"
14
+ }
15
+ ]
16
+ end
17
+
18
+ profile = Profiler.storage.load(token)
19
+ unless profile
20
+ return [
21
+ {
22
+ type: "text",
23
+ text: "Profile not found: #{token}"
24
+ }
25
+ ]
26
+ end
27
+
28
+ db_data = profile.collector_data("database")
29
+ unless db_data && db_data["queries"]
30
+ return [
31
+ {
32
+ type: "text",
33
+ text: "No database queries found in this profile"
34
+ }
35
+ ]
36
+ end
37
+
38
+ text = analyze_and_format(db_data["queries"])
39
+
40
+ [
41
+ {
42
+ type: "text",
43
+ text: text
44
+ }
45
+ ]
46
+ end
47
+
48
+ private
49
+
50
+ def self.analyze_and_format(queries)
51
+ lines = []
52
+ lines << "# SQL Query Analysis\n"
53
+
54
+ # Detect slow queries
55
+ slow_threshold = Profiler.configuration.slow_query_threshold
56
+ slow_queries = queries.select { |q| q["duration"] > slow_threshold }
57
+
58
+ if slow_queries.any?
59
+ lines << "## ⚠️ Slow Queries (> #{slow_threshold}ms)"
60
+ lines << "Found #{slow_queries.size} slow queries:\n"
61
+
62
+ slow_queries.first(5).each_with_index do |query, index|
63
+ lines << "### Query #{index + 1} - #{query['duration'].round(2)}ms"
64
+ lines << "```sql"
65
+ lines << query['sql']
66
+ lines << "```\n"
67
+ end
68
+
69
+ if slow_queries.size > 5
70
+ lines << "_... and #{slow_queries.size - 5} more slow queries_\n"
71
+ end
72
+ else
73
+ lines << "## ✅ No Slow Queries"
74
+ lines << "All queries executed in less than #{slow_threshold}ms\n"
75
+ end
76
+
77
+ # Detect duplicate queries (potential N+1)
78
+ query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
79
+ .transform_values(&:count)
80
+ .select { |_, count| count > 1 }
81
+
82
+ if query_counts.any?
83
+ lines << "## ⚠️ Duplicate Queries (Potential N+1)"
84
+ lines << "Found #{query_counts.size} duplicate query patterns:\n"
85
+
86
+ query_counts.sort_by { |_, count| -count }.first(5).each do |sql, count|
87
+ lines << "### Executed #{count} times:"
88
+ lines << "```sql"
89
+ lines << sql
90
+ lines << "```\n"
91
+ end
92
+
93
+ if query_counts.size > 5
94
+ lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n"
95
+ end
96
+ else
97
+ lines << "## ✅ No Duplicate Queries"
98
+ lines << "No potential N+1 query problems detected\n"
99
+ end
100
+
101
+ # Overall statistics
102
+ lines << "## Summary Statistics"
103
+ lines << "- **Total Queries:** #{queries.size}"
104
+ lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
105
+ lines << "- **Average Duration:** #{(queries.sum { |q| q['duration'] } / queries.size).round(2)}ms"
106
+ lines << "- **Slow Queries:** #{slow_queries.size}"
107
+ lines << "- **Duplicate Patterns:** #{query_counts.size}"
108
+ lines << "- **Cached Queries:** #{queries.count { |q| q['cached'] }}"
109
+
110
+ lines.join("\n")
111
+ end
112
+
113
+ def self.normalize_sql(sql)
114
+ # Normalize SQL by removing bind values for comparison
115
+ sql.gsub(/\$\d+/, '?')
116
+ .gsub(/\b\d+\b/, '?')
117
+ .gsub(/'[^']*'/, '?')
118
+ .gsub(/"[^"]*"/, '?')
119
+ .strip
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class ClearProfiles
7
+ def self.call(params)
8
+ type = params["type"]
9
+
10
+ if type && !%w[http job].include?(type)
11
+ return [{ type: "text", text: "Error: type must be 'http' or 'job'" }]
12
+ end
13
+
14
+ Profiler.storage.clear(type: type)
15
+
16
+ label = type ? "#{type} profiles" : "all profiles (requests and jobs)"
17
+ [{ type: "text", text: "Cleared #{label}." }]
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class GetProfileAjax
7
+ def self.call(params)
8
+ token = params["token"]
9
+ unless token
10
+ return [{ type: "text", text: "Error: token parameter is required" }]
11
+ end
12
+
13
+ profile = Profiler.storage.load(token)
14
+ unless profile
15
+ return [{ type: "text", text: "Profile not found: #{token}" }]
16
+ end
17
+
18
+ ajax_data = profile.collector_data("ajax")
19
+ unless ajax_data && ajax_data["total_requests"].to_i > 0
20
+ return [{ type: "text", text: "No AJAX requests found in this profile" }]
21
+ end
22
+
23
+ [{ type: "text", text: format_ajax(profile, ajax_data) }]
24
+ end
25
+
26
+ private
27
+
28
+ def self.format_ajax(profile, ajax_data)
29
+ lines = []
30
+ lines << "# AJAX Analysis: #{profile.token}\n"
31
+ lines << "**Request:** #{profile.method} #{profile.path}"
32
+ lines << "**Total AJAX Requests:** #{ajax_data['total_requests']}"
33
+ lines << "**Total Duration:** #{ajax_data['total_duration'].round(2)} ms\n"
34
+
35
+ if ajax_data["by_method"] && !ajax_data["by_method"].empty?
36
+ lines << "## By Method"
37
+ ajax_data["by_method"].each { |method, count| lines << "- **#{method}**: #{count}" }
38
+ lines << ""
39
+ end
40
+
41
+ if ajax_data["by_status"] && !ajax_data["by_status"].empty?
42
+ lines << "## By Status"
43
+ ajax_data["by_status"].each { |status, count| lines << "- **#{status}**: #{count}" }
44
+ lines << ""
45
+ end
46
+
47
+ if ajax_data["requests"] && !ajax_data["requests"].empty?
48
+ lines << "## Request List"
49
+ ajax_data["requests"].each_with_index do |req, index|
50
+ lines << "\n### Request #{index + 1}"
51
+ lines << "- **Method:** #{req['method']}"
52
+ lines << "- **Path:** #{req['path']}"
53
+ lines << "- **Status:** #{req['status']}"
54
+ lines << "- **Duration:** #{req['duration'].round(2)} ms"
55
+ lines << "- **Token:** #{req['token']}" if req['token']
56
+ lines << "- **Started At:** #{req['started_at']}" if req['started_at']
57
+ end
58
+ lines << ""
59
+ end
60
+
61
+ lines.join("\n")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end