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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e97a77a23dca55d709a1512c3c2ab3b95f0e6b15ed01b7769852587b962be8a
4
- data.tar.gz: 131c40f561129e8835c41a0b3ba456431bb11fe791c8b0add6396fbbfe0be293
3
+ metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
4
+ data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
5
5
  SHA512:
6
- metadata.gz: 7565ae06d41a7bd2d574610962e20fc3c4a6fcba5bda89482e840fb09af57c686eff9e8f4ccb02bfde05d53e0b5cc8019622ee7ea4db44fc85d6f5a0bc09c8c6
7
- data.tar.gz: bea8aee55cc58b41fc6d01c2ad4dffcdd9ecfdfc4617a45ba16b019577ad5209a2bcae5c9d226d0d5e31af1fbb3e508db8a7e3eae25c5c87f920dcad3e48d1f8
6
+ metadata.gz: 1be16066652bfa11550b5d277d3370eec2457d08dfbb0ceab46ebafe12a484cc342c61892133b030ab4b6d7d1506bcbe53ff8118eaa9ee4ee759c36012a0058e
7
+ data.tar.gz: a72957818f9d2a7dbdd914f57167ffd0bcfb6eef7df3dfb721a7a651f3acd89536d11cec4dbfa7c47f72f3331862b3279eb0312a7c66a0fbb47bb36e35c1640c
@@ -12,7 +12,7 @@ module Profiler
12
12
  job_id: job.job_id,
13
13
  queue: job.queue_name,
14
14
  arguments: job.arguments,
15
- executions: job.executions,
15
+ executions: job.executions - 1,
16
16
  &block
17
17
  )
18
18
  end
@@ -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,
@@ -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
  },
@@ -15,7 +15,11 @@ module Profiler
15
15
  ]
16
16
  end
17
17
 
18
- profile = Profiler.storage.load(token)
18
+ profile = if token == "latest"
19
+ Profiler.storage.list(limit: 1).first
20
+ else
21
+ Profiler.storage.load(token)
22
+ end
19
23
  unless profile
20
24
  return [
21
25
  {
@@ -10,7 +10,11 @@ module Profiler
10
10
  return [{ type: "text", text: "Error: token parameter is required" }]
11
11
  end
12
12
 
13
- profile = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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
- [{ type: "text", text: format_http(profile, http_data) }]
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 http_data["requests"] && !http_data["requests"].empty?
73
+ if requests && !requests.empty?
51
74
  lines << "## Request Details"
52
- http_data["requests"].each_with_index do |req, i|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.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-03 00:00:00.000000000 Z
11
+ date: 2026-04-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails