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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e97a77a23dca55d709a1512c3c2ab3b95f0e6b15ed01b7769852587b962be8a
4
- data.tar.gz: 131c40f561129e8835c41a0b3ba456431bb11fe791c8b0add6396fbbfe0be293
3
+ metadata.gz: 8a0b42cc0bcdfe940609dbb07f9ee29a23cd00495c738711950749b22e58d664
4
+ data.tar.gz: b61678855bf48c082f4776773ce1754328c4e6b6d1672ea2542a836f79d9602a
5
5
  SHA512:
6
- metadata.gz: 7565ae06d41a7bd2d574610962e20fc3c4a6fcba5bda89482e840fb09af57c686eff9e8f4ccb02bfde05d53e0b5cc8019622ee7ea4db44fc85d6f5a0bc09c8c6
7
- data.tar.gz: bea8aee55cc58b41fc6d01c2ad4dffcdd9ecfdfc4617a45ba16b019577ad5209a2bcae5c9d226d0d5e31af1fbb3e508db8a7e3eae25c5c87f920dcad3e48d1f8
6
+ metadata.gz: bde96bed30d2bc7fda19f62a10a2a1b9d55e1e73b83bc91ae0bba63ae7fb58a0d66ca692cb3db7ca66d17c29cfc69d190123095d6cc8f428e33e4d3030733b70
7
+ data.tar.gz: 310c1ddfc31ab16fddd783db87e24bbe10a5c48058f678215f389f66056ede8af928438f7a350fd4f9ab8828f49f6daf4cdffd4f41634c76b3f9d1f0a0840f0f
@@ -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,
@@ -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,17 +81,24 @@ 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
85
90
  ),
86
91
  define_tool(
87
92
  name: "get_profile",
88
- description: "Get detailed profile data by token",
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 (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
  },
@@ -96,10 +106,11 @@ module Profiler
96
106
  ),
97
107
  define_tool(
98
108
  name: "analyze_queries",
99
- description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries",
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 (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
  },
@@ -107,10 +118,10 @@ module Profiler
107
118
  ),
108
119
  define_tool(
109
120
  name: "get_profile_ajax",
110
- description: "Get detailed AJAX sub-request breakdown for a profile",
121
+ description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
111
122
  input_schema: {
112
123
  properties: {
113
- token: { type: "string", description: "Profile token (required)" }
124
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
114
125
  },
115
126
  required: ["token"]
116
127
  },
@@ -118,10 +129,10 @@ module Profiler
118
129
  ),
119
130
  define_tool(
120
131
  name: "get_profile_dumps",
121
- description: "Get variable dumps captured during a profile",
132
+ description: "Get variable dumps captured during a profile. Use 'latest' as token to get the most recent profile.",
122
133
  input_schema: {
123
134
  properties: {
124
- token: { type: "string", description: "Profile token (required)" }
135
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
125
136
  },
126
137
  required: ["token"]
127
138
  },
@@ -129,10 +140,15 @@ module Profiler
129
140
  ),
130
141
  define_tool(
131
142
  name: "get_profile_http",
132
- description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request)",
143
+ 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
144
  input_schema: {
134
145
  properties: {
135
- token: { type: "string", description: "Profile token (required)" }
146
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
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." }
136
152
  },
137
153
  required: ["token"]
138
154
  },
@@ -145,7 +161,9 @@ module Profiler
145
161
  properties: {
146
162
  queue: { type: "string", description: "Filter by queue name" },
147
163
  status: { type: "string", description: "Filter by status (completed, failed)" },
148
- 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." }
149
167
  }
150
168
  },
151
169
  handler: Tools::QueryJobs
@@ -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
  {
@@ -35,7 +39,8 @@ module Profiler
35
39
  ]
36
40
  end
37
41
 
38
- 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)
39
44
 
40
45
  [
41
46
  {
@@ -47,58 +52,55 @@ module Profiler
47
52
 
48
53
  private
49
54
 
50
- def self.analyze_and_format(queries)
55
+ def self.analyze_and_format(queries, summary_only: false)
51
56
  lines = []
52
57
  lines << "# SQL Query Analysis\n"
53
58
 
54
- # Detect slow queries
55
59
  slow_threshold = Profiler.configuration.slow_query_threshold
56
60
  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
61
  query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
79
62
  .transform_values(&:count)
80
63
  .select { |_, count| count > 1 }
81
64
 
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"
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"
91
82
  end
92
83
 
93
- if query_counts.size > 5
94
- 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"
95
100
  end
96
- else
97
- lines << "## ✅ No Duplicate Queries"
98
- lines << "No potential N+1 query problems detected\n"
99
101
  end
100
102
 
101
- # Overall statistics
103
+ # Summary statistics always included
102
104
  lines << "## Summary Statistics"
103
105
  lines << "- **Total Queries:** #{queries.size}"
104
106
  lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
@@ -111,11 +113,10 @@ module Profiler
111
113
  end
112
114
 
113
115
  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(/"[^"]*"/, '?')
116
+ sql.gsub(/\$\d+/, "?")
117
+ .gsub(/\b\d+\b/, "?")
118
+ .gsub(/'[^']*'/, "?")
119
+ .gsub(/"[^"]*"/, "?")
119
120
  .strip
120
121
  end
121
122
  end
@@ -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