rails-profiler 0.4.0 → 0.5.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/instrumentation/active_job_instrumentation.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +7 -0
- data/lib/profiler/mcp/server.rb +11 -10
- data/lib/profiler/mcp/tools/analyze_queries.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +23 -1
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +48 -7
- data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
- data/lib/profiler/models/profile.rb +8 -0
- data/lib/profiler/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
|
|
4
|
+
data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1be16066652bfa11550b5d277d3370eec2457d08dfbb0ceab46ebafe12a484cc342c61892133b030ab4b6d7d1506bcbe53ff8118eaa9ee4ee759c36012a0058e
|
|
7
|
+
data.tar.gz: a72957818f9d2a7dbdd914f57167ffd0bcfb6eef7df3dfb721a7a651f3acd89536d11cec4dbfa7c47f72f3331862b3279eb0312a7c66a0fbb47bb36e35c1640c
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "zlib"
|
|
6
6
|
require "stringio"
|
|
7
|
+
require "securerandom"
|
|
7
8
|
|
|
8
9
|
module Profiler
|
|
9
10
|
module Instrumentation
|
|
@@ -22,6 +23,8 @@ module Profiler
|
|
|
22
23
|
url = build_url(host, port, req.path, use_ssl?)
|
|
23
24
|
req_body = req.body.to_s
|
|
24
25
|
req_headers = req.to_hash.transform_values { |v| v.join(", ") }
|
|
26
|
+
request_id = SecureRandom.hex(8)
|
|
27
|
+
started_at = Time.now.iso8601(3)
|
|
25
28
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
29
|
Thread.current[:profiler_http_recording] = true
|
|
27
30
|
|
|
@@ -40,6 +43,8 @@ module Profiler
|
|
|
40
43
|
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
44
|
|
|
42
45
|
collector.record_request(
|
|
46
|
+
id: request_id,
|
|
47
|
+
started_at: started_at,
|
|
43
48
|
url: url,
|
|
44
49
|
method: req.method,
|
|
45
50
|
status: response.code.to_i,
|
|
@@ -63,6 +68,8 @@ module Profiler
|
|
|
63
68
|
if defined?(t0) && t0
|
|
64
69
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
65
70
|
collector&.record_request(
|
|
71
|
+
id: defined?(request_id) ? request_id : SecureRandom.hex(8),
|
|
72
|
+
started_at: defined?(started_at) ? started_at : Time.now.iso8601(3),
|
|
66
73
|
url: url,
|
|
67
74
|
method: req.method,
|
|
68
75
|
status: 0,
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -85,10 +85,10 @@ module Profiler
|
|
|
85
85
|
),
|
|
86
86
|
define_tool(
|
|
87
87
|
name: "get_profile",
|
|
88
|
-
description: "Get detailed profile data by token",
|
|
88
|
+
description: "Get detailed profile data by token. Use 'latest' as token to get the most recent profile.",
|
|
89
89
|
input_schema: {
|
|
90
90
|
properties: {
|
|
91
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
91
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
92
92
|
},
|
|
93
93
|
required: ["token"]
|
|
94
94
|
},
|
|
@@ -96,10 +96,10 @@ module Profiler
|
|
|
96
96
|
),
|
|
97
97
|
define_tool(
|
|
98
98
|
name: "analyze_queries",
|
|
99
|
-
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries",
|
|
99
|
+
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries. Use 'latest' as token to analyze the most recent profile.",
|
|
100
100
|
input_schema: {
|
|
101
101
|
properties: {
|
|
102
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
102
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
103
103
|
},
|
|
104
104
|
required: ["token"]
|
|
105
105
|
},
|
|
@@ -107,10 +107,10 @@ module Profiler
|
|
|
107
107
|
),
|
|
108
108
|
define_tool(
|
|
109
109
|
name: "get_profile_ajax",
|
|
110
|
-
description: "Get detailed AJAX sub-request breakdown for a profile",
|
|
110
|
+
description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
|
|
111
111
|
input_schema: {
|
|
112
112
|
properties: {
|
|
113
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
113
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
114
114
|
},
|
|
115
115
|
required: ["token"]
|
|
116
116
|
},
|
|
@@ -118,10 +118,10 @@ module Profiler
|
|
|
118
118
|
),
|
|
119
119
|
define_tool(
|
|
120
120
|
name: "get_profile_dumps",
|
|
121
|
-
description: "Get variable dumps captured during a profile",
|
|
121
|
+
description: "Get variable dumps captured during a profile. Use 'latest' as token to get the most recent profile.",
|
|
122
122
|
input_schema: {
|
|
123
123
|
properties: {
|
|
124
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
124
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
125
125
|
},
|
|
126
126
|
required: ["token"]
|
|
127
127
|
},
|
|
@@ -129,10 +129,11 @@ module Profiler
|
|
|
129
129
|
),
|
|
130
130
|
define_tool(
|
|
131
131
|
name: "get_profile_http",
|
|
132
|
-
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request)",
|
|
132
|
+
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request). Use 'latest' as token to get the most recent profile.",
|
|
133
133
|
input_schema: {
|
|
134
134
|
properties: {
|
|
135
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
135
|
+
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)" }
|
|
136
137
|
},
|
|
137
138
|
required: ["token"]
|
|
138
139
|
},
|
|
@@ -10,7 +10,11 @@ module Profiler
|
|
|
10
10
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
profile =
|
|
13
|
+
profile = if token == "latest"
|
|
14
|
+
Profiler.storage.list(limit: 1).first
|
|
15
|
+
else
|
|
16
|
+
Profiler.storage.load(token)
|
|
17
|
+
end
|
|
14
18
|
unless profile
|
|
15
19
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
20
|
end
|
|
@@ -18,7 +18,11 @@ module Profiler
|
|
|
18
18
|
]
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
profile =
|
|
21
|
+
profile = if token == "latest"
|
|
22
|
+
Profiler.storage.list(limit: 1).first
|
|
23
|
+
else
|
|
24
|
+
Profiler.storage.load(token)
|
|
25
|
+
end
|
|
22
26
|
unless profile
|
|
23
27
|
return [
|
|
24
28
|
{
|
|
@@ -49,6 +53,24 @@ module Profiler
|
|
|
49
53
|
lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
|
|
50
54
|
lines << "**Time:** #{profile.started_at}\n"
|
|
51
55
|
|
|
56
|
+
# Exception section
|
|
57
|
+
exception_data = profile.collector_data("exception")
|
|
58
|
+
if exception_data && exception_data["exception_class"]
|
|
59
|
+
lines << "## Exception"
|
|
60
|
+
lines << "**Class:** #{exception_data['exception_class']}"
|
|
61
|
+
lines << "**Message:** #{exception_data['message']}\n"
|
|
62
|
+
|
|
63
|
+
backtrace = exception_data["backtrace"]
|
|
64
|
+
if backtrace && !backtrace.empty?
|
|
65
|
+
lines << "### Backtrace"
|
|
66
|
+
backtrace.first(20).each do |frame|
|
|
67
|
+
marker = frame["app_frame"] ? "★ " : " "
|
|
68
|
+
lines << "#{marker}#{frame['location']}"
|
|
69
|
+
end
|
|
70
|
+
lines << ""
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
52
74
|
# Job section
|
|
53
75
|
job_data = profile.collector_data("job")
|
|
54
76
|
if job_data && job_data["job_class"]
|
|
@@ -10,7 +10,11 @@ module Profiler
|
|
|
10
10
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
profile =
|
|
13
|
+
profile = if token == "latest"
|
|
14
|
+
Profiler.storage.list(limit: 1).first
|
|
15
|
+
else
|
|
16
|
+
Profiler.storage.load(token)
|
|
17
|
+
end
|
|
14
18
|
unless profile
|
|
15
19
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
20
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
3
6
|
module Profiler
|
|
4
7
|
module MCP
|
|
5
8
|
module Tools
|
|
@@ -10,7 +13,11 @@ module Profiler
|
|
|
10
13
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
14
|
end
|
|
12
15
|
|
|
13
|
-
profile =
|
|
16
|
+
profile = if token == "latest"
|
|
17
|
+
Profiler.storage.list(limit: 1).first
|
|
18
|
+
else
|
|
19
|
+
Profiler.storage.load(token)
|
|
20
|
+
end
|
|
14
21
|
unless profile
|
|
15
22
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
23
|
end
|
|
@@ -20,39 +27,57 @@ module Profiler
|
|
|
20
27
|
return [{ type: "text", text: "No outbound HTTP requests found in this profile" }]
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
domain_filter = params["domain"]
|
|
31
|
+
[{ type: "text", text: format_http(profile, http_data, domain_filter) }]
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
27
35
|
|
|
28
|
-
def self.format_http(profile, http_data)
|
|
36
|
+
def self.format_http(profile, http_data, domain_filter)
|
|
29
37
|
threshold = Profiler.configuration.slow_http_threshold
|
|
38
|
+
requests = http_data["requests"] || []
|
|
39
|
+
|
|
40
|
+
if domain_filter && !domain_filter.empty?
|
|
41
|
+
requests = requests.select do |req|
|
|
42
|
+
host = begin
|
|
43
|
+
URI.parse(req["url"]).host.to_s
|
|
44
|
+
rescue URI::InvalidURIError
|
|
45
|
+
""
|
|
46
|
+
end
|
|
47
|
+
host.include?(domain_filter)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
30
51
|
lines = []
|
|
31
52
|
lines << "# Outbound HTTP Analysis: #{profile.token}\n"
|
|
32
53
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
54
|
+
lines << "**Started at:** #{profile.started_at&.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
33
55
|
lines << "**Total Outbound Requests:** #{http_data['total_requests']}"
|
|
56
|
+
lines << "**Showing:** #{requests.size} request(s)#{domain_filter ? " (filtered by domain: #{domain_filter})" : ""}"
|
|
34
57
|
lines << "**Total Duration:** #{http_data['total_duration'].round(2)} ms"
|
|
35
58
|
lines << "**Slow Requests (>#{threshold}ms):** #{http_data['slow_requests']}"
|
|
36
59
|
lines << "**Error Requests:** #{http_data['error_requests']}\n"
|
|
37
60
|
|
|
38
|
-
if http_data["by_host"] && !http_data["by_host"].empty?
|
|
61
|
+
if http_data["by_host"] && !http_data["by_host"].empty? && !domain_filter
|
|
39
62
|
lines << "## By Host"
|
|
40
63
|
http_data["by_host"].each { |host, count| lines << "- **#{host}**: #{count}" }
|
|
41
64
|
lines << ""
|
|
42
65
|
end
|
|
43
66
|
|
|
44
|
-
if http_data["by_status"] && !http_data["by_status"].empty?
|
|
67
|
+
if http_data["by_status"] && !http_data["by_status"].empty? && !domain_filter
|
|
45
68
|
lines << "## By Status"
|
|
46
69
|
http_data["by_status"].each { |status, count| lines << "- **#{status}**: #{count}" }
|
|
47
70
|
lines << ""
|
|
48
71
|
end
|
|
49
72
|
|
|
50
|
-
if
|
|
73
|
+
if requests && !requests.empty?
|
|
51
74
|
lines << "## Request Details"
|
|
52
|
-
|
|
75
|
+
requests.each_with_index do |req, i|
|
|
53
76
|
slow_flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
54
77
|
err_flag = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
55
78
|
lines << "\n### Request #{i + 1}#{slow_flag}#{err_flag}"
|
|
79
|
+
lines << "- **ID:** #{req['id']}" if req["id"]
|
|
80
|
+
lines << "- **Started at:** #{req['started_at']}" if req["started_at"]
|
|
56
81
|
lines << "- **Method:** #{req['method']}"
|
|
57
82
|
lines << "- **URL:** #{req['url']}"
|
|
58
83
|
lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
|
|
@@ -92,12 +117,28 @@ module Profiler
|
|
|
92
117
|
lines << "- **Called from:**"
|
|
93
118
|
req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
|
|
94
119
|
end
|
|
120
|
+
lines << "- **Curl:**"
|
|
121
|
+
lines << " ```bash"
|
|
122
|
+
lines << " #{generate_curl_for_outbound(req)}"
|
|
123
|
+
lines << " ```"
|
|
95
124
|
end
|
|
96
125
|
lines << ""
|
|
97
126
|
end
|
|
98
127
|
|
|
99
128
|
lines.join("\n")
|
|
100
129
|
end
|
|
130
|
+
|
|
131
|
+
def self.generate_curl_for_outbound(req)
|
|
132
|
+
parts = ["curl -X #{req['method']}"]
|
|
133
|
+
req["request_headers"]
|
|
134
|
+
&.reject { |k, _| k.downcase == "user-agent" }
|
|
135
|
+
&.each { |k, v| parts << " -H #{Shellwords.shellescape("#{k}: #{v}")}" }
|
|
136
|
+
if req["request_body"] && !req["request_body"].empty? && req["request_body_encoding"] != "base64"
|
|
137
|
+
parts << " -d #{Shellwords.shellescape(req['request_body'])}"
|
|
138
|
+
end
|
|
139
|
+
parts << " #{Shellwords.shellescape(req['url'])}"
|
|
140
|
+
parts.join(" \\\n")
|
|
141
|
+
end
|
|
101
142
|
end
|
|
102
143
|
end
|
|
103
144
|
end
|
|
@@ -49,7 +49,7 @@ module Profiler
|
|
|
49
49
|
job_class = job_data["job_class"] || profile.path
|
|
50
50
|
queue = job_data["queue"] || "-"
|
|
51
51
|
status = job_data["status"] || "-"
|
|
52
|
-
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{job_class} | #{queue} | #{status} | #{profile.duration.round(2)}ms | #{profile.token} |"
|
|
52
|
+
lines << "| #{profile.started_at.strftime('%Y-%m-%d %H:%M:%S')} | #{job_class} | #{queue} | #{status} | #{profile.duration.round(2)}ms | #{profile.token} |"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
lines.join("\n")
|
|
@@ -55,7 +55,7 @@ module Profiler
|
|
|
55
55
|
query_count = db_data ? db_data["total_queries"] : 0
|
|
56
56
|
type = profile.profile_type || "http"
|
|
57
57
|
|
|
58
|
-
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
|
|
58
|
+
lines << "| #{profile.started_at.strftime('%Y-%m-%d %H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
lines.join("\n")
|
|
@@ -173,9 +173,17 @@ module Profiler
|
|
|
173
173
|
params.to_h.except("password", "password_confirmation", "token", "secret")
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
ALLOWED_HEADERS = %w[
|
|
177
|
+
Accept Accept-Charset Accept-Encoding Accept-Language
|
|
178
|
+
Authorization Cache-Control Connection Content-Length Content-Type
|
|
179
|
+
Cookie Host If-Modified-Since If-None-Match Origin
|
|
180
|
+
Referer User-Agent
|
|
181
|
+
].freeze
|
|
182
|
+
|
|
176
183
|
def extract_headers(env)
|
|
177
184
|
env.select { |k, _| k.start_with?("HTTP_") }
|
|
178
185
|
.transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
|
|
186
|
+
.select { |k, _| ALLOWED_HEADERS.include?(k) }
|
|
179
187
|
end
|
|
180
188
|
end
|
|
181
189
|
end
|
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.5.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-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|