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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
4
- data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
3
+ metadata.gz: 8a0b42cc0bcdfe940609dbb07f9ee29a23cd00495c738711950749b22e58d664
4
+ data.tar.gz: b61678855bf48c082f4776773ce1754328c4e6b6d1672ea2542a836f79d9602a
5
5
  SHA512:
6
- metadata.gz: 1be16066652bfa11550b5d277d3370eec2457d08dfbb0ceab46ebafe12a484cc342c61892133b030ab4b6d7d1506bcbe53ff8118eaa9ee4ee759c36012a0058e
7
- data.tar.gz: a72957818f9d2a7dbdd914f57167ffd0bcfb6eef7df3dfb721a7a651f3acd89536d11cec4dbfa7c47f72f3331862b3279eb0312a7c66a0fbb47bb36e35c1640c
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
@@ -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
- text = analyze_and_format(db_data["queries"])
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
- if query_counts.any?
87
- lines << "## ⚠️ Duplicate Queries (Potential N+1)"
88
- lines << "Found #{query_counts.size} duplicate query patterns:\n"
89
-
90
- query_counts.sort_by { |_, count| -count }.first(5).each do |sql, count|
91
- lines << "### Executed #{count} times:"
92
- lines << "```sql"
93
- lines << sql
94
- lines << "```\n"
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
- if query_counts.size > 5
98
- lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n"
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
- # Overall statistics
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
- # Normalize SQL by removing bind values for comparison
119
- sql.gsub(/\$\d+/, '?')
120
- .gsub(/\b\d+\b/, '?')
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
- # Exception section
80
+ def self.section_exception(profile)
81
+ lines = []
57
82
  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 << ""
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
- # Job section
101
+ def self.section_job(profile)
102
+ lines = []
75
103
  job_data = profile.collector_data("job")
76
- if job_data && job_data["job_class"]
77
- lines << "## Job"
78
- lines << "- Class: #{job_data['job_class']}"
79
- lines << "- Job ID: #{job_data['job_id']}"
80
- lines << "- Queue: #{job_data['queue']}"
81
- lines << "- Executions: #{job_data['executions']}"
82
- lines << "- Status: #{job_data['status']}"
83
- lines << "- Error: #{job_data['error']}" if job_data['error']
84
- if job_data['arguments'] && !job_data['arguments'].empty?
85
- lines << "- Arguments: #{job_data['arguments'].map(&:to_s).join(', ')}"
86
- end
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
- # Request section
120
+ def self.section_request(profile, params)
121
+ lines = []
91
122
  req_data = profile.collector_data("request")
92
- if req_data
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
- if headers && !headers.empty?
103
- lines << "## Request Headers"
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
- req_body = req_data["request_body"]
109
- if req_body && !req_body.empty?
110
- enc = req_data["request_body_encoding"]
111
- lines << "## Request Body"
112
- if enc == "base64"
113
- lines << "_[binary, base64-encoded]_"
114
- else
115
- lines << "```"
116
- lines << req_body
117
- lines << "```"
118
- end
119
- lines << ""
120
- end
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
- if enc == "base64"
136
- lines << "_[binary, base64-encoded]_"
137
- else
138
- lines << "```"
139
- lines << resp_body
140
- lines << "```"
141
- end
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
- # Curl command
146
- req_data_for_curl = profile.collector_data("request")
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, req_data_for_curl)
186
+ lines << generate_curl(profile, req_data)
150
187
  lines << "```"
151
188
  lines << ""
189
+ lines
190
+ end
152
191
 
153
- # Database section
192
+ def self.section_database(profile)
193
+ lines = []
154
194
  db_data = profile.collector_data("database")
155
- if db_data && db_data["total_queries"]
156
- lines << "## Database"
157
- lines << "- Total Queries: #{db_data['total_queries']}"
158
- lines << "- Total Duration: #{db_data['total_duration'].round(2)} ms"
159
- lines << "- Slow Queries: #{db_data['slow_queries']}"
160
- lines << "- Cached Queries: #{db_data['cached_queries']}\n"
161
-
162
- if db_data["queries"] && !db_data["queries"].empty?
163
- lines << "### Query Details"
164
- db_data["queries"].each_with_index do |query, index|
165
- lines << "\n**Query #{index + 1}** (#{query['duration'].round(2)}ms):"
166
- lines << "```sql"
167
- lines << query['sql']
168
- lines << "```"
169
- if query['backtrace'] && !query['backtrace'].empty?
170
- lines << "_Backtrace:_"
171
- query['backtrace'].first(3).each { |frame| lines << " #{frame}" }
172
- end
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
- # Performance section
220
+ def self.section_performance(profile)
221
+ lines = []
179
222
  perf_data = profile.collector_data("performance")
180
- if perf_data && perf_data["total_events"]
181
- lines << "## Performance Timeline"
182
- lines << "- Total Events: #{perf_data['total_events']}"
183
- lines << "- Total Duration: #{perf_data['total_duration'].round(2)} ms\n"
184
-
185
- if perf_data["events"] && !perf_data["events"].empty?
186
- lines << "### Events"
187
- perf_data["events"].each do |event|
188
- lines << "- **#{event['name']}**: #{event['duration'].round(2)} ms"
189
- end
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
- # Views section
239
+ def self.section_views(profile)
240
+ lines = []
195
241
  view_data = profile.collector_data("view")
196
- if view_data && (view_data["total_views"] || view_data["total_partials"])
197
- lines << "## View Rendering"
198
- lines << "- Templates: #{view_data['total_views']}"
199
- lines << "- Partials: #{view_data['total_partials']}"
200
- lines << "- Total Duration: #{view_data['total_duration'].round(2)} ms\n"
201
-
202
- if view_data["views"] && !view_data["views"].empty?
203
- lines << "### Templates"
204
- view_data["views"].each do |view|
205
- lines << "- `#{view['identifier']}` #{view['duration'].round(2)} ms"
206
- end
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
- if view_data["partials"] && !view_data["partials"].empty?
211
- lines << "### Partials"
212
- view_data["partials"].each do |partial|
213
- lines << "- `#{partial['identifier']}` — #{partial['duration'].round(2)} ms"
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
- # Cache section
267
+ def self.section_cache(profile)
268
+ lines = []
220
269
  cache_data = profile.collector_data("cache")
221
- if cache_data && cache_data["total_reads"]
222
- lines << "## Cache"
223
- lines << "- Reads: #{cache_data['total_reads']}"
224
- lines << "- Writes: #{cache_data['total_writes']}"
225
- lines << "- Deletes: #{cache_data['total_deletes']}"
226
- lines << "- Hit Rate: #{cache_data['hit_rate']}%\n"
227
-
228
- if cache_data["reads"] && !cache_data["reads"].empty?
229
- lines << "### Cache Reads"
230
- cache_data["reads"].each do |op|
231
- hit_label = op['hit'] ? "HIT" : "MISS"
232
- lines << "- [#{hit_label}] `#{op['key']}` #{op['duration'].round(2)} ms"
233
- end
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
- if cache_data["writes"] && !cache_data["writes"].empty?
238
- lines << "### Cache Writes"
239
- cache_data["writes"].each do |op|
240
- lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
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
- if cache_data["deletes"] && !cache_data["deletes"].empty?
246
- lines << "### Cache Deletes"
247
- cache_data["deletes"].each do |op|
248
- lines << "- `#{op['key']}` — #{op['duration'].round(2)} ms"
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
- # Ajax section
305
+ def self.section_ajax(profile)
306
+ lines = []
255
307
  ajax_data = profile.collector_data("ajax")
256
- if ajax_data && ajax_data["total_requests"].to_i > 0
257
- lines << "## AJAX Requests"
258
- lines << "- Total: #{ajax_data['total_requests']}"
259
- lines << "- Total Duration: #{ajax_data['total_duration'].round(2)} ms\n"
260
-
261
- if ajax_data["requests"] && !ajax_data["requests"].empty?
262
- lines << "### Request List"
263
- ajax_data["requests"].each do |req|
264
- lines << "- **#{req['method']} #{req['path']}** — #{req['status']} — #{req['duration'].round(2)} ms (token: #{req['token']})"
265
- end
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
- # HTTP section
324
+ def self.section_http(profile)
325
+ lines = []
271
326
  http_data = profile.collector_data("http")
272
- if http_data && http_data["total_requests"].to_i > 0
273
- threshold = Profiler.configuration.slow_http_threshold
274
- lines << "## Outbound HTTP"
275
- lines << "- Total: #{http_data['total_requests']}"
276
- lines << "- Total Duration: #{http_data['total_duration'].round(2)} ms"
277
- lines << "- Slow (>#{threshold}ms): #{http_data['slow_requests']}"
278
- lines << "- Errors: #{http_data['error_requests']}\n"
279
-
280
- if http_data["requests"] && !http_data["requests"].empty?
281
- lines << "### Request List"
282
- http_data["requests"].each do |req|
283
- flag = req["duration"] >= threshold ? " [SLOW]" : ""
284
- err = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
285
- lines << "- **#{req['method']} #{req['url']}** #{req['status'] == 0 ? 'error' : req['status']} #{req['duration'].round(2)} ms#{flag}#{err}"
286
- end
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
- # Routes section
348
+ def self.section_routes(profile)
349
+ lines = []
292
350
  routes_data = profile.collector_data("routes")
293
- if routes_data && routes_data["total"].to_i > 0
294
- lines << "## Routes"
295
- lines << "- Total routes: #{routes_data['total']}"
296
-
297
- matched = routes_data["matched"]
298
- if matched
299
- lines << "- **Matched:** `#{matched['verb']} #{matched['pattern']}`"
300
- lines << " - Route name: #{matched['name']}_path" if matched["name"]
301
- lines << " - Controller#Action: #{matched['controller_action']}" if matched["controller_action"]
302
- else
303
- lines << "- No route matched"
304
- end
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
- # Dumps section
368
+ def self.section_dumps(profile)
369
+ lines = []
309
370
  dump_data = profile.collector_data("dump")
310
- if dump_data && dump_data["count"].to_i > 0
311
- lines << "## Variable Dumps"
312
- lines << "- Count: #{dump_data['count']}\n"
313
-
314
- dump_data["dumps"]&.each_with_index do |dump, index|
315
- label = dump['label'] || "Dump #{index + 1}"
316
- location = [dump['file'], dump['line']].compact.join(':')
317
- lines << "### #{label}"
318
- lines << "_Source: #{location}_" unless location.empty?
319
- lines << "```"
320
- lines << (dump['formatted'] || dump['value'].inspect)
321
- lines << "```"
322
- end
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.join("\n")
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
- if req["request_body_encoding"] == "base64"
95
- lines << " *(binary content, base64 encoded — #{req['request_body'].bytesize} chars)*"
96
- else
97
- lines << " ```"
98
- lines << " #{req['request_body'].lines.first(5).join(' ')}"
99
- lines << " ```"
100
- 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
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
- if req["response_body_encoding"] == "base64"
109
- lines << " *(binary content, base64 encoded — #{req['response_body'].bytesize} chars)*"
110
- else
111
- lines << " ```"
112
- lines << " #{req['response_body'].lines.first(10).join(' ')}"
113
- lines << " ```"
114
- 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
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
- 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('%Y-%m-%d %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('%Y-%m-%d %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")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.5.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.5.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-04 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