rails-profiler 0.5.0 → 0.6.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/lib/profiler/mcp/body_formatter.rb +50 -0
- data/lib/profiler/mcp/file_cache.rb +31 -0
- data/lib/profiler/mcp/path_extractor.rb +30 -0
- data/lib/profiler/mcp/server.rb +22 -5
- data/lib/profiler/mcp/tools/analyze_queries.rb +41 -44
- data/lib/profiler/mcp/tools/get_profile_detail.rb +268 -208
- data/lib/profiler/mcp/tools/get_profile_http.rb +23 -16
- data/lib/profiler/mcp/tools/query_jobs.rb +37 -14
- data/lib/profiler/mcp/tools/query_profiles.rb +42 -27
- data/lib/profiler/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a0b42cc0bcdfe940609dbb07f9ee29a23cd00495c738711950749b22e58d664
|
|
4
|
+
data.tar.gz: b61678855bf48c082f4776773ce1754328c4e6b6d1672ea2542a836f79d9602a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bde96bed30d2bc7fda19f62a10a2a1b9d55e1e73b83bc91ae0bba63ae7fb58a0d66ca692cb3db7ca66d17c29cfc69d190123095d6cc8f428e33e4d3030733b70
|
|
7
|
+
data.tar.gz: 310c1ddfc31ab16fddd783db87e24bbe10a5c48058f678215f389f66056ede8af928438f7a350fd4f9ab8828f49f6daf4cdffd4f41634c76b3f9d1f0a0840f0f
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module BodyFormatter
|
|
6
|
+
# Formats a body string for MCP output.
|
|
7
|
+
#
|
|
8
|
+
# params keys used:
|
|
9
|
+
# "save_bodies" (boolean) — save to /tmp/rails-profiler/{token}/{name} and return path
|
|
10
|
+
# "max_body_size" (number) — truncate inline body at N chars
|
|
11
|
+
# "json_path" (string) — JSONPath to extract from body when save_bodies is true
|
|
12
|
+
# "xml_path" (string) — XPath to extract from body when save_bodies is true
|
|
13
|
+
#
|
|
14
|
+
# Returns a formatted string ready to embed in markdown, or nil if body is blank.
|
|
15
|
+
def self.format_body(token, name, body, encoding, params)
|
|
16
|
+
return " *(binary, base64 encoded)*" if encoding == "base64"
|
|
17
|
+
return nil if body.nil? || body.empty?
|
|
18
|
+
|
|
19
|
+
if params["save_bodies"]
|
|
20
|
+
path = FileCache.save(token, name, body)
|
|
21
|
+
if path
|
|
22
|
+
result = " *(saved → `#{path}`)*"
|
|
23
|
+
if params["json_path"]
|
|
24
|
+
extracted = PathExtractor.extract_json(body, params["json_path"])
|
|
25
|
+
result += "\n `#{params['json_path']}` → `#{extracted}`"
|
|
26
|
+
elsif params["xml_path"]
|
|
27
|
+
extracted = PathExtractor.extract_xml(body, params["xml_path"])
|
|
28
|
+
result += "\n `#{params['xml_path']}` → `#{extracted}`"
|
|
29
|
+
end
|
|
30
|
+
result
|
|
31
|
+
else
|
|
32
|
+
truncate_body(body, params["max_body_size"])
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
truncate_body(body, params["max_body_size"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.truncate_body(body, max_size)
|
|
40
|
+
max = max_size&.to_i
|
|
41
|
+
content = if max && body.length > max
|
|
42
|
+
"#{body[0, max]}\n... [truncated, #{body.length} chars total]"
|
|
43
|
+
else
|
|
44
|
+
body
|
|
45
|
+
end
|
|
46
|
+
"```\n#{content}\n```"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
class FileCache
|
|
8
|
+
BASE_DIR = "/tmp/rails-profiler"
|
|
9
|
+
|
|
10
|
+
def self.save(token, name, content)
|
|
11
|
+
cleanup if rand < 0.05
|
|
12
|
+
|
|
13
|
+
dir = File.join(BASE_DIR, token)
|
|
14
|
+
FileUtils.mkdir_p(dir)
|
|
15
|
+
path = File.join(dir, name)
|
|
16
|
+
File.write(path, content)
|
|
17
|
+
path
|
|
18
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.cleanup(max_age: 3600)
|
|
23
|
+
return unless Dir.exist?(BASE_DIR)
|
|
24
|
+
|
|
25
|
+
Dir.glob(File.join(BASE_DIR, "*")).each do |dir|
|
|
26
|
+
FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
module PathExtractor
|
|
8
|
+
# Supports dot-notation JSONPath: $.key, $.key.sub, $.array[0].key
|
|
9
|
+
def self.extract_json(content, path)
|
|
10
|
+
data = JSON.parse(content)
|
|
11
|
+
segments = path.sub(/\A\$\.?/, "").split(".").flat_map do |seg|
|
|
12
|
+
seg =~ /\A(.+)\[(\d+)\]\z/ ? [$1, $2.to_i] : [seg]
|
|
13
|
+
end
|
|
14
|
+
result = segments.reduce(data) do |obj, seg|
|
|
15
|
+
obj.is_a?(Array) ? obj[seg.to_i] : obj[seg]
|
|
16
|
+
end
|
|
17
|
+
JSON.generate(result)
|
|
18
|
+
rescue => e
|
|
19
|
+
"JSONPath error: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.extract_xml(content, xpath)
|
|
23
|
+
require "nokogiri"
|
|
24
|
+
Nokogiri::XML(content).xpath(xpath).to_s
|
|
25
|
+
rescue => e
|
|
26
|
+
"XPath error: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -59,6 +59,9 @@ module Profiler
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
61
|
def build_tools
|
|
62
|
+
require_relative "file_cache"
|
|
63
|
+
require_relative "path_extractor"
|
|
64
|
+
require_relative "body_formatter"
|
|
62
65
|
require_relative "tools/query_profiles"
|
|
63
66
|
require_relative "tools/get_profile_detail"
|
|
64
67
|
require_relative "tools/analyze_queries"
|
|
@@ -78,7 +81,9 @@ module Profiler
|
|
|
78
81
|
method: { type: "string", description: "Filter by HTTP method (GET, POST, etc.)" },
|
|
79
82
|
min_duration: { type: "number", description: "Minimum duration in milliseconds" },
|
|
80
83
|
profile_type: { type: "string", description: "Filter by type: 'http' or 'job'" },
|
|
81
|
-
limit: { type: "number", description: "Maximum number of results" }
|
|
84
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
85
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, type, method, path, duration, queries, status, token. Omit for all." },
|
|
86
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
|
|
82
87
|
}
|
|
83
88
|
},
|
|
84
89
|
handler: Tools::QueryProfiles
|
|
@@ -88,7 +93,12 @@ module Profiler
|
|
|
88
93
|
description: "Get detailed profile data by token. Use 'latest' as token to get the most recent profile.",
|
|
89
94
|
input_schema: {
|
|
90
95
|
properties: {
|
|
91
|
-
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
96
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
97
|
+
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, routes, dumps. Omit for all." },
|
|
98
|
+
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
99
|
+
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
100
|
+
json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
101
|
+
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." }
|
|
92
102
|
},
|
|
93
103
|
required: ["token"]
|
|
94
104
|
},
|
|
@@ -99,7 +109,8 @@ module Profiler
|
|
|
99
109
|
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries. Use 'latest' as token to analyze the most recent profile.",
|
|
100
110
|
input_schema: {
|
|
101
111
|
properties: {
|
|
102
|
-
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
112
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
113
|
+
summary_only: { type: "boolean", description: "Return only the summary statistics section, skipping slow query and N+1 details." }
|
|
103
114
|
},
|
|
104
115
|
required: ["token"]
|
|
105
116
|
},
|
|
@@ -133,7 +144,11 @@ module Profiler
|
|
|
133
144
|
input_schema: {
|
|
134
145
|
properties: {
|
|
135
146
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
136
|
-
domain: { type: "string", description: "Filter outbound requests by domain (partial match on host)" }
|
|
147
|
+
domain: { type: "string", description: "Filter outbound requests by domain (partial match on host)" },
|
|
148
|
+
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
149
|
+
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
150
|
+
json_path: { type: "string", description: "JSONPath expression to extract from response bodies (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
151
|
+
xml_path: { type: "string", description: "XPath expression to extract from response bodies (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
|
|
137
152
|
},
|
|
138
153
|
required: ["token"]
|
|
139
154
|
},
|
|
@@ -146,7 +161,9 @@ module Profiler
|
|
|
146
161
|
properties: {
|
|
147
162
|
queue: { type: "string", description: "Filter by queue name" },
|
|
148
163
|
status: { type: "string", description: "Filter by status (completed, failed)" },
|
|
149
|
-
limit: { type: "number", description: "Maximum number of results" }
|
|
164
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
165
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, job_class, queue, status, duration, token. Omit for all." },
|
|
166
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns jobs older than this." }
|
|
150
167
|
}
|
|
151
168
|
},
|
|
152
169
|
handler: Tools::QueryJobs
|
|
@@ -39,7 +39,8 @@ module Profiler
|
|
|
39
39
|
]
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
summary_only = params["summary_only"] == true || params["summary_only"] == "true"
|
|
43
|
+
text = analyze_and_format(db_data["queries"], summary_only: summary_only)
|
|
43
44
|
|
|
44
45
|
[
|
|
45
46
|
{
|
|
@@ -51,58 +52,55 @@ module Profiler
|
|
|
51
52
|
|
|
52
53
|
private
|
|
53
54
|
|
|
54
|
-
def self.analyze_and_format(queries)
|
|
55
|
+
def self.analyze_and_format(queries, summary_only: false)
|
|
55
56
|
lines = []
|
|
56
57
|
lines << "# SQL Query Analysis\n"
|
|
57
58
|
|
|
58
|
-
# Detect slow queries
|
|
59
59
|
slow_threshold = Profiler.configuration.slow_query_threshold
|
|
60
60
|
slow_queries = queries.select { |q| q["duration"] > slow_threshold }
|
|
61
|
-
|
|
62
|
-
if slow_queries.any?
|
|
63
|
-
lines << "## ⚠️ Slow Queries (> #{slow_threshold}ms)"
|
|
64
|
-
lines << "Found #{slow_queries.size} slow queries:\n"
|
|
65
|
-
|
|
66
|
-
slow_queries.first(5).each_with_index do |query, index|
|
|
67
|
-
lines << "### Query #{index + 1} - #{query['duration'].round(2)}ms"
|
|
68
|
-
lines << "```sql"
|
|
69
|
-
lines << query['sql']
|
|
70
|
-
lines << "```\n"
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
if slow_queries.size > 5
|
|
74
|
-
lines << "_... and #{slow_queries.size - 5} more slow queries_\n"
|
|
75
|
-
end
|
|
76
|
-
else
|
|
77
|
-
lines << "## ✅ No Slow Queries"
|
|
78
|
-
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Detect duplicate queries (potential N+1)
|
|
82
61
|
query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
|
|
83
62
|
.transform_values(&:count)
|
|
84
63
|
.select { |_, count| count > 1 }
|
|
85
64
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
65
|
+
unless summary_only
|
|
66
|
+
# Detect slow queries
|
|
67
|
+
if slow_queries.any?
|
|
68
|
+
lines << "## ⚠️ Slow Queries (> #{slow_threshold}ms)"
|
|
69
|
+
lines << "Found #{slow_queries.size} slow queries:\n"
|
|
70
|
+
|
|
71
|
+
slow_queries.first(5).each_with_index do |query, index|
|
|
72
|
+
lines << "### Query #{index + 1} - #{query['duration'].round(2)}ms"
|
|
73
|
+
lines << "```sql"
|
|
74
|
+
lines << query["sql"]
|
|
75
|
+
lines << "```\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
lines << "_... and #{slow_queries.size - 5} more slow queries_\n" if slow_queries.size > 5
|
|
79
|
+
else
|
|
80
|
+
lines << "## ✅ No Slow Queries"
|
|
81
|
+
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
95
82
|
end
|
|
96
83
|
|
|
97
|
-
|
|
98
|
-
|
|
84
|
+
# Detect duplicate queries (potential N+1)
|
|
85
|
+
if query_counts.any?
|
|
86
|
+
lines << "## ⚠️ Duplicate Queries (Potential N+1)"
|
|
87
|
+
lines << "Found #{query_counts.size} duplicate query patterns:\n"
|
|
88
|
+
|
|
89
|
+
query_counts.sort_by { |_, count| -count }.first(5).each do |sql, count|
|
|
90
|
+
lines << "### Executed #{count} times:"
|
|
91
|
+
lines << "```sql"
|
|
92
|
+
lines << sql
|
|
93
|
+
lines << "```\n"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n" if query_counts.size > 5
|
|
97
|
+
else
|
|
98
|
+
lines << "## ✅ No Duplicate Queries"
|
|
99
|
+
lines << "No potential N+1 query problems detected\n"
|
|
99
100
|
end
|
|
100
|
-
else
|
|
101
|
-
lines << "## ✅ No Duplicate Queries"
|
|
102
|
-
lines << "No potential N+1 query problems detected\n"
|
|
103
101
|
end
|
|
104
102
|
|
|
105
|
-
#
|
|
103
|
+
# Summary statistics always included
|
|
106
104
|
lines << "## Summary Statistics"
|
|
107
105
|
lines << "- **Total Queries:** #{queries.size}"
|
|
108
106
|
lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
|
|
@@ -115,11 +113,10 @@ module Profiler
|
|
|
115
113
|
end
|
|
116
114
|
|
|
117
115
|
def self.normalize_sql(sql)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.gsub(
|
|
121
|
-
.gsub(/
|
|
122
|
-
.gsub(/"[^"]*"/, '?')
|
|
116
|
+
sql.gsub(/\$\d+/, "?")
|
|
117
|
+
.gsub(/\b\d+\b/, "?")
|
|
118
|
+
.gsub(/'[^']*'/, "?")
|
|
119
|
+
.gsub(/"[^"]*"/, "?")
|
|
123
120
|
.strip
|
|
124
121
|
end
|
|
125
122
|
end
|
|
@@ -32,7 +32,7 @@ module Profiler
|
|
|
32
32
|
]
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
text = format_profile_detail(profile)
|
|
35
|
+
text = format_profile_detail(profile, params)
|
|
36
36
|
|
|
37
37
|
[
|
|
38
38
|
{
|
|
@@ -44,7 +44,29 @@ module Profiler
|
|
|
44
44
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
|
-
def self.format_profile_detail(profile)
|
|
47
|
+
def self.format_profile_detail(profile, params = {})
|
|
48
|
+
requested = params["sections"]&.map(&:to_s)
|
|
49
|
+
want = ->(name) { requested.nil? || requested.include?(name) }
|
|
50
|
+
|
|
51
|
+
lines = []
|
|
52
|
+
lines += section_overview(profile) if want.("overview")
|
|
53
|
+
lines += section_exception(profile) if want.("exception")
|
|
54
|
+
lines += section_job(profile) if want.("job")
|
|
55
|
+
lines += section_request(profile, params) if want.("request")
|
|
56
|
+
lines += section_response(profile, params) if want.("response")
|
|
57
|
+
lines += section_curl(profile) if want.("curl")
|
|
58
|
+
lines += section_database(profile) if want.("database")
|
|
59
|
+
lines += section_performance(profile) if want.("performance")
|
|
60
|
+
lines += section_views(profile) if want.("views")
|
|
61
|
+
lines += section_cache(profile) if want.("cache")
|
|
62
|
+
lines += section_ajax(profile) if want.("ajax")
|
|
63
|
+
lines += section_http(profile) if want.("http")
|
|
64
|
+
lines += section_routes(profile) if want.("routes")
|
|
65
|
+
lines += section_dumps(profile) if want.("dumps")
|
|
66
|
+
lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.section_overview(profile)
|
|
48
70
|
lines = []
|
|
49
71
|
lines << "# Profile Details: #{profile.token}\n"
|
|
50
72
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
@@ -52,278 +74,316 @@ module Profiler
|
|
|
52
74
|
lines << "**Duration:** #{profile.duration.round(2)} ms"
|
|
53
75
|
lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
|
|
54
76
|
lines << "**Time:** #{profile.started_at}\n"
|
|
77
|
+
lines
|
|
78
|
+
end
|
|
55
79
|
|
|
56
|
-
|
|
80
|
+
def self.section_exception(profile)
|
|
81
|
+
lines = []
|
|
57
82
|
exception_data = profile.collector_data("exception")
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lines << ""
|
|
83
|
+
return lines unless exception_data && exception_data["exception_class"]
|
|
84
|
+
|
|
85
|
+
lines << "## Exception"
|
|
86
|
+
lines << "**Class:** #{exception_data['exception_class']}"
|
|
87
|
+
lines << "**Message:** #{exception_data['message']}\n"
|
|
88
|
+
|
|
89
|
+
backtrace = exception_data["backtrace"]
|
|
90
|
+
if backtrace && !backtrace.empty?
|
|
91
|
+
lines << "### Backtrace"
|
|
92
|
+
backtrace.first(20).each do |frame|
|
|
93
|
+
marker = frame["app_frame"] ? "★ " : " "
|
|
94
|
+
lines << "#{marker}#{frame['location']}"
|
|
71
95
|
end
|
|
96
|
+
lines << ""
|
|
72
97
|
end
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
73
100
|
|
|
74
|
-
|
|
101
|
+
def self.section_job(profile)
|
|
102
|
+
lines = []
|
|
75
103
|
job_data = profile.collector_data("job")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
lines << ""
|
|
104
|
+
return lines unless job_data && job_data["job_class"]
|
|
105
|
+
|
|
106
|
+
lines << "## Job"
|
|
107
|
+
lines << "- Class: #{job_data['job_class']}"
|
|
108
|
+
lines << "- Job ID: #{job_data['job_id']}"
|
|
109
|
+
lines << "- Queue: #{job_data['queue']}"
|
|
110
|
+
lines << "- Executions: #{job_data['executions']}"
|
|
111
|
+
lines << "- Status: #{job_data['status']}"
|
|
112
|
+
lines << "- Error: #{job_data['error']}" if job_data["error"]
|
|
113
|
+
if job_data["arguments"] && !job_data["arguments"].empty?
|
|
114
|
+
lines << "- Arguments: #{job_data['arguments'].map(&:to_s).join(', ')}"
|
|
88
115
|
end
|
|
116
|
+
lines << ""
|
|
117
|
+
lines
|
|
118
|
+
end
|
|
89
119
|
|
|
90
|
-
|
|
120
|
+
def self.section_request(profile, params)
|
|
121
|
+
lines = []
|
|
91
122
|
req_data = profile.collector_data("request")
|
|
92
|
-
|
|
93
|
-
params = req_data["params"]
|
|
94
|
-
headers = req_data["headers"]
|
|
95
|
-
|
|
96
|
-
if params && !params.empty?
|
|
97
|
-
lines << "## Request Params"
|
|
98
|
-
params.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
99
|
-
lines << ""
|
|
100
|
-
end
|
|
123
|
+
return lines unless req_data
|
|
101
124
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
headers.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
105
|
-
lines << ""
|
|
106
|
-
end
|
|
125
|
+
request_params = req_data["params"]
|
|
126
|
+
headers = req_data["headers"]
|
|
107
127
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
if request_params && !request_params.empty?
|
|
129
|
+
lines << "## Request Params"
|
|
130
|
+
request_params.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
131
|
+
lines << ""
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if headers && !headers.empty?
|
|
135
|
+
lines << "## Request Headers"
|
|
136
|
+
headers.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
137
|
+
lines << ""
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
req_body = req_data["request_body"]
|
|
141
|
+
if req_body && !req_body.empty?
|
|
142
|
+
lines << "## Request Body"
|
|
143
|
+
formatted = BodyFormatter.format_body(
|
|
144
|
+
profile.token,
|
|
145
|
+
"request_body",
|
|
146
|
+
req_body,
|
|
147
|
+
req_data["request_body_encoding"],
|
|
148
|
+
params
|
|
149
|
+
)
|
|
150
|
+
lines << formatted if formatted
|
|
151
|
+
lines << ""
|
|
121
152
|
end
|
|
153
|
+
lines
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.section_response(profile, params)
|
|
157
|
+
lines = []
|
|
122
158
|
|
|
123
|
-
# Response Headers section
|
|
124
159
|
if profile.response_headers&.any?
|
|
125
160
|
lines << "## Response Headers"
|
|
126
161
|
profile.response_headers.each { |k, v| lines << "- **#{k}**: #{v}" }
|
|
127
162
|
lines << ""
|
|
128
163
|
end
|
|
129
164
|
|
|
130
|
-
# Response Body section
|
|
131
165
|
resp_body = profile.response_body
|
|
132
166
|
if resp_body && !resp_body.empty?
|
|
133
|
-
enc = profile.response_body_encoding
|
|
134
167
|
lines << "## Response Body"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
168
|
+
formatted = BodyFormatter.format_body(
|
|
169
|
+
profile.token,
|
|
170
|
+
"response_body",
|
|
171
|
+
resp_body,
|
|
172
|
+
profile.response_body_encoding,
|
|
173
|
+
params
|
|
174
|
+
)
|
|
175
|
+
lines << formatted if formatted
|
|
142
176
|
lines << ""
|
|
143
177
|
end
|
|
178
|
+
lines
|
|
179
|
+
end
|
|
144
180
|
|
|
145
|
-
|
|
146
|
-
|
|
181
|
+
def self.section_curl(profile)
|
|
182
|
+
req_data = profile.collector_data("request")
|
|
183
|
+
lines = []
|
|
147
184
|
lines << "## Curl Command"
|
|
148
185
|
lines << "```bash"
|
|
149
|
-
lines << generate_curl(profile,
|
|
186
|
+
lines << generate_curl(profile, req_data)
|
|
150
187
|
lines << "```"
|
|
151
188
|
lines << ""
|
|
189
|
+
lines
|
|
190
|
+
end
|
|
152
191
|
|
|
153
|
-
|
|
192
|
+
def self.section_database(profile)
|
|
193
|
+
lines = []
|
|
154
194
|
db_data = profile.collector_data("database")
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
return lines unless db_data && db_data["total_queries"]
|
|
196
|
+
|
|
197
|
+
lines << "## Database"
|
|
198
|
+
lines << "- Total Queries: #{db_data['total_queries']}"
|
|
199
|
+
lines << "- Total Duration: #{db_data['total_duration'].round(2)} ms"
|
|
200
|
+
lines << "- Slow Queries: #{db_data['slow_queries']}"
|
|
201
|
+
lines << "- Cached Queries: #{db_data['cached_queries']}\n"
|
|
202
|
+
|
|
203
|
+
if db_data["queries"] && !db_data["queries"].empty?
|
|
204
|
+
lines << "### Query Details"
|
|
205
|
+
db_data["queries"].each_with_index do |query, index|
|
|
206
|
+
lines << "\n**Query #{index + 1}** (#{query['duration'].round(2)}ms):"
|
|
207
|
+
lines << "```sql"
|
|
208
|
+
lines << query["sql"]
|
|
209
|
+
lines << "```"
|
|
210
|
+
if query["backtrace"] && !query["backtrace"].empty?
|
|
211
|
+
lines << "_Backtrace:_"
|
|
212
|
+
query["backtrace"].first(3).each { |frame| lines << " #{frame}" }
|
|
173
213
|
end
|
|
174
214
|
end
|
|
175
|
-
lines << ""
|
|
176
215
|
end
|
|
216
|
+
lines << ""
|
|
217
|
+
lines
|
|
218
|
+
end
|
|
177
219
|
|
|
178
|
-
|
|
220
|
+
def self.section_performance(profile)
|
|
221
|
+
lines = []
|
|
179
222
|
perf_data = profile.collector_data("performance")
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
223
|
+
return lines unless perf_data && perf_data["total_events"]
|
|
224
|
+
|
|
225
|
+
lines << "## Performance Timeline"
|
|
226
|
+
lines << "- Total Events: #{perf_data['total_events']}"
|
|
227
|
+
lines << "- Total Duration: #{perf_data['total_duration'].round(2)} ms\n"
|
|
228
|
+
|
|
229
|
+
if perf_data["events"] && !perf_data["events"].empty?
|
|
230
|
+
lines << "### Events"
|
|
231
|
+
perf_data["events"].each do |event|
|
|
232
|
+
lines << "- **#{event['name']}**: #{event['duration'].round(2)} ms"
|
|
190
233
|
end
|
|
191
|
-
lines << ""
|
|
192
234
|
end
|
|
235
|
+
lines << ""
|
|
236
|
+
lines
|
|
237
|
+
end
|
|
193
238
|
|
|
194
|
-
|
|
239
|
+
def self.section_views(profile)
|
|
240
|
+
lines = []
|
|
195
241
|
view_data = profile.collector_data("view")
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
lines << ""
|
|
242
|
+
return lines unless view_data && (view_data["total_views"] || view_data["total_partials"])
|
|
243
|
+
|
|
244
|
+
lines << "## View Rendering"
|
|
245
|
+
lines << "- Templates: #{view_data['total_views']}"
|
|
246
|
+
lines << "- Partials: #{view_data['total_partials']}"
|
|
247
|
+
lines << "- Total Duration: #{view_data['total_duration'].round(2)} ms\n"
|
|
248
|
+
|
|
249
|
+
if view_data["views"] && !view_data["views"].empty?
|
|
250
|
+
lines << "### Templates"
|
|
251
|
+
view_data["views"].each do |view|
|
|
252
|
+
lines << "- `#{view['identifier']}` — #{view['duration'].round(2)} ms"
|
|
208
253
|
end
|
|
254
|
+
lines << ""
|
|
255
|
+
end
|
|
209
256
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
end
|
|
215
|
-
lines << ""
|
|
257
|
+
if view_data["partials"] && !view_data["partials"].empty?
|
|
258
|
+
lines << "### Partials"
|
|
259
|
+
view_data["partials"].each do |partial|
|
|
260
|
+
lines << "- `#{partial['identifier']}` — #{partial['duration'].round(2)} ms"
|
|
216
261
|
end
|
|
262
|
+
lines << ""
|
|
217
263
|
end
|
|
264
|
+
lines
|
|
265
|
+
end
|
|
218
266
|
|
|
219
|
-
|
|
267
|
+
def self.section_cache(profile)
|
|
268
|
+
lines = []
|
|
220
269
|
cache_data = profile.collector_data("cache")
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
lines << ""
|
|
270
|
+
return lines unless cache_data && cache_data["total_reads"]
|
|
271
|
+
|
|
272
|
+
lines << "## Cache"
|
|
273
|
+
lines << "- Reads: #{cache_data['total_reads']}"
|
|
274
|
+
lines << "- Writes: #{cache_data['total_writes']}"
|
|
275
|
+
lines << "- Deletes: #{cache_data['total_deletes']}"
|
|
276
|
+
lines << "- Hit Rate: #{cache_data['hit_rate']}%\n"
|
|
277
|
+
|
|
278
|
+
if cache_data["reads"] && !cache_data["reads"].empty?
|
|
279
|
+
lines << "### Cache Reads"
|
|
280
|
+
cache_data["reads"].each do |op|
|
|
281
|
+
hit_label = op["hit"] ? "HIT" : "MISS"
|
|
282
|
+
lines << "- [#{hit_label}] `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
235
283
|
end
|
|
284
|
+
lines << ""
|
|
285
|
+
end
|
|
236
286
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
end
|
|
242
|
-
lines << ""
|
|
287
|
+
if cache_data["writes"] && !cache_data["writes"].empty?
|
|
288
|
+
lines << "### Cache Writes"
|
|
289
|
+
cache_data["writes"].each do |op|
|
|
290
|
+
lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
243
291
|
end
|
|
292
|
+
lines << ""
|
|
293
|
+
end
|
|
244
294
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
end
|
|
250
|
-
lines << ""
|
|
295
|
+
if cache_data["deletes"] && !cache_data["deletes"].empty?
|
|
296
|
+
lines << "### Cache Deletes"
|
|
297
|
+
cache_data["deletes"].each do |op|
|
|
298
|
+
lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
|
|
251
299
|
end
|
|
300
|
+
lines << ""
|
|
252
301
|
end
|
|
302
|
+
lines
|
|
303
|
+
end
|
|
253
304
|
|
|
254
|
-
|
|
305
|
+
def self.section_ajax(profile)
|
|
306
|
+
lines = []
|
|
255
307
|
ajax_data = profile.collector_data("ajax")
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
lines << ""
|
|
308
|
+
return lines unless ajax_data && ajax_data["total_requests"].to_i > 0
|
|
309
|
+
|
|
310
|
+
lines << "## AJAX Requests"
|
|
311
|
+
lines << "- Total: #{ajax_data['total_requests']}"
|
|
312
|
+
lines << "- Total Duration: #{ajax_data['total_duration'].round(2)} ms\n"
|
|
313
|
+
|
|
314
|
+
if ajax_data["requests"] && !ajax_data["requests"].empty?
|
|
315
|
+
lines << "### Request List"
|
|
316
|
+
ajax_data["requests"].each do |req|
|
|
317
|
+
lines << "- **#{req['method']} #{req['path']}** — #{req['status']} — #{req['duration'].round(2)} ms (token: #{req['token']})"
|
|
267
318
|
end
|
|
319
|
+
lines << ""
|
|
268
320
|
end
|
|
321
|
+
lines
|
|
322
|
+
end
|
|
269
323
|
|
|
270
|
-
|
|
324
|
+
def self.section_http(profile)
|
|
325
|
+
lines = []
|
|
271
326
|
http_data = profile.collector_data("http")
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
lines << ""
|
|
327
|
+
return lines unless http_data && http_data["total_requests"].to_i > 0
|
|
328
|
+
|
|
329
|
+
threshold = Profiler.configuration.slow_http_threshold
|
|
330
|
+
lines << "## Outbound HTTP"
|
|
331
|
+
lines << "- Total: #{http_data['total_requests']}"
|
|
332
|
+
lines << "- Total Duration: #{http_data['total_duration'].round(2)} ms"
|
|
333
|
+
lines << "- Slow (>#{threshold}ms): #{http_data['slow_requests']}"
|
|
334
|
+
lines << "- Errors: #{http_data['error_requests']}\n"
|
|
335
|
+
|
|
336
|
+
if http_data["requests"] && !http_data["requests"].empty?
|
|
337
|
+
lines << "### Request List"
|
|
338
|
+
http_data["requests"].each do |req|
|
|
339
|
+
flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
340
|
+
err = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
341
|
+
lines << "- **#{req['method']} #{req['url']}** — #{req['status'] == 0 ? 'error' : req['status']} — #{req['duration'].round(2)} ms#{flag}#{err}"
|
|
288
342
|
end
|
|
343
|
+
lines << ""
|
|
289
344
|
end
|
|
345
|
+
lines
|
|
346
|
+
end
|
|
290
347
|
|
|
291
|
-
|
|
348
|
+
def self.section_routes(profile)
|
|
349
|
+
lines = []
|
|
292
350
|
routes_data = profile.collector_data("routes")
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
lines << ""
|
|
351
|
+
return lines unless routes_data && routes_data["total"].to_i > 0
|
|
352
|
+
|
|
353
|
+
lines << "## Routes"
|
|
354
|
+
lines << "- Total routes: #{routes_data['total']}"
|
|
355
|
+
|
|
356
|
+
matched = routes_data["matched"]
|
|
357
|
+
if matched
|
|
358
|
+
lines << "- **Matched:** `#{matched['verb']} #{matched['pattern']}`"
|
|
359
|
+
lines << " - Route name: #{matched['name']}_path" if matched["name"]
|
|
360
|
+
lines << " - Controller#Action: #{matched['controller_action']}" if matched["controller_action"]
|
|
361
|
+
else
|
|
362
|
+
lines << "- No route matched"
|
|
306
363
|
end
|
|
364
|
+
lines << ""
|
|
365
|
+
lines
|
|
366
|
+
end
|
|
307
367
|
|
|
308
|
-
|
|
368
|
+
def self.section_dumps(profile)
|
|
369
|
+
lines = []
|
|
309
370
|
dump_data = profile.collector_data("dump")
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
lines << ""
|
|
371
|
+
return lines unless dump_data && dump_data["count"].to_i > 0
|
|
372
|
+
|
|
373
|
+
lines << "## Variable Dumps"
|
|
374
|
+
lines << "- Count: #{dump_data['count']}\n"
|
|
375
|
+
|
|
376
|
+
dump_data["dumps"]&.each_with_index do |dump, index|
|
|
377
|
+
label = dump["label"] || "Dump #{index + 1}"
|
|
378
|
+
location = [dump["file"], dump["line"]].compact.join(":")
|
|
379
|
+
lines << "### #{label}"
|
|
380
|
+
lines << "_Source: #{location}_" unless location.empty?
|
|
381
|
+
lines << "```"
|
|
382
|
+
lines << (dump["formatted"] || dump["value"].inspect)
|
|
383
|
+
lines << "```"
|
|
324
384
|
end
|
|
325
|
-
|
|
326
|
-
lines
|
|
385
|
+
lines << ""
|
|
386
|
+
lines
|
|
327
387
|
end
|
|
328
388
|
|
|
329
389
|
def self.generate_curl(profile, req_data)
|
|
@@ -28,12 +28,12 @@ module Profiler
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
domain_filter = params["domain"]
|
|
31
|
-
[{ type: "text", text: format_http(profile, http_data, domain_filter) }]
|
|
31
|
+
[{ type: "text", text: format_http(profile, http_data, domain_filter, params) }]
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
def self.format_http(profile, http_data, domain_filter)
|
|
36
|
+
def self.format_http(profile, http_data, domain_filter, params)
|
|
37
37
|
threshold = Profiler.configuration.slow_http_threshold
|
|
38
38
|
requests = http_data["requests"] || []
|
|
39
39
|
|
|
@@ -85,34 +85,41 @@ module Profiler
|
|
|
85
85
|
lines << "- **Request Size:** #{req['request_size']} bytes"
|
|
86
86
|
lines << "- **Response Size:** #{req['response_size']} bytes"
|
|
87
87
|
lines << "- **Error:** #{req['error']}" if req["error"]
|
|
88
|
+
|
|
88
89
|
if req["request_headers"] && !req["request_headers"].empty?
|
|
89
90
|
lines << "- **Request Headers:**"
|
|
90
91
|
req["request_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
91
92
|
end
|
|
93
|
+
|
|
92
94
|
if req["request_body"] && !req["request_body"].empty?
|
|
93
95
|
lines << "- **Request Body:**"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
formatted = BodyFormatter.format_body(
|
|
97
|
+
profile.token,
|
|
98
|
+
"http_#{i}_request_body",
|
|
99
|
+
req["request_body"],
|
|
100
|
+
req["request_body_encoding"],
|
|
101
|
+
params
|
|
102
|
+
)
|
|
103
|
+
lines << formatted if formatted
|
|
101
104
|
end
|
|
105
|
+
|
|
102
106
|
if req["response_headers"] && !req["response_headers"].empty?
|
|
103
107
|
lines << "- **Response Headers:**"
|
|
104
108
|
req["response_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
|
|
105
109
|
end
|
|
110
|
+
|
|
106
111
|
if req["response_body"] && !req["response_body"].empty?
|
|
107
112
|
lines << "- **Response Body:**"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
formatted = BodyFormatter.format_body(
|
|
114
|
+
profile.token,
|
|
115
|
+
"http_#{i}_response_body",
|
|
116
|
+
req["response_body"],
|
|
117
|
+
req["response_body_encoding"],
|
|
118
|
+
params
|
|
119
|
+
)
|
|
120
|
+
lines << formatted if formatted
|
|
115
121
|
end
|
|
122
|
+
|
|
116
123
|
if req["backtrace"] && !req["backtrace"].empty?
|
|
117
124
|
lines << "- **Called from:**"
|
|
118
125
|
req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
|
|
@@ -4,9 +4,12 @@ module Profiler
|
|
|
4
4
|
module MCP
|
|
5
5
|
module Tools
|
|
6
6
|
class QueryJobs
|
|
7
|
+
ALL_FIELDS = %w[time job_class queue status duration token].freeze
|
|
8
|
+
|
|
7
9
|
def self.call(params)
|
|
8
10
|
limit = params["limit"]&.to_i || 20
|
|
9
|
-
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
10
13
|
|
|
11
14
|
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
12
15
|
|
|
@@ -24,32 +27,52 @@ module Profiler
|
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
if params["cursor"]
|
|
31
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
32
|
+
jobs = jobs.select { |p| p.started_at < cutoff } if cutoff
|
|
33
|
+
end
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
jobs = jobs.first(limit)
|
|
36
|
+
fields = params["fields"]&.map(&:to_s)
|
|
30
37
|
|
|
31
|
-
[{ type: "text", text:
|
|
38
|
+
[{ type: "text", text: format_jobs_table(jobs, fields, limit) }]
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
private
|
|
35
42
|
|
|
36
|
-
def self.format_jobs_table(jobs)
|
|
37
|
-
if jobs.empty?
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
def self.format_jobs_table(jobs, fields, limit)
|
|
44
|
+
return "No job profiles found matching the criteria." if jobs.empty?
|
|
45
|
+
|
|
46
|
+
fields ||= ALL_FIELDS
|
|
47
|
+
fields = fields & ALL_FIELDS
|
|
40
48
|
|
|
41
49
|
lines = []
|
|
42
50
|
lines << "# Background Job Profiles\n"
|
|
43
51
|
lines << "Found #{jobs.size} jobs:\n"
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
|
|
53
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
54
|
+
separator = fields.map { |_| "------" }.join("|")
|
|
55
|
+
lines << "| #{header} |"
|
|
56
|
+
lines << "|#{separator}|"
|
|
46
57
|
|
|
47
58
|
jobs.each do |profile|
|
|
48
59
|
job_data = profile.collector_data("job") || {}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
row = fields.map do |f|
|
|
61
|
+
case f
|
|
62
|
+
when "time" then profile.started_at.strftime("%H:%M:%S")
|
|
63
|
+
when "job_class" then job_data["job_class"] || profile.path
|
|
64
|
+
when "queue" then job_data["queue"] || "-"
|
|
65
|
+
when "status" then job_data["status"] || "-"
|
|
66
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
67
|
+
when "token" then profile.token.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
lines << "| #{row.join(' | ')} |"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if jobs.size == limit
|
|
74
|
+
lines << ""
|
|
75
|
+
lines << "*Next cursor: #{jobs.last.started_at.iso8601}*"
|
|
53
76
|
end
|
|
54
77
|
|
|
55
78
|
lines.join("\n")
|
|
@@ -4,58 +4,73 @@ module Profiler
|
|
|
4
4
|
module MCP
|
|
5
5
|
module Tools
|
|
6
6
|
class QueryProfiles
|
|
7
|
+
ALL_FIELDS = %w[time type method path duration queries status token].freeze
|
|
8
|
+
|
|
7
9
|
def self.call(params)
|
|
8
10
|
limit = params["limit"]&.to_i || 20
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# Apply filters
|
|
12
|
-
if params["path"]
|
|
13
|
-
profiles = profiles.select { |p| p.path&.include?(params["path"]) }
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
if params["method"]
|
|
17
|
-
profiles = profiles.select { |p| p.method == params["method"]&.upcase }
|
|
18
|
-
end
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
19
13
|
|
|
14
|
+
profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
|
|
15
|
+
profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
|
|
20
16
|
if params["min_duration"]
|
|
21
17
|
min_dur = params["min_duration"].to_f
|
|
22
18
|
profiles = profiles.select { |p| p.duration && p.duration >= min_dur }
|
|
23
19
|
end
|
|
20
|
+
profiles = profiles.select { |p| p.profile_type == params["profile_type"] } if params["profile_type"]
|
|
24
21
|
|
|
25
|
-
if params["
|
|
26
|
-
|
|
22
|
+
if params["cursor"]
|
|
23
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
24
|
+
profiles = profiles.select { |p| p.started_at < cutoff } if cutoff
|
|
27
25
|
end
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
profiles = profiles.first(limit)
|
|
28
|
+
fields = params["fields"]&.map(&:to_s)
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
type: "text",
|
|
35
|
-
text: text
|
|
36
|
-
}
|
|
37
|
-
]
|
|
30
|
+
text = format_profiles_table(profiles, fields, limit)
|
|
31
|
+
[{ type: "text", text: text }]
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
private
|
|
41
35
|
|
|
42
|
-
def self.format_profiles_table(profiles)
|
|
43
|
-
if profiles.empty?
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
def self.format_profiles_table(profiles, fields, limit)
|
|
37
|
+
return "No profiles found matching the criteria." if profiles.empty?
|
|
38
|
+
|
|
39
|
+
fields ||= ALL_FIELDS
|
|
40
|
+
fields = fields & ALL_FIELDS # only allow valid fields
|
|
46
41
|
|
|
47
42
|
lines = []
|
|
48
43
|
lines << "# Profiled Requests\n"
|
|
49
44
|
lines << "Found #{profiles.size} profiles:\n"
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
|
|
46
|
+
header = fields.map { |f| f.capitalize }.join(" | ")
|
|
47
|
+
separator = fields.map { |_| "------" }.join("|")
|
|
48
|
+
lines << "| #{header} |"
|
|
49
|
+
lines << "|#{separator}|"
|
|
52
50
|
|
|
53
51
|
profiles.each do |profile|
|
|
54
52
|
db_data = profile.collector_data("database")
|
|
55
53
|
query_count = db_data ? db_data["total_queries"] : 0
|
|
56
54
|
type = profile.profile_type || "http"
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
row = fields.map do |f|
|
|
57
|
+
case f
|
|
58
|
+
when "time" then profile.started_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
59
|
+
when "type" then type
|
|
60
|
+
when "method" then profile.method.to_s
|
|
61
|
+
when "path" then profile.path.to_s
|
|
62
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
63
|
+
when "queries" then query_count.to_s
|
|
64
|
+
when "status" then profile.status.to_s
|
|
65
|
+
when "token" then profile.token.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
lines << "| #{row.join(' | ')} |"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if profiles.size == limit
|
|
72
|
+
lines << ""
|
|
73
|
+
lines << "*Next cursor: #{profiles.last.started_at.iso8601}*"
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
lines.join("\n")
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -142,6 +142,9 @@ files:
|
|
|
142
142
|
- lib/profiler/instrumentation/net_http_instrumentation.rb
|
|
143
143
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
144
144
|
- lib/profiler/job_profiler.rb
|
|
145
|
+
- lib/profiler/mcp/body_formatter.rb
|
|
146
|
+
- lib/profiler/mcp/file_cache.rb
|
|
147
|
+
- lib/profiler/mcp/path_extractor.rb
|
|
145
148
|
- lib/profiler/mcp/resources/n1_patterns.rb
|
|
146
149
|
- lib/profiler/mcp/resources/recent_jobs.rb
|
|
147
150
|
- lib/profiler/mcp/resources/recent_requests.rb
|