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.
- checksums.yaml +7 -0
- data/app/assets/builds/profiler-toolbar.js +1191 -0
- data/app/assets/builds/profiler.css +2668 -0
- data/app/assets/builds/profiler.js +2772 -0
- data/app/controllers/profiler/api/ajax_controller.rb +36 -0
- data/app/controllers/profiler/api/jobs_controller.rb +39 -0
- data/app/controllers/profiler/api/outbound_http_controller.rb +36 -0
- data/app/controllers/profiler/api/profiles_controller.rb +60 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +44 -0
- data/app/controllers/profiler/application_controller.rb +19 -0
- data/app/controllers/profiler/assets_controller.rb +29 -0
- data/app/controllers/profiler/profiles_controller.rb +107 -0
- data/app/views/layouts/profiler/application.html.erb +16 -0
- data/app/views/layouts/profiler/embedded.html.erb +34 -0
- data/app/views/profiler/profiles/index.html.erb +1 -0
- data/app/views/profiler/profiles/show.html.erb +4 -0
- data/config/routes.rb +36 -0
- data/exe/profiler-mcp +8 -0
- data/lib/profiler/collectors/ajax_collector.rb +109 -0
- data/lib/profiler/collectors/base_collector.rb +92 -0
- data/lib/profiler/collectors/cache_collector.rb +96 -0
- data/lib/profiler/collectors/database_collector.rb +113 -0
- data/lib/profiler/collectors/dump_collector.rb +98 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +182 -0
- data/lib/profiler/collectors/http_collector.rb +112 -0
- data/lib/profiler/collectors/job_collector.rb +50 -0
- data/lib/profiler/collectors/performance_collector.rb +103 -0
- data/lib/profiler/collectors/request_collector.rb +80 -0
- data/lib/profiler/collectors/view_collector.rb +79 -0
- data/lib/profiler/configuration.rb +81 -0
- data/lib/profiler/engine.rb +17 -0
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +22 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +153 -0
- data/lib/profiler/instrumentation/sidekiq_middleware.rb +18 -0
- data/lib/profiler/job_profiler.rb +118 -0
- data/lib/profiler/mcp/resources/n1_patterns.rb +62 -0
- data/lib/profiler/mcp/resources/recent_jobs.rb +39 -0
- data/lib/profiler/mcp/resources/recent_requests.rb +35 -0
- data/lib/profiler/mcp/resources/slow_queries.rb +47 -0
- data/lib/profiler/mcp/server.rb +217 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +124 -0
- data/lib/profiler/mcp/tools/clear_profiles.rb +22 -0
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +66 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +326 -0
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +51 -0
- data/lib/profiler/mcp/tools/get_profile_http.rb +104 -0
- data/lib/profiler/mcp/tools/query_jobs.rb +60 -0
- data/lib/profiler/mcp/tools/query_profiles.rb +66 -0
- data/lib/profiler/middleware/cors_middleware.rb +55 -0
- data/lib/profiler/middleware/profiler_middleware.rb +151 -0
- data/lib/profiler/middleware/toolbar_injector.rb +378 -0
- data/lib/profiler/models/profile.rb +182 -0
- data/lib/profiler/models/sql_query.rb +48 -0
- data/lib/profiler/models/timeline_event.rb +40 -0
- data/lib/profiler/railtie.rb +75 -0
- data/lib/profiler/storage/base_store.rb +41 -0
- data/lib/profiler/storage/blob_store.rb +46 -0
- data/lib/profiler/storage/file_store.rb +119 -0
- data/lib/profiler/storage/memory_store.rb +94 -0
- data/lib/profiler/storage/redis_store.rb +98 -0
- data/lib/profiler/storage/sqlite_store.rb +272 -0
- data/lib/profiler/tasks/profiler.rake +79 -0
- data/lib/profiler/version.rb +5 -0
- data/lib/profiler.rb +68 -0
- 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
|