rails-profiler 0.31.0 → 0.31.2

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +53 -33
  5. data/app/controllers/profiler/api/ajax_controller.rb +6 -0
  6. data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
  7. data/lib/profiler/allocation_counter.rb +21 -0
  8. data/lib/profiler/cluster/slave_proxy.rb +6 -0
  9. data/lib/profiler/collectors/base_collector.rb +31 -0
  10. data/lib/profiler/collectors/cache_collector.rb +5 -0
  11. data/lib/profiler/collectors/database_collector.rb +5 -0
  12. data/lib/profiler/collectors/exception_collector.rb +5 -0
  13. data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
  14. data/lib/profiler/collectors/http_collector.rb +6 -1
  15. data/lib/profiler/collectors/log_collector.rb +5 -0
  16. data/lib/profiler/collectors/request_collector.rb +14 -11
  17. data/lib/profiler/collectors/view_collector.rb +5 -0
  18. data/lib/profiler/configuration.rb +33 -7
  19. data/lib/profiler/console_profiler.rb +3 -18
  20. data/lib/profiler/env_override_store.rb +54 -43
  21. data/lib/profiler/job_profiler.rb +3 -16
  22. data/lib/profiler/mcp/body_formatter.rb +4 -4
  23. data/lib/profiler/mcp/file_cache.rb +19 -7
  24. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  25. data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
  26. data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
  27. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  28. data/lib/profiler/mcp/tools/list_env_vars.rb +3 -0
  29. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
  30. data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
  31. data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
  32. data/lib/profiler/middleware/capturing_body.rb +112 -0
  33. data/lib/profiler/middleware/profiler_middleware.rb +261 -53
  34. data/lib/profiler/middleware/streamed_profile.rb +135 -0
  35. data/lib/profiler/middleware/toolbar_injector.rb +3 -10
  36. data/lib/profiler/models/profile.rb +69 -5
  37. data/lib/profiler/redaction.rb +10 -0
  38. data/lib/profiler/storage/base_store.rb +5 -1
  39. data/lib/profiler/storage/blob_store.rb +28 -4
  40. data/lib/profiler/storage/file_store.rb +21 -15
  41. data/lib/profiler/storage/memory_store.rb +6 -0
  42. data/lib/profiler/storage/private_files.rb +135 -0
  43. data/lib/profiler/storage/redis_store.rb +6 -0
  44. data/lib/profiler/storage/sqlite_store.rb +26 -8
  45. data/lib/profiler/storage/token.rb +24 -0
  46. data/lib/profiler/test_profiler.rb +3 -18
  47. data/lib/profiler/version.rb +1 -1
  48. data/lib/profiler.rb +12 -0
  49. metadata +10 -5
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "local_request"
5
+ require_relative "allocation_counter"
4
6
 
5
7
  module Profiler
6
8
  # Header the profiler's own clients send on every request. A page on another origin cannot
@@ -31,7 +33,7 @@ module Profiler
31
33
 
32
34
  attr_accessor :storage_options, :collectors,
33
35
  :skip_paths, :slow_query_threshold, :max_queries_warning,
34
- :track_memory, :memory_warning_threshold,
36
+ :track_memory, :allocated_objects_warning_threshold,
35
37
  :mcp_enabled, :mcp_transport, :mcp_port,
36
38
  :authorization_mode, :max_profiles, :extension_cors_enabled,
37
39
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
@@ -39,18 +41,16 @@ module Profiler
39
41
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
40
42
  :track_jobs,
41
43
  :track_console,
42
- :apply_env_overrides_when_disabled,
44
+ :apply_env_overrides_when_disabled, :restrict_storage_permissions,
43
45
  :test_runner_allow_undiscovered_files,
44
46
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
45
- :compress_bodies, :compress_body_threshold,
47
+ :compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
46
48
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
47
49
  :name, :master_url, :self_url,
48
50
  :cluster_heartbeat_interval, :cluster_offline_threshold,
49
51
  :cluster_master, :cluster_secret, :cluster_require_secret,
50
52
  :cluster_allow_insecure_http
51
53
 
52
- attr_writer :tmp_path
53
-
54
54
  attr_reader :authorize_block, :enabled, :track_tests
55
55
 
56
56
  def initialize
@@ -63,7 +63,9 @@ module Profiler
63
63
  @slow_query_threshold = 100 # milliseconds
64
64
  @max_queries_warning = 50
65
65
  @track_memory = true
66
- @memory_warning_threshold = 100 * 1024 * 1024 # 100 MB
66
+ # Not compared with anything yet. The default is the former 100 MB memory threshold read
67
+ # as the objects it stood for.
68
+ @allocated_objects_warning_threshold = 100 * 1024 * 1024 / AllocationCounter::LEGACY_BYTES_PER_OBJECT
67
69
  @mcp_enabled = false
68
70
  @mcp_transport = :stdio
69
71
  @mcp_port = 3001
@@ -96,10 +98,16 @@ module Profiler
96
98
  @mailer_skip_actions = []
97
99
  @compress_bodies = true
98
100
  @compress_body_threshold = 10 * 1024 # 10 KB
101
+ # The request and response bodies kept in a profile stop at this many bytes; the
102
+ # application still reads and sends all of them. nil keeps whole bodies.
103
+ @max_captured_body_bytes = 256 * 1024
99
104
  @redact_sensitive_data = true
100
105
  @filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
101
106
  @env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
102
107
  @tmp_path = nil
108
+ # The profiler's directories 0700 and files 0600 (Profiler::Storage::PrivateFiles). false
109
+ # leaves the modes to the umask, for a web process and workers running under two users.
110
+ @restrict_storage_permissions = true
103
111
  @name = nil
104
112
  @master_url = nil
105
113
  @self_url = nil
@@ -113,12 +121,30 @@ module Profiler
113
121
  @cluster_allow_insecure_http = false
114
122
  end
115
123
 
124
+ # Always a Pathname, whether set from a String or left to its default.
116
125
  def tmp_path
117
126
  @tmp_path || default_tmp_path
118
127
  end
119
128
 
129
+ def tmp_path=(value)
130
+ @tmp_path = value.nil? ? nil : Pathname.new(value)
131
+ end
132
+
120
133
  attr_reader :cluster_allowed_slave_urls
121
134
 
135
+ # Deprecated: the figure it was meant for was never a byte count (see AllocationCounter).
136
+ # Read and written as allocated_objects_warning_threshold times the bytes per object the
137
+ # former figure used.
138
+ def memory_warning_threshold
139
+ @allocated_objects_warning_threshold&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
140
+ end
141
+
142
+ def memory_warning_threshold=(bytes)
143
+ warn "[Profiler] memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
144
+ "(a number of objects) instead"
145
+ @allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
146
+ end
147
+
122
148
  # :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
123
149
  # A Regexp that is not anchored with \A and \z is refused here, at boot, rather than ignored.
124
150
  def cluster_allowed_slave_urls=(value)
@@ -226,7 +252,7 @@ module Profiler
226
252
  if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
227
253
  Rails.root.join("tmp", "rails-profiler")
228
254
  else
229
- File.expand_path("tmp/rails-profiler", Dir.pwd)
255
+ Pathname.new(File.expand_path("tmp/rails-profiler", Dir.pwd))
230
256
  end
231
257
  end
232
258
 
@@ -48,7 +48,7 @@ module Profiler
48
48
 
49
49
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
50
50
 
51
- memory_before = current_memory if Profiler.configuration.track_memory
51
+ allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
52
52
 
53
53
  console_status = "completed"
54
54
 
@@ -66,7 +66,7 @@ module Profiler
66
66
  ensure
67
67
  Profiler::CurrentContext.token = previous_token
68
68
  if Profiler.configuration.track_memory
69
- profile.memory = current_memory - memory_before
69
+ profile.allocated_objects = AllocationCounter.current - allocations_before
70
70
  end
71
71
 
72
72
  profile.finish(console_status == "completed" ? 200 : 500)
@@ -80,26 +80,11 @@ module Profiler
80
80
  end
81
81
  end
82
82
 
83
- Profiler.storage.save(profile.token, profile)
83
+ Profiler.save_profile(profile, from: "ConsoleProfiler")
84
84
  end
85
85
  ensure
86
86
  # After collect, and also when collect or the storage failed.
87
87
  Collectors::Lifecycle.release_all(collectors)
88
88
  end
89
-
90
- private
91
-
92
- def current_memory
93
- return 0 unless defined?(GC.stat)
94
-
95
- stats = GC.stat
96
- if stats.key?(:total_allocated_size)
97
- stats[:total_allocated_size]
98
- elsif stats.key?(:total_allocated_objects)
99
- stats[:total_allocated_objects] * 40
100
- else
101
- 0
102
- end
103
- end
104
89
  end
105
90
  end
@@ -2,9 +2,16 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require_relative "storage/private_files"
5
6
 
6
7
  module Profiler
7
8
  class EnvOverrideStore
9
+ # Raised by the methods a caller asked for (set, delete, reset, reset_all, clear and
10
+ # all_overrides) when the overrides file cannot be read or written. The caller then knows that
11
+ # nothing was changed. apply! and apply_at_boot! still only warn: they run at boot and before
12
+ # the application's jobs, where an override must never make the application fail.
13
+ class Error < Profiler::Error; end
14
+
8
15
  DELETED_SENTINEL = "__profiler_deleted__"
9
16
  RESTORE_SENTINEL = "__profiler_restore__"
10
17
 
@@ -30,53 +37,53 @@ module Profiler
30
37
  end
31
38
 
32
39
  def set(key, value)
33
- remember_process_original(key)
34
- update_overrides do |overrides|
35
- overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
40
+ surfacing("set #{key}") do
41
+ remember_process_original(key)
42
+ update_overrides do |overrides|
43
+ overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
44
+ end
36
45
  end
37
- rescue => e
38
- warn "[Profiler] EnvOverrideStore: failed to set #{key}: #{e.message}"
39
46
  end
40
47
 
41
48
  def delete(key)
42
- remember_process_original(key)
43
- update_overrides do |overrides|
44
- overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
49
+ surfacing("delete #{key}") do
50
+ remember_process_original(key)
51
+ update_overrides do |overrides|
52
+ overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
53
+ end
45
54
  end
46
- rescue => e
47
- warn "[Profiler] EnvOverrideStore: failed to delete #{key}: #{e.message}"
48
55
  end
49
56
 
50
57
  # Returns whether ENV was written in this process.
51
58
  def reset(key)
52
59
  entry = nil
53
- update_overrides do |overrides|
54
- entry = overrides[key]
55
- # Keep a RESTORE entry so Sidekiq workers pick it up on next job
56
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
60
+ surfacing("reset #{key}") do
61
+ update_overrides do |overrides|
62
+ entry = overrides[key]
63
+ # Keep a RESTORE entry so Sidekiq workers pick it up on next job
64
+ overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
65
+ end
57
66
  end
58
67
 
59
68
  # Apply immediately to the current (web) process
60
69
  restore_env(key, entry, blocked: blocked_reason)
61
- rescue => e
62
- warn "[Profiler] EnvOverrideStore: failed to reset #{key}: #{e.message}"
63
70
  end
64
71
 
65
72
  def reset_all
66
73
  originals = {}
67
- update_overrides do |overrides|
68
- overrides.each do |key, entry|
69
- originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
70
- # Leave a RESTORE sentinel for Sidekiq workers to pick up
71
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
74
+ surfacing("reset the overrides") do
75
+ update_overrides do |overrides|
76
+ overrides.each do |key, entry|
77
+ originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
78
+ # Leave a RESTORE sentinel for Sidekiq workers to pick up
79
+ overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
80
+ end
72
81
  end
73
82
  end
74
83
 
75
84
  # Apply immediately to the current (web) process
76
85
  blocked = blocked_reason
77
86
  originals.each { |key, original| restore_env(key, { "original" => original }, blocked: blocked) }
78
- rescue => e
79
- warn "[Profiler] EnvOverrideStore: failed to reset all: #{e.message}"
80
87
  end
81
88
 
82
89
  # Why the persisted overrides must not reach ENV in this process, or nil when they may.
@@ -154,27 +161,36 @@ module Profiler
154
161
  warn "[Profiler] EnvOverrideStore: failed to apply overrides: #{e.message}"
155
162
  end
156
163
 
157
- # Returns active overrides (excludes RESTORE entries — already being reverted)
164
+ # Returns active overrides (excludes RESTORE entries, already being reverted)
165
+ # A file that does not parse reads as no override; one that cannot be read raises Error.
158
166
  def all_overrides
159
- load_overrides.reject do |_, entry|
160
- value = entry.is_a?(Hash) ? entry["value"] : entry
161
- value == RESTORE_SENTINEL
162
- end.transform_values do |entry|
163
- entry.is_a?(Hash) ? entry : { "value" => entry, "original" => nil }
167
+ surfacing("read the overrides") do
168
+ load_overrides.reject do |_, entry|
169
+ value = entry.is_a?(Hash) ? entry["value"] : entry
170
+ value == RESTORE_SENTINEL
171
+ end.transform_values do |entry|
172
+ entry.is_a?(Hash) ? entry : { "value" => entry, "original" => nil }
173
+ end
164
174
  end
165
- rescue => e
166
- warn "[Profiler] EnvOverrideStore: failed to load overrides: #{e.message}"
167
- {}
168
175
  end
169
176
 
170
177
  def clear
171
- with_lock { FileUtils.rm_f(override_file_path) }
172
- rescue => e
173
- warn "[Profiler] EnvOverrideStore: failed to clear: #{e.message}"
178
+ surfacing("clear the overrides") { with_lock { FileUtils.rm_f(override_file_path) } }
174
179
  end
175
180
 
176
181
  private
177
182
 
183
+ # The message names the file relative to tmp_path, and an Errno by its description only: it
184
+ # goes back to the Env tab, the MCP tools and the test runner output, without absolute paths.
185
+ def surfacing(action)
186
+ yield
187
+ rescue Error
188
+ raise
189
+ rescue StandardError => e
190
+ reason = e.is_a?(SystemCallError) ? e.class.new.message : e.class.name
191
+ raise Error, "could not #{action} in env_overrides.json under tmp_path: #{reason}"
192
+ end
193
+
178
194
  def remember_process_original(key)
179
195
  @process_originals_lock.synchronize do
180
196
  @process_originals[key] = ENV[key] unless @process_originals.key?(key)
@@ -227,8 +243,8 @@ module Profiler
227
243
  # An exclusive flock on a file next to the overrides file. Not reentrant: never nest it.
228
244
  def with_lock
229
245
  path = override_file_path
230
- FileUtils.mkdir_p(path.dirname)
231
- File.open("#{path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
246
+ Storage::PrivateFiles.tmp_dir
247
+ Storage::PrivateFiles.open("#{path}.lock") do |lock|
232
248
  lock.flock(File::LOCK_EX)
233
249
  yield
234
250
  end
@@ -237,12 +253,7 @@ module Profiler
237
253
  # Written to a temporary file then renamed over the old one, so that a reader never sees a
238
254
  # half-written file, which would parse as no override at all.
239
255
  def save_overrides(overrides)
240
- path = override_file_path
241
- tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
242
- File.write(tmp, JSON.generate(overrides))
243
- File.rename(tmp, path)
244
- ensure
245
- FileUtils.rm_f(tmp) if tmp
256
+ Storage::PrivateFiles.write(override_file_path, JSON.generate(overrides))
246
257
  end
247
258
  end
248
259
  end
@@ -71,7 +71,7 @@ module Profiler
71
71
 
72
72
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
73
73
 
74
- memory_before = current_memory if Profiler.configuration.track_memory
74
+ allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
75
75
 
76
76
  job_status = "completed"
77
77
  error_message = nil
@@ -92,7 +92,7 @@ module Profiler
92
92
  Thread.current[:profiler_current_job_class] = previous_job_class
93
93
  Profiler::CurrentContext.token = previous_token
94
94
  if Profiler.configuration.track_memory
95
- profile.memory = current_memory - memory_before
95
+ profile.allocated_objects = AllocationCounter.current - allocations_before
96
96
  end
97
97
 
98
98
  job_collector.update_status(job_status, error_message)
@@ -107,7 +107,7 @@ module Profiler
107
107
  end
108
108
  end
109
109
 
110
- Profiler.storage.save(profile.token, profile)
110
+ Profiler.save_profile(profile, from: "JobProfiler")
111
111
  end
112
112
  ensure
113
113
  # After collect, and also when collect or the storage failed.
@@ -131,18 +131,5 @@ module Profiler
131
131
  arg.to_s
132
132
  end
133
133
  end
134
-
135
- def current_memory
136
- return 0 unless defined?(GC.stat)
137
-
138
- stats = GC.stat
139
- if stats.key?(:total_allocated_size)
140
- stats[:total_allocated_size]
141
- elsif stats.key?(:total_allocated_objects)
142
- stats[:total_allocated_objects] * 40
143
- else
144
- 0
145
- end
146
- end
147
134
  end
148
135
  end
@@ -9,10 +9,10 @@ module Profiler
9
9
  # Formats a body string for MCP output.
10
10
  #
11
11
  # params keys used:
12
- # "save_bodies" (boolean) — save to ./tmp/rails-profiler/{token}/{name} and return path
13
- # "max_body_size" (number) — truncate inline body at N chars
14
- # "json_path" (string) — JSONPath to extract from body when save_bodies is true
15
- # "xml_path" (string) — XPath to extract from body when save_bodies is true
12
+ # "save_bodies" (boolean): save to {tmp_path}/mcp-cache/{token}/{name} and return path
13
+ # "max_body_size" (number): truncate inline body at N chars
14
+ # "json_path" (string): JSONPath to extract from body when save_bodies is true
15
+ # "xml_path" (string): XPath to extract from body when save_bodies is true
16
16
  #
17
17
  # Returns a formatted string ready to embed in markdown, or nil if body is blank.
18
18
  def self.format_body(token, name, body, encoding, params)
@@ -1,22 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "../storage/private_files"
5
+ require_relative "../storage/token"
4
6
 
5
7
  module Profiler
6
8
  module MCP
9
+ # The bodies the MCP tools save on request (save_bodies), under tmp_path/mcp-cache/<token>/<name>.
10
+ # The token comes from a profile, which a slave may have sent: one that is not a token the gem
11
+ # issues, or a name that is not one the tools use, is refused. The cleanup only removes the
12
+ # token directories of this cache: tmp_path also holds the profiles, the SQLite blobs and the
13
+ # env overrides.
7
14
  class FileCache
15
+ DIR_NAME = "mcp-cache"
16
+ NAME_FORMAT = /\A[a-z0-9_]+\z/
17
+
8
18
  def self.base_dir
9
- Profiler.configuration.tmp_path.to_s
19
+ Profiler.configuration.tmp_path.join(DIR_NAME).to_s
10
20
  end
11
21
 
12
22
  def self.save(token, name, content)
23
+ return nil unless Storage::Token.valid?(token) && name.is_a?(String) && NAME_FORMAT.match?(name)
24
+
13
25
  cleanup if rand < 0.05
14
26
 
15
- dir = File.join(base_dir, token)
16
- FileUtils.mkdir_p(dir)
17
- path = File.join(dir, name)
18
- File.write(path, content)
19
- path
27
+ dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
28
+ Storage::PrivateFiles.write(dir.join(name), content)
20
29
  rescue Errno::EACCES, Errno::EROFS
21
30
  nil
22
31
  end
@@ -25,7 +34,10 @@ module Profiler
25
34
  bd = base_dir
26
35
  return unless Dir.exist?(bd)
27
36
 
28
- Dir.glob(File.join(bd, "*")).each do |dir|
37
+ Dir.children(bd).each do |entry|
38
+ next unless Storage::Token.valid?(entry)
39
+
40
+ dir = File.join(bd, entry)
29
41
  FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
30
42
  end
31
43
  end
@@ -14,6 +14,8 @@ module Profiler
14
14
  method: profile.method,
15
15
  status: profile.status,
16
16
  duration: profile.duration&.round(2),
17
+ allocated_objects: profile.allocated_objects,
18
+ # Deprecated, kept for the readers that expect it: allocated_objects times 40, in MB.
17
19
  memory: profile.memory ? (profile.memory / 1024.0 / 1024.0).round(2) : nil,
18
20
  timestamp: profile.started_at&.iso8601,
19
21
  query_count: profile.collector_data("database")&.dig("total_queries") || 0,
@@ -26,7 +26,9 @@ module Profiler
26
26
  Profiler.env_override_store.delete(key)
27
27
  ENV.delete(key)
28
28
 
29
- [{ type: "text", text: "Deleted #{key}. Override persisted — will remain deleted across restarts until reset." }]
29
+ [{ type: "text", text: "Deleted #{key}. Override persisted: it will remain deleted across restarts until reset." }]
30
+ rescue Profiler::EnvOverrideStore::Error => e
31
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
30
32
  end
31
33
  end
32
34
  end
@@ -87,7 +87,7 @@ module Profiler
87
87
  lines << "**Request:** #{profile.method} #{profile.path}"
88
88
  lines << "**Status:** #{profile.status}"
89
89
  lines << "**Duration:** #{profile.duration.round(2)} ms"
90
- lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
90
+ lines << "**Allocated objects:** #{profile.allocated_objects}" if profile.allocated_objects
91
91
  lines << "**Time:** #{profile.started_at}"
92
92
  if profile.gem_version
93
93
  if profile.gem_version != Profiler::VERSION
@@ -184,6 +184,9 @@ module Profiler
184
184
  req_body = req_data["request_body"]
185
185
  if req_body && !req_body.empty?
186
186
  lines << "## Request Body"
187
+ if req_data["request_body_truncated"]
188
+ lines << truncation_note(req_data["request_body_size"], req_data["request_body_size_is_minimum"])
189
+ end
187
190
  formatted = BodyFormatter.format_body(
188
191
  profile.token,
189
192
  "request_body",
@@ -209,6 +212,10 @@ module Profiler
209
212
  resp_body = profile.response_body
210
213
  if resp_body && !resp_body.empty?
211
214
  lines << "## Response Body"
215
+ response_data = profile.collector_data("request") || {}
216
+ if response_data["response_body_truncated"]
217
+ lines << truncation_note(response_data["response_body_size"], response_data["response_body_size_is_minimum"])
218
+ end
212
219
  formatted = BodyFormatter.format_body(
213
220
  profile.token,
214
221
  "response_body",
@@ -222,6 +229,11 @@ module Profiler
222
229
  lines
223
230
  end
224
231
 
232
+ def self.truncation_note(size, minimum)
233
+ "_Truncated: the body was #{"at least " if minimum}#{size || "?"} bytes, only its beginning was kept " \
234
+ "(max_captured_body_bytes)._"
235
+ end
236
+
225
237
  def self.section_curl(profile)
226
238
  req_data = profile.collector_data("request")
227
239
  lines = []
@@ -52,7 +52,7 @@ module Profiler
52
52
  lines << "| File | #{test_data["test_file"]}:#{test_data["test_line"]} |"
53
53
  lines << "| Duration | #{profile.duration&.round(2)}ms |"
54
54
  lines << "| Assertions | #{test_data["assertions"] || "-"} |"
55
- lines << "| Memory delta | #{profile.memory ? "#{(profile.memory.to_f / 1024 / 1024).round(2)} MB" : "-"} |"
55
+ lines << "| Allocated objects | #{profile.allocated_objects || "-"} |"
56
56
  lines << "| Time | #{profile.started_at&.strftime("%H:%M:%S")} |"
57
57
 
58
58
  # Exception / skip
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
  require_relative "../../env_override_store"
5
6
  require_relative "../../redaction"
@@ -61,6 +62,8 @@ module Profiler
61
62
  end
62
63
 
63
64
  [{ type: "text", text: text }]
65
+ rescue Profiler::EnvOverrideStore::Error => e
66
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}." }], error: true)
64
67
  end
65
68
 
66
69
  private
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
 
5
6
  module Profiler
@@ -22,6 +23,8 @@ module Profiler
22
23
  Profiler.env_override_store.reset_all
23
24
 
24
25
  [{ type: "text", text: "Reset #{count} environment variable#{"s" if count != 1} to original values." }]
26
+ rescue Profiler::EnvOverrideStore::Error => e
27
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; nothing was changed." }], error: true)
25
28
  end
26
29
  end
27
30
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
 
5
6
  require "uri"
@@ -27,6 +28,8 @@ module Profiler
27
28
 
28
29
  done = restored ? "original value restored" : "ENV left unchanged"
29
30
  [{ type: "text", text: "Reset #{key}: override removed, #{done} in this process." }]
31
+ rescue Profiler::EnvOverrideStore::Error => e
32
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
30
33
  end
31
34
  end
32
35
  end
@@ -33,6 +33,8 @@ module Profiler
33
33
  ENV[key] = value
34
34
 
35
35
  [{ type: "text", text: "Set #{key}=#{value}" }]
36
+ rescue Profiler::EnvOverrideStore::Error => e
37
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
36
38
  end
37
39
  end
38
40
  end