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.
- checksums.yaml +4 -4
- data/README.md +42 -43
- data/lib/dead_bro/circuit_breaker.rb +58 -38
- data/lib/dead_bro/client.rb +112 -143
- data/lib/dead_bro/configuration.rb +76 -40
- data/lib/dead_bro/dispatcher.rb +130 -0
- data/lib/dead_bro/error_middleware.rb +1 -1
- data/lib/dead_bro/job_subscriber.rb +35 -12
- data/lib/dead_bro/lightweight_memory_tracker.rb +5 -7
- data/lib/dead_bro/logger.rb +30 -11
- data/lib/dead_bro/memory_details.rb +71 -0
- data/lib/dead_bro/memory_helpers.rb +62 -0
- data/lib/dead_bro/memory_leak_detector.rb +178 -158
- data/lib/dead_bro/memory_tracking_subscriber.rb +7 -31
- data/lib/dead_bro/monitor.rb +18 -5
- data/lib/dead_bro/railtie.rb +6 -6
- data/lib/dead_bro/sql_subscriber.rb +103 -70
- data/lib/dead_bro/subscriber.rb +36 -14
- data/lib/dead_bro/version.rb +1 -1
- data/lib/dead_bro.rb +85 -88
- metadata +3 -1
|
@@ -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, :
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
166
|
+
compiled = @compiled_exclusive_controllers
|
|
167
|
+
return true if compiled.nil? || compiled.empty?
|
|
145
168
|
target = "#{controller_name}##{action_name}"
|
|
146
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
data/lib/dead_bro/logger.rb
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|