dead_bro 0.2.8 → 0.2.9

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.
@@ -11,11 +11,14 @@ module DeadBro
11
11
 
12
12
  # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
13
13
  attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled,
14
- :sample_rate, :excluded_controllers, :excluded_jobs,
15
- :exclusive_controllers, :exclusive_jobs, :slow_query_threshold_ms, :explain_analyze_enabled,
14
+ :sample_rate, :slow_query_threshold_ms, :explain_analyze_enabled,
16
15
  :job_queue_monitoring_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
17
16
  :max_sql_queries_to_send, :max_logs_to_send
18
17
 
18
+ # Readers for exclusion lists. Writers are defined below so we can compile
19
+ # and cache the regex form once, instead of rebuilding it per request.
20
+ attr_reader :excluded_controllers, :excluded_jobs, :exclusive_controllers, :exclusive_jobs
21
+
19
22
  # Tracks when we last received settings from the backend (in-memory only)
20
23
  attr_accessor :settings_received_at
21
24
 
@@ -56,10 +59,10 @@ module DeadBro
56
59
  @slow_query_threshold_ms = 500
57
60
  @max_sql_queries_to_send = 500
58
61
  @max_logs_to_send = 100
59
- @excluded_controllers = []
60
- @excluded_jobs = []
61
- @exclusive_controllers = []
62
- @exclusive_jobs = []
62
+ self.excluded_controllers = []
63
+ self.excluded_jobs = []
64
+ self.exclusive_controllers = []
65
+ self.exclusive_jobs = []
63
66
  @job_queue_monitoring_enabled = false
64
67
  @enable_db_stats = false
65
68
  @enable_process_stats = false
@@ -71,6 +74,26 @@ module DeadBro
71
74
  @settings_mutex = Mutex.new
72
75
  end
73
76
 
77
+ def excluded_controllers=(value)
78
+ @excluded_controllers = Array(value).map(&:to_s)
79
+ @compiled_excluded_controllers = compile_patterns(@excluded_controllers)
80
+ end
81
+
82
+ def excluded_jobs=(value)
83
+ @excluded_jobs = Array(value).map(&:to_s)
84
+ @compiled_excluded_jobs = compile_patterns(@excluded_jobs)
85
+ end
86
+
87
+ def exclusive_controllers=(value)
88
+ @exclusive_controllers = Array(value).map(&:to_s)
89
+ @compiled_exclusive_controllers = compile_patterns(@exclusive_controllers)
90
+ end
91
+
92
+ def exclusive_jobs=(value)
93
+ @exclusive_jobs = Array(value).map(&:to_s)
94
+ @compiled_exclusive_jobs = compile_patterns(@exclusive_jobs)
95
+ end
96
+
74
97
  # Apply a settings hash received from the backend response.
75
98
  # Only known keys are applied; unknown keys are silently ignored.
76
99
  # Serialized so concurrent HTTP threads do not interleave writes with request-thread reads.
@@ -105,45 +128,45 @@ module DeadBro
105
128
  end
106
129
 
107
130
  def excluded_controller?(controller_name, action_name = nil)
108
- return false if @excluded_controllers.empty?
131
+ compiled = @compiled_excluded_controllers
132
+ return false if compiled.nil? || compiled.empty?
109
133
 
110
- # If action_name is provided, check both controller#action patterns and controller-only patterns
111
134
  if action_name
112
135
  target = "#{controller_name}##{action_name}"
113
- # Check controller#action patterns (patterns containing '#')
114
- action_patterns = @excluded_controllers.select { |pat| pat.to_s.include?("#") }
115
- if action_patterns.any? { |pat| match_name_or_pattern?(target, pat) }
116
- return true
117
- end
118
- # Check controller-only patterns (patterns without '#')
119
- # If the controller itself is excluded, all its actions are excluded
120
- controller_patterns = @excluded_controllers.reject { |pat| pat.to_s.include?("#") }
121
- if controller_patterns.any? { |pat| match_name_or_pattern?(controller_name, pat) }
122
- return true
136
+ compiled.each do |entry|
137
+ if entry[:has_hash]
138
+ return true if match_compiled?(target, entry)
139
+ elsif match_compiled?(controller_name, entry)
140
+ return true
141
+ end
123
142
  end
124
143
  return false
125
144
  end
126
145
 
127
- # When action_name is nil, only check controller-only patterns (no #)
128
- controller_patterns = @excluded_controllers.reject { |pat| pat.to_s.include?("#") }
129
- return false if controller_patterns.empty?
130
- controller_patterns.any? { |pat| match_name_or_pattern?(controller_name, pat) }
146
+ compiled.each do |entry|
147
+ next if entry[:has_hash]
148
+ return true if match_compiled?(controller_name, entry)
149
+ end
150
+ false
131
151
  end
132
152
 
133
153
  def excluded_job?(job_class_name)
134
- return false if @excluded_jobs.empty?
135
- @excluded_jobs.any? { |pat| match_name_or_pattern?(job_class_name, pat) }
154
+ compiled = @compiled_excluded_jobs
155
+ return false if compiled.nil? || compiled.empty?
156
+ compiled.any? { |entry| match_compiled?(job_class_name, entry) }
136
157
  end
137
158
 
138
159
  def exclusive_job?(job_class_name)
139
- return true if @exclusive_jobs.empty? # If not defined, allow all (default behavior)
140
- @exclusive_jobs.any? { |pat| match_name_or_pattern?(job_class_name, pat) }
160
+ compiled = @compiled_exclusive_jobs
161
+ return true if compiled.nil? || compiled.empty?
162
+ compiled.any? { |entry| match_compiled?(job_class_name, entry) }
141
163
  end
142
164
 
143
165
  def exclusive_controller?(controller_name, action_name)
144
- return true if @exclusive_controllers.empty? # If not defined, allow all (default behavior)
166
+ compiled = @compiled_exclusive_controllers
167
+ return true if compiled.nil? || compiled.empty?
145
168
  target = "#{controller_name}##{action_name}"
146
- @exclusive_controllers.any? { |pat| match_name_or_pattern?(target, pat) }
169
+ compiled.any? { |entry| match_compiled?(target, entry) }
147
170
  end
148
171
 
149
172
  def should_sample?
@@ -170,21 +193,34 @@ module DeadBro
170
193
 
171
194
  private
172
195
 
173
- def match_name_or_pattern?(name, pattern)
174
- return false if name.nil? || pattern.nil?
175
- pat = pattern.to_s
176
- return !!(name.to_s == pat) unless pat.include?("*")
196
+ # Turn a list of user-facing patterns into {pattern, has_hash, regex}
197
+ # entries. Regex is nil when the pattern is a plain literal (cheaper eq
198
+ # compare). Compiling up-front removes per-request regex allocation.
199
+ def compile_patterns(patterns)
200
+ Array(patterns).map do |pat|
201
+ s = pat.to_s
202
+ has_hash = s.include?("#")
203
+ regex = if s.include?("*")
204
+ if has_hash
205
+ Regexp.new("\\A" + Regexp.escape(s).gsub("\\*", ".*") + "\\z")
206
+ else
207
+ Regexp.new("\\A" + Regexp.escape(s).gsub("\\*", "[^:]*") + "\\z")
208
+ end
209
+ end
210
+ {pattern: s, has_hash: has_hash, regex: regex}
211
+ end
212
+ rescue
213
+ []
214
+ end
177
215
 
178
- # For controller action patterns (containing '#'), use .* to match any characters including colons
179
- # For controller-only patterns, use [^:]* to match namespace segments
180
- regex = if pat.include?("#")
181
- # Controller action pattern: allow * to match any characters including colons
182
- Regexp.new("^" + Regexp.escape(pat).gsub("\\*", ".*") + "$")
216
+ def match_compiled?(name, entry)
217
+ return false if name.nil? || entry.nil?
218
+ n = name.to_s
219
+ if entry[:regex]
220
+ !!(n =~ entry[:regex])
183
221
  else
184
- # Controller-only pattern: use [^:]* to match namespace segments
185
- Regexp.new("^" + Regexp.escape(pat).gsub("\\*", "[^:]*") + "$")
222
+ n == entry[:pattern]
186
223
  end
187
- !!(name.to_s =~ regex)
188
224
  rescue
189
225
  false
190
226
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module DeadBro
6
+ # Background worker pool that runs HTTP posts for Client off the request
7
+ # thread. Replaces the previous `Thread.new` per metric. One shared pool per
8
+ # process; re-initializes after fork (Puma, Unicorn).
9
+ class Dispatcher
10
+ DEFAULT_QUEUE_SIZE = 500
11
+ DEFAULT_WORKERS = 2
12
+ SHUTDOWN = Object.new
13
+
14
+ class << self
15
+ def instance
16
+ @instance ||= new
17
+ end
18
+
19
+ # Exposed for tests.
20
+ def reset!
21
+ @instance&.shutdown
22
+ @instance = nil
23
+ end
24
+
25
+ # Test hook — when true, `dispatch` runs the block inline on the caller
26
+ # thread instead of handing it to a worker. Keeps specs deterministic
27
+ # without having to stub `Thread.new` or poll for queue drain.
28
+ attr_accessor :inline
29
+ end
30
+
31
+ def initialize(queue_size: DEFAULT_QUEUE_SIZE, workers: DEFAULT_WORKERS)
32
+ @queue_size = queue_size
33
+ @worker_count = workers
34
+ @mutex = Mutex.new
35
+ @dropped = 0
36
+ @shutting_down = false
37
+ boot_workers(Process.pid)
38
+ install_at_exit_hook
39
+ end
40
+
41
+ # Schedule a block for background execution. Never blocks the caller: if the
42
+ # queue is full the job is dropped and `dropped_count` is incremented.
43
+ def dispatch(&block)
44
+ return false unless block_given?
45
+ return false if @shutting_down
46
+
47
+ if self.class.inline
48
+ begin
49
+ block.call
50
+ rescue
51
+ # Match worker semantics — swallow job errors.
52
+ end
53
+ return true
54
+ end
55
+
56
+ ensure_workers_alive!
57
+ @queue.push(block, true) # non-blocking
58
+ true
59
+ rescue ThreadError
60
+ @mutex.synchronize { @dropped += 1 }
61
+ false
62
+ end
63
+
64
+ def dropped_count
65
+ @mutex.synchronize { @dropped }
66
+ end
67
+
68
+ def shutdown
69
+ return if @shutting_down
70
+ @shutting_down = true
71
+ workers = @workers || []
72
+ workers.length.times do
73
+ begin
74
+ @queue.push(SHUTDOWN)
75
+ rescue
76
+ end
77
+ end
78
+ workers.each do |t|
79
+ begin
80
+ t.join(2)
81
+ rescue
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def boot_workers(pid)
89
+ @pid = pid
90
+ @queue = SizedQueue.new(@queue_size)
91
+ @workers = Array.new(@worker_count) do
92
+ t = Thread.new { run }
93
+ begin
94
+ t.name = "dead_bro-dispatcher"
95
+ rescue
96
+ end
97
+ t.abort_on_exception = false
98
+ t
99
+ end
100
+ end
101
+
102
+ def ensure_workers_alive!
103
+ return if @pid == Process.pid && @workers && @workers.all?(&:alive?)
104
+
105
+ @mutex.synchronize do
106
+ return if @pid == Process.pid && @workers && @workers.all?(&:alive?)
107
+ # Post-fork (new PID) or a worker died — bring up a fresh pool.
108
+ boot_workers(Process.pid)
109
+ @shutting_down = false
110
+ end
111
+ end
112
+
113
+ def install_at_exit_hook
114
+ at_exit { shutdown }
115
+ rescue
116
+ end
117
+
118
+ def run
119
+ loop do
120
+ job = @queue.pop
121
+ break if job.equal?(SHUTDOWN)
122
+ begin
123
+ job.call
124
+ rescue
125
+ # Never let a job crash the worker.
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -19,7 +19,7 @@ module DeadBro
19
19
  # Use the error class name as the event name
20
20
  event_name = exception.class.name.to_s
21
21
  event_name = EVENT_NAME if event_name.empty?
22
- @client.post_metric(event_name: event_name, payload: payload)
22
+ @client.post_metric(event_name: event_name, payload: payload, force: true)
23
23
  rescue
24
24
  # Never let APM reporting interfere with the host app
25
25
  end
@@ -17,15 +17,26 @@ module DeadBro
17
17
  begin
18
18
  job_class_name = data[:job].class.name
19
19
  if DeadBro.configuration.excluded_job?(job_class_name)
20
+ drain_job_tracking
20
21
  next
21
22
  end
22
23
  # If exclusive_jobs is defined and not empty, only track matching jobs
23
24
  unless DeadBro.configuration.exclusive_job?(job_class_name)
25
+ drain_job_tracking
24
26
  next
25
27
  end
26
28
  rescue
27
29
  end
28
30
 
31
+ # Skip out via sampling before we build any payload — jobs can be chatty
32
+ # enough that even the "cheap" stop/analyze work matters under load.
33
+ # Completions have no exception attached; the exception subscriber below
34
+ # always sends errors with force: true.
35
+ unless DeadBro.configuration.should_sample?
36
+ drain_job_tracking
37
+ next
38
+ end
39
+
29
40
  duration_ms = ((finished - started) * 1000.0).round(2)
30
41
 
31
42
  # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
@@ -34,6 +45,11 @@ module DeadBro
34
45
  DeadBro.logger.clear
35
46
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
36
47
  DeadBro::SqlSubscriber.start_request_tracking
48
+ if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
49
+ DeadBro::MemoryTrackingSubscriber.start_request_tracking
50
+ else
51
+ DeadBro::LightweightMemoryTracker.start_request_tracking if defined?(DeadBro::LightweightMemoryTracker)
52
+ end
37
53
  end
38
54
 
39
55
  # Get SQL queries executed during this job
@@ -110,6 +126,11 @@ module DeadBro
110
126
  DeadBro.logger.clear
111
127
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
112
128
  DeadBro::SqlSubscriber.start_request_tracking
129
+ if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
130
+ DeadBro::MemoryTrackingSubscriber.start_request_tracking
131
+ else
132
+ DeadBro::LightweightMemoryTracker.start_request_tracking if defined?(DeadBro::LightweightMemoryTracker)
133
+ end
113
134
  end
114
135
 
115
136
  # Get SQL queries executed during this job
@@ -164,12 +185,24 @@ module DeadBro
164
185
  }
165
186
 
166
187
  event_name = exception&.class&.name || "ActiveJob::Exception"
167
- client.post_metric(event_name: event_name, payload: payload, error: true)
188
+ client.post_metric(event_name: event_name, payload: payload, force: true)
168
189
  end
169
190
  rescue
170
191
  # Never raise from instrumentation install
171
192
  end
172
193
 
194
+ # Release job-side thread-local tracking state when we've decided not to
195
+ # build a payload (excluded job / sampled out). Matches Subscriber.drain_request_tracking.
196
+ def self.drain_job_tracking
197
+ DeadBro::SqlSubscriber.stop_request_tracking if defined?(DeadBro::SqlSubscriber)
198
+ DeadBro::LightweightMemoryTracker.stop_request_tracking if defined?(DeadBro::LightweightMemoryTracker)
199
+ if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
200
+ DeadBro::MemoryTrackingSubscriber.stop_request_tracking
201
+ end
202
+ rescue
203
+ # Best effort
204
+ end
205
+
173
206
  private
174
207
 
175
208
  def self.safe_arguments(arguments)
@@ -215,17 +248,7 @@ module DeadBro
215
248
  end
216
249
 
217
250
  def self.memory_usage_mb
218
- if defined?(GC) && GC.respond_to?(:stat)
219
- # Get memory usage in MB
220
- memory_kb = begin
221
- `ps -o rss= -p #{Process.pid}`.to_i
222
- rescue
223
- 0
224
- end
225
- (memory_kb / 1024.0).round(2)
226
- else
227
- 0
228
- end
251
+ DeadBro::MemoryHelpers.rss_mb
229
252
  rescue
230
253
  0
231
254
  end
@@ -43,13 +43,11 @@ module DeadBro
43
43
  end
44
44
 
45
45
  def self.lightweight_memory_usage
46
- # Use only GC stats for memory estimation (no system calls)
47
- return 0 unless defined?(GC) && GC.respond_to?(:stat)
48
-
49
- gc_stats = GC.stat
50
- heap_pages = gc_stats[:heap_allocated_pages] || 0
51
- # Rough estimation: 4KB per page
52
- (heap_pages * 4) / 1024.0 # Convert to MB
46
+ # Real RSS, cached for ~1s across threads so this is cheap even on hot
47
+ # paths. Previous versions multiplied heap_pages by 4KB and labelled the
48
+ # result as MB — both the unit and the page size were wrong (MRI heap
49
+ # pages are ~16KB and heap != RSS), so the number was effectively fiction.
50
+ DeadBro::MemoryHelpers.rss_mb
53
51
  rescue
54
52
  0
55
53
  end
@@ -18,8 +18,14 @@ module DeadBro
18
18
  COLOR_ERROR = "\033[31m" # Red
19
19
  COLOR_FATAL = "\033[35m" # Magenta
20
20
 
21
+ # Hard cap per-thread buffer size. Prevents unbounded growth when a
22
+ # request/job logs a lot, or when tracking never gets a chance to flush
23
+ # (e.g. code running outside a request lifecycle).
24
+ MAX_LOG_ENTRIES = 500
25
+
21
26
  def initialize
22
27
  @thread_logs_key = :dead_bro_logs
28
+ @thread_logs_dropped_key = :dead_bro_logs_dropped
23
29
  end
24
30
 
25
31
  def debug(message)
@@ -42,29 +48,42 @@ module DeadBro
42
48
  log(:fatal, message)
43
49
  end
44
50
 
45
- # Get all logs for the current thread
51
+ # Get all logs for the current thread. If the buffer was capped, append a
52
+ # synthetic marker entry so downstream consumers know entries were dropped.
46
53
  def logs
47
- Thread.current[@thread_logs_key] || []
54
+ entries = Thread.current[@thread_logs_key] || []
55
+ dropped = Thread.current[@thread_logs_dropped_key] || 0
56
+ return entries if dropped.zero?
57
+
58
+ entries + [{
59
+ sev: "warn",
60
+ msg: "[DeadBro::Logger] #{dropped} log entries dropped (buffer cap #{MAX_LOG_ENTRIES})",
61
+ time: Time.now.utc.iso8601(3)
62
+ }]
48
63
  end
49
64
 
50
65
  # Clear logs for the current thread
51
66
  def clear
52
67
  Thread.current[@thread_logs_key] = []
68
+ Thread.current[@thread_logs_dropped_key] = 0
53
69
  end
54
70
 
55
71
  private
56
72
 
57
73
  def log(severity, message)
58
74
  timestamp = Time.now.utc
59
- log_entry = {
60
- sev: severity.to_s,
61
- msg: message.to_s,
62
- time: timestamp.iso8601(3) # Include milliseconds for better precision
63
- }
64
-
65
- # Store in thread-local storage
66
- Thread.current[@thread_logs_key] ||= []
67
- Thread.current[@thread_logs_key] << log_entry
75
+
76
+ buffer = (Thread.current[@thread_logs_key] ||= [])
77
+ if buffer.length >= MAX_LOG_ENTRIES
78
+ Thread.current[@thread_logs_dropped_key] =
79
+ (Thread.current[@thread_logs_dropped_key] || 0) + 1
80
+ else
81
+ buffer << {
82
+ sev: severity.to_s,
83
+ msg: message.to_s,
84
+ time: timestamp.iso8601(3) # Include milliseconds for better precision
85
+ }
86
+ end
68
87
 
69
88
  # Print the message immediately
70
89
  print_log(severity, message, timestamp)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ module MemoryDetails
5
+ # Maps Ruby internal ObjectSpace type codes to human-readable names.
6
+ # Types omitted here are filtered out (internal noise).
7
+ OBJECT_TYPE_NAMES = {
8
+ T_STRING: "String",
9
+ T_ARRAY: "Array",
10
+ T_HASH: "Hash",
11
+ T_OBJECT: "Object",
12
+ T_DATA: "C Extension",
13
+ T_CLASS: "Class",
14
+ T_MODULE: "Module",
15
+ T_STRUCT: "Struct",
16
+ T_MATCH: "MatchData",
17
+ T_REGEXP: "Regexp",
18
+ T_SYMBOL: "Symbol",
19
+ T_FLOAT: "Float",
20
+ T_FILE: "File",
21
+ T_BIGNUM: "Integer (big)"
22
+ }.freeze
23
+
24
+ # Noise types never shown to users.
25
+ SKIP_TYPES = %i[FREE T_IMEMO TOTAL T_NODE T_ICLASS T_ZOMBIE T_MOVED].freeze
26
+
27
+ def self.format_object_breakdown(deltas)
28
+ result = {}
29
+ deltas.each do |type, count|
30
+ next if SKIP_TYPES.include?(type)
31
+ next unless count.positive?
32
+ name = OBJECT_TYPE_NAMES[type] || type.to_s.sub(/\AT_/, "")
33
+ result[name] = count
34
+ end
35
+ result.sort_by { |_, v| -v }.to_h
36
+ end
37
+
38
+ def self.build(gc_before:, gc_after:, memory_before_mb:, memory_after_mb:,
39
+ object_counts_before:, object_counts_after:, large_objects:)
40
+ memory_delta_mb = (memory_after_mb - memory_before_mb).round(2)
41
+ gc_collections = (gc_after[:count] || 0) - (gc_before[:count] || 0)
42
+ heap_pages_added = (gc_after[:heap_allocated_pages] || 0) - (gc_before[:heap_allocated_pages] || 0)
43
+ new_objects = (gc_after[:total_allocated_objects] || 0) - (gc_before[:total_allocated_objects] || 0)
44
+
45
+ raw_deltas = {}
46
+ if object_counts_before.any? && object_counts_after.any?
47
+ keys = (object_counts_before.keys + object_counts_after.keys).uniq
48
+ keys.each do |k|
49
+ diff = (object_counts_after[k] || 0) - (object_counts_before[k] || 0)
50
+ raw_deltas[k] = diff unless diff.zero?
51
+ end
52
+ raw_deltas = raw_deltas.sort_by { |_, v| -v.abs }.first(20).to_h
53
+ end
54
+
55
+ warnings = []
56
+ warnings << "Memory grew #{memory_delta_mb}MB — possible leak or large allocation" if memory_delta_mb > 20
57
+ warnings << "GC ran #{gc_collections} times — many short-lived objects being created" if gc_collections > 5
58
+ warnings << "Heap grew by #{heap_pages_added} pages — Ruby needed more memory from the OS" if heap_pages_added > 10
59
+ warnings << "#{large_objects.length} object(s) over 1MB found in memory" if large_objects.any?
60
+
61
+ {
62
+ gc_collections: gc_collections,
63
+ heap_pages_added: heap_pages_added,
64
+ new_objects: new_objects,
65
+ object_breakdown: format_object_breakdown(raw_deltas),
66
+ large_objects: large_objects,
67
+ warnings: warnings
68
+ }
69
+ end
70
+ end
71
+ end
@@ -4,6 +4,68 @@ module DeadBro
4
4
  module MemoryHelpers
5
5
  # Helper methods for memory tracking and leak detection
6
6
 
7
+ RSS_CACHE_TTL_SECONDS = 1.0
8
+ @rss_cache_mutex = Mutex.new
9
+ @rss_cache = nil # [value_bytes, captured_at_monotonic]
10
+
11
+ # Current process RSS in bytes. Uses /proc/self/status on Linux (cheap read)
12
+ # and falls back to `ps` elsewhere. Result is cached for 1s across threads
13
+ # so this is safe to call from every request without flooding the kernel.
14
+ def self.rss_bytes
15
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ cached = @rss_cache
17
+ if cached && (now - cached[1]) < RSS_CACHE_TTL_SECONDS
18
+ return cached[0]
19
+ end
20
+
21
+ value = read_rss_bytes
22
+ @rss_cache_mutex.synchronize do
23
+ # Re-check inside the lock to avoid racing a newer reading.
24
+ cached = @rss_cache
25
+ if cached.nil? || (now - cached[1]) >= RSS_CACHE_TTL_SECONDS
26
+ @rss_cache = [value, now]
27
+ end
28
+ end
29
+ value
30
+ rescue
31
+ 0
32
+ end
33
+
34
+ def self.rss_mb
35
+ (rss_bytes.to_f / (1024 * 1024)).round(2)
36
+ rescue
37
+ 0.0
38
+ end
39
+
40
+ def self.read_rss_bytes
41
+ if File.readable?("/proc/self/status")
42
+ read_rss_from_proc_status
43
+ else
44
+ read_rss_from_ps
45
+ end
46
+ rescue
47
+ 0
48
+ end
49
+
50
+ def self.read_rss_from_proc_status
51
+ File.foreach("/proc/self/status") do |line|
52
+ next unless line.start_with?("VmRSS:")
53
+ kb = line.split[1].to_i
54
+ return kb * 1024 if kb > 0
55
+ end
56
+ 0
57
+ rescue
58
+ 0
59
+ end
60
+
61
+ def self.read_rss_from_ps
62
+ kb = `ps -o rss= -p #{Process.pid}`.to_i
63
+ return 0 if kb <= 0
64
+ kb * 1024
65
+ rescue
66
+ 0
67
+ end
68
+
7
69
  # Take a memory snapshot with a custom label
8
70
  def self.snapshot(label)
9
71
  DeadBro::MemoryTrackingSubscriber.take_memory_snapshot(label)