rails-profiler 0.4.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.
@@ -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, params) }]
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, params)
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']}"
@@ -60,44 +85,67 @@ module Profiler
60
85
  lines << "- **Request Size:** #{req['request_size']} bytes"
61
86
  lines << "- **Response Size:** #{req['response_size']} bytes"
62
87
  lines << "- **Error:** #{req['error']}" if req["error"]
88
+
63
89
  if req["request_headers"] && !req["request_headers"].empty?
64
90
  lines << "- **Request Headers:**"
65
91
  req["request_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
66
92
  end
93
+
67
94
  if req["request_body"] && !req["request_body"].empty?
68
95
  lines << "- **Request Body:**"
69
- if req["request_body_encoding"] == "base64"
70
- lines << " *(binary content, base64 encoded — #{req['request_body'].bytesize} chars)*"
71
- else
72
- lines << " ```"
73
- lines << " #{req['request_body'].lines.first(5).join(' ')}"
74
- lines << " ```"
75
- end
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
76
104
  end
105
+
77
106
  if req["response_headers"] && !req["response_headers"].empty?
78
107
  lines << "- **Response Headers:**"
79
108
  req["response_headers"].each { |k, v| lines << " - `#{k}`: #{v}" }
80
109
  end
110
+
81
111
  if req["response_body"] && !req["response_body"].empty?
82
112
  lines << "- **Response Body:**"
83
- if req["response_body_encoding"] == "base64"
84
- lines << " *(binary content, base64 encoded — #{req['response_body'].bytesize} chars)*"
85
- else
86
- lines << " ```"
87
- lines << " #{req['response_body'].lines.first(10).join(' ')}"
88
- lines << " ```"
89
- end
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
90
121
  end
122
+
91
123
  if req["backtrace"] && !req["backtrace"].empty?
92
124
  lines << "- **Called from:**"
93
125
  req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
94
126
  end
127
+ lines << "- **Curl:**"
128
+ lines << " ```bash"
129
+ lines << " #{generate_curl_for_outbound(req)}"
130
+ lines << " ```"
95
131
  end
96
132
  lines << ""
97
133
  end
98
134
 
99
135
  lines.join("\n")
100
136
  end
137
+
138
+ def self.generate_curl_for_outbound(req)
139
+ parts = ["curl -X #{req['method']}"]
140
+ req["request_headers"]
141
+ &.reject { |k, _| k.downcase == "user-agent" }
142
+ &.each { |k, v| parts << " -H #{Shellwords.shellescape("#{k}: #{v}")}" }
143
+ if req["request_body"] && !req["request_body"].empty? && req["request_body_encoding"] != "base64"
144
+ parts << " -d #{Shellwords.shellescape(req['request_body'])}"
145
+ end
146
+ parts << " #{Shellwords.shellescape(req['url'])}"
147
+ parts.join(" \\\n")
148
+ end
101
149
  end
102
150
  end
103
151
  end
@@ -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
- profiles = Profiler.storage.list(limit: [limit * 5, 200].min)
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
- jobs = jobs.first(limit)
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
- text = format_jobs_table(jobs)
35
+ jobs = jobs.first(limit)
36
+ fields = params["fields"]&.map(&:to_s)
30
37
 
31
- [{ type: "text", 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
- return "No job profiles found matching the criteria."
39
- end
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
- lines << "| Time | Job Class | Queue | Status | Duration | Token |"
45
- lines << "|------|-----------|-------|--------|----------|-------|"
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
- job_class = job_data["job_class"] || profile.path
50
- queue = job_data["queue"] || "-"
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} |"
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
- profiles = Profiler.storage.list(limit: limit)
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["profile_type"]
26
- profiles = profiles.select { |p| p.profile_type == params["profile_type"] }
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
- # Format as markdown table
30
- text = format_profiles_table(profiles)
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
- return "No profiles found matching the criteria."
45
- end
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
- lines << "| Time | Type | Method | Path | Duration | Queries | Status | Token |"
51
- lines << "|------|------|--------|------|----------|---------|--------|-------|"
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
- lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
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")
@@ -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.6.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.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-03 00:00:00.000000000 Z
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