rails-profiler 0.24.0 → 0.26.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 +994 -26
- data/app/controllers/profiler/api/console_controller.rb +46 -0
- data/app/controllers/profiler/api/profiles_controller.rb +1 -1
- 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 +13 -0
- data/lib/profiler/collectors/console_collector.rb +57 -0
- data/lib/profiler/collectors/database_collector.rb +1 -1
- data/lib/profiler/collectors/test_collector.rb +75 -0
- data/lib/profiler/configuration.rb +14 -1
- data/lib/profiler/console_profiler.rb +102 -0
- data/lib/profiler/instrumentation/irb_instrumentation.rb +21 -0
- data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
- data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
- data/lib/profiler/mcp/server.rb +77 -6
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -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 +21 -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 +23 -2
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -71,6 +71,9 @@ module Profiler
|
|
|
71
71
|
require_relative "tools/get_profile_http"
|
|
72
72
|
require_relative "tools/query_jobs"
|
|
73
73
|
require_relative "tools/query_mailers"
|
|
74
|
+
require_relative "tools/query_test_profiles"
|
|
75
|
+
require_relative "tools/get_test_profile_detail"
|
|
76
|
+
require_relative "tools/run_tests"
|
|
74
77
|
require_relative "tools/clear_profiles"
|
|
75
78
|
require_relative "tools/list_env_vars"
|
|
76
79
|
require_relative "tools/set_env_var"
|
|
@@ -203,12 +206,64 @@ module Profiler
|
|
|
203
206
|
},
|
|
204
207
|
handler: Tools::QueryMailers
|
|
205
208
|
),
|
|
209
|
+
define_tool(
|
|
210
|
+
name: "query_test_profiles",
|
|
211
|
+
description: "Search and filter test profiles (RSpec/Minitest) by test name, status, or duration.",
|
|
212
|
+
input_schema: {
|
|
213
|
+
properties: {
|
|
214
|
+
test_name: { type: "string", description: "Filter by test name (partial match)" },
|
|
215
|
+
status: { type: "string", description: "Filter by status: 'passed', 'failed', or 'pending'" },
|
|
216
|
+
min_duration: { type: "number", description: "Minimum duration in milliseconds" },
|
|
217
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
218
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, test_name, status, duration, queries, n1, token. Omit for all." },
|
|
219
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
handler: Tools::QueryTestProfiles
|
|
223
|
+
),
|
|
224
|
+
define_tool(
|
|
225
|
+
name: "get_test_profile",
|
|
226
|
+
description: "Get detailed data for a test profile: metadata, SQL queries, N+1 patterns, cache, exception. Use 'latest' as token for the most recent test.",
|
|
227
|
+
input_schema: {
|
|
228
|
+
properties: {
|
|
229
|
+
token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" }
|
|
230
|
+
},
|
|
231
|
+
required: ["token"]
|
|
232
|
+
},
|
|
233
|
+
handler: Tools::GetTestProfileDetail
|
|
234
|
+
),
|
|
235
|
+
define_tool(
|
|
236
|
+
name: "run_tests",
|
|
237
|
+
description: "Run test files and wait for results. Returns output, status, duration, and tokens of test profiles created. Synchronous with configurable timeout (default 120s).",
|
|
238
|
+
input_schema: {
|
|
239
|
+
properties: {
|
|
240
|
+
files: {
|
|
241
|
+
type: "array",
|
|
242
|
+
items: { type: "string" },
|
|
243
|
+
description: "Relative paths of test files to run (e.g. ['spec/models/user_spec.rb']). Omit to run all discovered tests."
|
|
244
|
+
},
|
|
245
|
+
framework: {
|
|
246
|
+
type: "string",
|
|
247
|
+
description: "Test framework: 'rspec' or 'minitest'. Auto-detected if omitted."
|
|
248
|
+
},
|
|
249
|
+
timeout_seconds: {
|
|
250
|
+
type: "number",
|
|
251
|
+
description: "Maximum seconds to wait for tests to finish (default: 120)."
|
|
252
|
+
},
|
|
253
|
+
max_output: {
|
|
254
|
+
type: "number",
|
|
255
|
+
description: "Maximum characters of output to return (tail). Default: 4000."
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
handler: Tools::RunTests
|
|
260
|
+
),
|
|
206
261
|
define_tool(
|
|
207
262
|
name: "clear_profiles",
|
|
208
|
-
description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only
|
|
263
|
+
description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job'/'test' to clear only that type.",
|
|
209
264
|
input_schema: {
|
|
210
265
|
properties: {
|
|
211
|
-
type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs" }
|
|
266
|
+
type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles" }
|
|
212
267
|
}
|
|
213
268
|
},
|
|
214
269
|
handler: Tools::ClearProfiles
|
|
@@ -279,12 +334,16 @@ module Profiler
|
|
|
279
334
|
require_relative "resources/slow_queries"
|
|
280
335
|
require_relative "resources/n1_patterns"
|
|
281
336
|
require_relative "resources/recent_jobs"
|
|
337
|
+
require_relative "resources/slow_tests"
|
|
338
|
+
require_relative "resources/failing_tests"
|
|
282
339
|
|
|
283
340
|
handlers = {
|
|
284
|
-
"profiler://recent"
|
|
285
|
-
"profiler://slow-queries"
|
|
286
|
-
"profiler://n1-patterns"
|
|
287
|
-
"profiler://recent-jobs"
|
|
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
|
|
288
347
|
}
|
|
289
348
|
|
|
290
349
|
resources = [
|
|
@@ -311,6 +370,18 @@ module Profiler
|
|
|
311
370
|
name: "Recent Jobs",
|
|
312
371
|
description: "List of recently profiled background jobs",
|
|
313
372
|
mime_type: "application/json"
|
|
373
|
+
),
|
|
374
|
+
::MCP::Resource.new(
|
|
375
|
+
uri: "profiler://slow-tests",
|
|
376
|
+
name: "Slow Tests",
|
|
377
|
+
description: "Top 10 slowest test profiles with query counts and N+1 detection",
|
|
378
|
+
mime_type: "application/json"
|
|
379
|
+
),
|
|
380
|
+
::MCP::Resource.new(
|
|
381
|
+
uri: "profiler://failing-tests",
|
|
382
|
+
name: "Failing Tests",
|
|
383
|
+
description: "Recent test profiles with status 'failed', including exception messages",
|
|
384
|
+
mime_type: "application/json"
|
|
314
385
|
)
|
|
315
386
|
]
|
|
316
387
|
|
|
@@ -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,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
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../test_runner/discovery"
|
|
4
|
+
require_relative "../../test_runner/run_store"
|
|
5
|
+
require_relative "../../test_runner/runner"
|
|
6
|
+
|
|
7
|
+
module Profiler
|
|
8
|
+
module MCP
|
|
9
|
+
module Tools
|
|
10
|
+
class RunTests
|
|
11
|
+
DEFAULT_TIMEOUT = 120
|
|
12
|
+
DEFAULT_MAX_OUTPUT = 4000
|
|
13
|
+
POLL_TIMEOUT = 10 # seconds per wait_for_output call
|
|
14
|
+
|
|
15
|
+
def self.call(params)
|
|
16
|
+
files = Array(params["files"])
|
|
17
|
+
framework = params["framework"]&.to_s
|
|
18
|
+
timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
|
|
19
|
+
max_output = (params["max_output"] || DEFAULT_MAX_OUTPUT).to_i
|
|
20
|
+
|
|
21
|
+
# Auto-detect framework if not provided
|
|
22
|
+
framework ||= begin
|
|
23
|
+
available = Profiler::TestRunner::Discovery.frameworks.map(&:to_s)
|
|
24
|
+
available.first || "rspec"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# If no files specified, discover all for the given framework
|
|
28
|
+
if files.empty?
|
|
29
|
+
tree = Profiler::TestRunner::Discovery.files(framework: framework.to_sym)
|
|
30
|
+
files = tree.flat_map { |dir| dir[:files].map { |f| f[:path] } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if files.empty?
|
|
34
|
+
return [{ type: "text", text: "No test files found for framework '#{framework}'." }]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
run_started_at = Time.now
|
|
38
|
+
run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
|
|
39
|
+
|
|
40
|
+
output_pos = 0
|
|
41
|
+
deadline = Time.now + timeout_secs
|
|
42
|
+
timed_out = false
|
|
43
|
+
|
|
44
|
+
until Profiler::TestRunner::RunStore::TERMINAL_STATUSES.include?(run.status)
|
|
45
|
+
remaining = (deadline - Time.now).to_i
|
|
46
|
+
if remaining <= 0
|
|
47
|
+
timed_out = true
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
wait = [POLL_TIMEOUT, remaining].min
|
|
52
|
+
result = Profiler::TestRunner.run_store.wait_for_output(run.id, position: output_pos, timeout: wait)
|
|
53
|
+
output_pos = result[:position]
|
|
54
|
+
break if result[:finished]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
full_output = run.output_lines.join
|
|
58
|
+
tail_output = full_output.length > max_output ? "…(truncated)\n" + full_output[-(max_output)..] : full_output
|
|
59
|
+
|
|
60
|
+
# Collect test profiles created during this run
|
|
61
|
+
profile_tokens = collect_run_profiles(run_started_at)
|
|
62
|
+
|
|
63
|
+
[{ type: "text", text: format_result(run, tail_output, timed_out, profile_tokens, files, framework) }]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def self.collect_run_profiles(since)
|
|
69
|
+
Profiler.storage.list(limit: 500).select do |p|
|
|
70
|
+
p.profile_type == "test" && p.started_at && p.started_at >= since
|
|
71
|
+
end.map(&:token)
|
|
72
|
+
rescue
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.format_result(run, output, timed_out, profile_tokens, files, framework)
|
|
77
|
+
lines = []
|
|
78
|
+
lines << "# Test Run #{timed_out ? "(timed out)" : ""}\n"
|
|
79
|
+
|
|
80
|
+
lines << "## Summary"
|
|
81
|
+
lines << "| Field | Value |"
|
|
82
|
+
lines << "|-------|-------|"
|
|
83
|
+
lines << "| Run ID | `#{run.id}` |"
|
|
84
|
+
lines << "| Framework | #{framework} |"
|
|
85
|
+
lines << "| Files | #{files.size} |"
|
|
86
|
+
lines << "| Status | **#{run.status}** |"
|
|
87
|
+
lines << "| Exit code | #{run.exit_code.inspect} |"
|
|
88
|
+
lines << "| Duration | #{run.to_h[:duration]&.round(0)}ms |"
|
|
89
|
+
|
|
90
|
+
if timed_out
|
|
91
|
+
lines << ""
|
|
92
|
+
lines << "> ⚠ Timed out — run is still in progress. Use run ID `#{run.id}` to check later."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
lines << "\n## Output (last #{output.length} chars)"
|
|
96
|
+
lines << "```"
|
|
97
|
+
lines << output.strip
|
|
98
|
+
lines << "```"
|
|
99
|
+
|
|
100
|
+
if profile_tokens.any?
|
|
101
|
+
lines << "\n## Test Profiles Created (#{profile_tokens.size})"
|
|
102
|
+
lines << "Use `get_test_profile` with any of these tokens for detailed SQL/cache/exception data:"
|
|
103
|
+
profile_tokens.first(10).each { |t| lines << "- `#{t}`" }
|
|
104
|
+
lines << "- _(#{profile_tokens.size - 10} more…)_" if profile_tokens.size > 10
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
lines.join("\n")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Profiler
|
|
|
14
14
|
# Set default configuration for Rails environment
|
|
15
15
|
Profiler.configure do |config|
|
|
16
16
|
config.enabled = Rails.env.development? || Rails.env.test?
|
|
17
|
-
config.storage = Rails.env.development? ? :file : :memory
|
|
17
|
+
config.storage = (Rails.env.development? || Rails.env.test?) ? :file : :memory
|
|
18
18
|
config.tmp_path = Rails.root.join("tmp", "rails-profiler")
|
|
19
|
+
config.track_tests = Rails.env.test?
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -56,6 +57,17 @@ module Profiler
|
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
initializer "profiler.setup_test_profiler" do
|
|
61
|
+
next unless Profiler.configuration.enabled && Profiler.configuration.track_tests
|
|
62
|
+
|
|
63
|
+
require_relative "test_profiler"
|
|
64
|
+
require_relative "test_helpers/rspec_support"
|
|
65
|
+
require_relative "test_helpers/minitest_support"
|
|
66
|
+
require_relative "test_runner/discovery"
|
|
67
|
+
require_relative "test_runner/run_store"
|
|
68
|
+
require_relative "test_runner/runner"
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
initializer "profiler.setup_job_instrumentation" do
|
|
60
72
|
next unless Profiler.configuration.enabled && Profiler.configuration.track_jobs
|
|
61
73
|
|
|
@@ -81,6 +93,14 @@ module Profiler
|
|
|
81
93
|
end
|
|
82
94
|
end
|
|
83
95
|
|
|
96
|
+
console do
|
|
97
|
+
next unless Profiler.configuration.enabled && Profiler.configuration.track_console
|
|
98
|
+
|
|
99
|
+
require_relative "console_profiler"
|
|
100
|
+
require_relative "instrumentation/irb_instrumentation"
|
|
101
|
+
IRB::Context.prepend(Profiler::Instrumentation::IrbInstrumentation)
|
|
102
|
+
end
|
|
103
|
+
|
|
84
104
|
rake_tasks do
|
|
85
105
|
load "profiler/tasks/profiler.rake"
|
|
86
106
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_profiler"
|
|
4
|
+
require_relative "reporter"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module TestHelpers
|
|
8
|
+
# Minitest integration for the profiler.
|
|
9
|
+
#
|
|
10
|
+
# Usage in test_helper.rb:
|
|
11
|
+
#
|
|
12
|
+
# require 'profiler/test_helpers/minitest_support'
|
|
13
|
+
# Profiler::TestHelpers::MinitestSupport.install
|
|
14
|
+
module MinitestSupport
|
|
15
|
+
def self.install
|
|
16
|
+
require "minitest"
|
|
17
|
+
|
|
18
|
+
Minitest::Test.prepend(RunWrapper)
|
|
19
|
+
|
|
20
|
+
Minitest.after_run do
|
|
21
|
+
Profiler::TestHelpers::Reporter.print
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module RunWrapper
|
|
26
|
+
def run
|
|
27
|
+
Profiler::TestProfiler.profile(
|
|
28
|
+
test_name: "#{self.class.name}##{name}",
|
|
29
|
+
test_file: self.class.instance_method(name).source_location&.first || "",
|
|
30
|
+
test_line: self.class.instance_method(name).source_location&.last || 0,
|
|
31
|
+
framework: :minitest
|
|
32
|
+
) { super }
|
|
33
|
+
rescue NameError
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Reporter
|
|
6
|
+
WIDTH = 68
|
|
7
|
+
|
|
8
|
+
def self.print
|
|
9
|
+
return unless Profiler.enabled?
|
|
10
|
+
|
|
11
|
+
profiles = Profiler.storage.list(limit: 1000).select { |p| p.profile_type == "test" }
|
|
12
|
+
return if profiles.empty?
|
|
13
|
+
|
|
14
|
+
passed = profiles.count { |p| test_status(p) == "passed" }
|
|
15
|
+
failed = profiles.count { |p| test_status(p) == "failed" }
|
|
16
|
+
pending = profiles.count { |p| test_status(p) == "pending" }
|
|
17
|
+
|
|
18
|
+
total_queries = profiles.sum { |p| (p.collector_data("database") || {})["total_queries"].to_i }
|
|
19
|
+
n1_profiles = profiles.select { |p| has_n1?(p) }
|
|
20
|
+
total_ms = profiles.sum(&:duration).round(0).to_i
|
|
21
|
+
|
|
22
|
+
lines = []
|
|
23
|
+
lines << ""
|
|
24
|
+
lines << cyan("┌─ Profiler Test Report " + "─" * (WIDTH - 23) + "┐")
|
|
25
|
+
lines << cyan("│") + " #{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending" +
|
|
26
|
+
pad_to(WIDTH - 1, "#{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending") +
|
|
27
|
+
cyan("│")
|
|
28
|
+
lines << cyan("│") + " Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected" +
|
|
29
|
+
pad_to(WIDTH - 1, "Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected") +
|
|
30
|
+
cyan("│")
|
|
31
|
+
|
|
32
|
+
# Slowest tests
|
|
33
|
+
slowest = profiles.sort_by { |p| -p.duration }.first(5)
|
|
34
|
+
lines << cyan("├─ Slowest tests " + "─" * (WIDTH - 17) + "┤")
|
|
35
|
+
slowest.each_with_index do |p, i|
|
|
36
|
+
test_data = p.collector_data("test") || {}
|
|
37
|
+
db_data = p.collector_data("database") || {}
|
|
38
|
+
name = truncate(test_data["test_name"] || p.path, 44)
|
|
39
|
+
queries = db_data["total_queries"].to_i
|
|
40
|
+
n1_flag = has_n1?(p) ? " #{red("⚠ N+1")}" : ""
|
|
41
|
+
stats_raw = "#{p.duration.round(0).to_i}ms #{queries}q"
|
|
42
|
+
pad = [0, WIDTH - 6 - name.length - stats_raw.length].max
|
|
43
|
+
lines << cyan("│") + " #{i + 1}. #{name}#{" " * pad}#{stats_raw}#{n1_flag}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# N+1 patterns
|
|
47
|
+
if n1_profiles.any?
|
|
48
|
+
lines << cyan("├─ N+1 patterns " + "─" * (WIDTH - 16) + "┤")
|
|
49
|
+
n1_profiles.first(3).each do |p|
|
|
50
|
+
test_data = p.collector_data("test") || {}
|
|
51
|
+
db_data = p.collector_data("database") || {}
|
|
52
|
+
queries = db_data["queries"] || []
|
|
53
|
+
pattern = top_n1_pattern(queries)
|
|
54
|
+
name = truncate(test_data["test_name"] || p.path, WIDTH - 4)
|
|
55
|
+
lines << cyan("│") + " #{yellow("▸")} #{yellow(truncate(pattern.to_s, WIDTH - 6))}"
|
|
56
|
+
lines << cyan("│") + " → #{name}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Failed tests
|
|
61
|
+
failed_profiles = profiles.select { |p| test_status(p) == "failed" }
|
|
62
|
+
if failed_profiles.any?
|
|
63
|
+
lines << cyan("├─ Failed tests " + "─" * (WIDTH - 15) + "┤")
|
|
64
|
+
failed_profiles.first(5).each do |p|
|
|
65
|
+
test_data = p.collector_data("test") || {}
|
|
66
|
+
name = test_data["test_name"] || p.path
|
|
67
|
+
exception = test_data["exception_message"]
|
|
68
|
+
lines << cyan("│") + " #{red("✗")} #{truncate(name, WIDTH - 5)}"
|
|
69
|
+
lines << cyan("│") + " #{truncate(exception.to_s, WIDTH - 6)}" if exception
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines << cyan("└" + "─" * WIDTH + "┘")
|
|
74
|
+
lines << ""
|
|
75
|
+
|
|
76
|
+
$stdout.puts lines.join("\n")
|
|
77
|
+
rescue => e
|
|
78
|
+
warn "Profiler Reporter: failed to generate report: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.has_n1?(profile)
|
|
82
|
+
db_data = profile.collector_data("database") || {}
|
|
83
|
+
queries = db_data["queries"] || []
|
|
84
|
+
return false if queries.size < 3
|
|
85
|
+
|
|
86
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.test_status(profile)
|
|
90
|
+
(profile.collector_data("test") || {})["status"] || "passed"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.top_n1_pattern(queries)
|
|
94
|
+
return "" if queries.size < 3
|
|
95
|
+
|
|
96
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }
|
|
97
|
+
.select { |_, qs| qs.size >= 3 }
|
|
98
|
+
.max_by { |_, qs| qs.size }
|
|
99
|
+
&.first || ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.normalize_sql(sql)
|
|
103
|
+
sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.truncate(str, max)
|
|
107
|
+
str = str.to_s
|
|
108
|
+
str.length > max ? str[0, max - 3] + "..." : str
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.pad_to(width, str)
|
|
112
|
+
visible = str.gsub(/\e\[[0-9;]*m/, "")
|
|
113
|
+
" " * [0, width - visible.length - 1].max
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.cyan(str) = "\e[36m#{str}\e[0m"
|
|
117
|
+
def self.yellow(str) = "\e[33m#{str}\e[0m"
|
|
118
|
+
def self.red(str) = "\e[31m#{str}\e[0m"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|