dead_bro 0.2.34 → 0.2.36
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/CHANGELOG.md +15 -0
- data/README.md +2 -0
- data/lib/dead_bro/configuration.rb +8 -3
- data/lib/dead_bro/error_middleware.rb +46 -6
- data/lib/dead_bro/gc_tracker.rb +24 -5
- data/lib/dead_bro/job_subscriber.rb +1 -0
- data/lib/dead_bro/memory_leak_detector.rb +2 -1
- data/lib/dead_bro/subscriber.rb +29 -4
- data/lib/dead_bro/version.rb +2 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f33da7799522248140cc65b3637ae2a1dede4295b3696370bd3aabea0d09c7b
|
|
4
|
+
data.tar.gz: 376071ecd3eafe0e41695246074ccea3b3a2c479532d08eb24d46fa0d471c94e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9539bb294999bcd1d3b1f4bed9bc7612d4b9f3e66fe92769de92a0b50d8991ffadf2daf6e418b3951fcda7b8e1ae9afa5b6980c4de7af49d982ccd07aff6f2
|
|
7
|
+
data.tar.gz: 1e9abbd0ef17016ea6f639fa60d6d18edc0b481cdeec3d6495f52f5ae31e73a413db0bb7e7607b274a344d8ab1b33dda098dca608c617a024622aff3796fc8e0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Added
|
|
4
|
+
- **`gc_pressure` now says whether the window's GC had finished.** `gc_state_at_end` is the GC's phase when a request or job window closes — `"none"`, `"marking"` or `"sweeping"`, from `GC.latest_gc_info(:state)` — sent with the other memory-tracking fields. Ruby marks and sweeps in steps between allocations, so a GC counted in the window is often still running when it closes, and until it finishes `heap_live_slots_growth` still counts garbage it hasn't freed. The dashboard uses it so a sample measured mid-GC isn't flagged as a possible leak. It's a single-key read; no hash is built.
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
- **The in-process leak detector's `object_count` was always 0.** `gc_stats` never read `heap_live_slots`, so every `MemoryLeakDetector` sample recorded `object_count: 0` and the `object_stats` / `memory_efficiency` figures from `DeadBro::MemoryHelpers.analyze_memory` were meaningless. `gc_stats` now includes `heap_live_slots` — in request and job payloads alike — and the request's leak sample and payload share one `GC.stat` read. `average_memory_per_object_kb` is now actually in KB; it divided RSS in MB by the object count.
|
|
8
|
+
|
|
9
|
+
## [0.2.35] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **In production, every exception raised in a controller action was sent to DeadBro twice.** The request subscriber reports it with the request's details; the exception then reached `ErrorMiddleware` on its way out (with `consider_all_requests_local` off, `ActionDispatch::DebugExceptions` re-raises it instead of rendering the debug page), which sent it again. The dashboard already counted each error once, so the second request was wasted work for the app. The subscriber now flags the request in the Rack env (`env["dead_bro.reported"]`) and `ErrorMiddleware` skips flagged requests, so one error is one request to DeadBro. `ErrorMiddleware` still reports exceptions that never reach a controller action. An exception raised in an excluded controller is no longer sent at all.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Requests that match no route are no longer sent unless the app's settings ask for them.** Rails raises `ActionController::RoutingError` for them in the middleware stack, not in a controller, and `ErrorMiddleware` sent each one to DeadBro, which discarded it. Turn on "Track requests that match no route" in the app's settings in DeadBro (the `report_routing_errors` setting) to have them sent and stored as `ActionController::RoutingError` errors. A `RoutingError` raised by a controller action is still reported, once, by the request subscriber.
|
|
16
|
+
- `ErrorMiddleware` reports now carry the response status, the time the request took, the machine's host name and the request's host, so DeadBro can store them as requests.
|
|
17
|
+
|
|
3
18
|
## [0.2.34] - 2026-09-29
|
|
4
19
|
|
|
5
20
|
### Fixed
|
data/README.md
CHANGED
|
@@ -61,6 +61,8 @@ For **Amazon ECS**, add one of these variables in your task definition from Code
|
|
|
61
61
|
|
|
62
62
|
Use the DeadBro UI to turn features on or off, set sample rates, define controller/job inclusions and exclusions, tune slow-query EXPLAIN, enable queue and system metrics, and adjust related limits. After you deploy the initializer above, those choices take effect when the gem receives them from the API (typically on the next successful metric or heartbeat response).
|
|
63
63
|
|
|
64
|
+
Requests that match none of the app's routes (`ActionController::RoutingError`) aren't sent unless "Track requests that match no route" is on in the app's settings (`report_routing_errors`, off by default). DeadBro then stores them as `ActionController::RoutingError` errors.
|
|
65
|
+
|
|
64
66
|
### Optional local flags
|
|
65
67
|
|
|
66
68
|
You can still set `config.enabled` in Ruby if you need to force the integration off in a given environment before any remote settings arrive; otherwise the dashboard can control `enabled` like other remote settings.
|
|
@@ -23,7 +23,7 @@ module DeadBro
|
|
|
23
23
|
attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled, :allocation_sample_rate,
|
|
24
24
|
:sample_rate, :slow_query_threshold_ms, :watch_enabled,
|
|
25
25
|
:monitor_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
|
|
26
|
-
:max_sql_queries_to_send, :max_logs_to_send
|
|
26
|
+
:max_sql_queries_to_send, :max_logs_to_send, :report_routing_errors
|
|
27
27
|
|
|
28
28
|
# Readers for exclusion lists. Writers are defined below so we can compile
|
|
29
29
|
# and cache the regex form once, instead of rebuilding it per request.
|
|
@@ -73,7 +73,7 @@ module DeadBro
|
|
|
73
73
|
explain_enabled slow_query_threshold_ms max_sql_queries_to_send max_logs_to_send
|
|
74
74
|
watch_enabled excluded_controllers excluded_jobs exclusive_controllers exclusive_jobs
|
|
75
75
|
monitor_enabled enable_db_stats enable_process_stats enable_system_stats
|
|
76
|
-
sample_rates_by_type
|
|
76
|
+
sample_rates_by_type report_routing_errors
|
|
77
77
|
].freeze
|
|
78
78
|
|
|
79
79
|
def initialize
|
|
@@ -116,6 +116,10 @@ module DeadBro
|
|
|
116
116
|
@enable_db_stats = false
|
|
117
117
|
@enable_process_stats = false
|
|
118
118
|
@enable_system_stats = false
|
|
119
|
+
# A request that matches no route raises ActionController::RoutingError in
|
|
120
|
+
# the middleware stack, not in a controller. ErrorMiddleware sends those
|
|
121
|
+
# only when this is on.
|
|
122
|
+
@report_routing_errors = false
|
|
119
123
|
|
|
120
124
|
@settings_received_at = nil
|
|
121
125
|
@skip_until = nil
|
|
@@ -190,7 +194,8 @@ module DeadBro
|
|
|
190
194
|
# (explain_enabled) stays untouched; see #explain_active?
|
|
191
195
|
@remote_explain_enabled = !!value
|
|
192
196
|
when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled", "watch_enabled",
|
|
193
|
-
"monitor_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats"
|
|
197
|
+
"monitor_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats",
|
|
198
|
+
"report_routing_errors"
|
|
194
199
|
send(:"#{k}=", !!value)
|
|
195
200
|
when "excluded_controllers", "excluded_jobs", "exclusive_controllers", "exclusive_jobs"
|
|
196
201
|
send(:"#{k}=", Array(value).map(&:to_s))
|
|
@@ -12,14 +12,17 @@ module DeadBro
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def call(env)
|
|
15
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
16
|
@app.call(env)
|
|
16
17
|
rescue Exception => exception # rubocop:disable Lint/RescueException
|
|
17
18
|
begin
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
if report?(exception, env)
|
|
20
|
+
payload = build_payload(exception, env, started)
|
|
21
|
+
# Use the error class name as the event name
|
|
22
|
+
event_name = exception.class.name.to_s
|
|
23
|
+
event_name = EVENT_NAME if event_name.empty?
|
|
24
|
+
@client.post_metric(event_name: event_name, payload: payload, force: true)
|
|
25
|
+
end
|
|
23
26
|
rescue *DeadBro::CONTAINED_ERRORS
|
|
24
27
|
# Never let APM reporting interfere with the host app — in particular,
|
|
25
28
|
# never replace the host's exception with one of ours.
|
|
@@ -29,7 +32,23 @@ module DeadBro
|
|
|
29
32
|
|
|
30
33
|
private
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
# Only exceptions that never reached a controller action are this
|
|
36
|
+
# middleware's: the request subscriber reports the others, with the
|
|
37
|
+
# request's details, and flags the env. A request that matches no route
|
|
38
|
+
# raises ActionController::RoutingError here; those are sent only when the
|
|
39
|
+
# app's report_routing_errors setting is on.
|
|
40
|
+
def report?(exception, env)
|
|
41
|
+
return false if env[DeadBro::Subscriber::REPORTED_ENV_KEY]
|
|
42
|
+
return !!DeadBro.configuration.report_routing_errors if routing_error?(exception)
|
|
43
|
+
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def routing_error?(exception)
|
|
48
|
+
defined?(::ActionController::RoutingError) && exception.is_a?(::ActionController::RoutingError)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_payload(exception, env, started)
|
|
33
52
|
req = rack_request(env)
|
|
34
53
|
|
|
35
54
|
payload = {
|
|
@@ -39,6 +58,11 @@ module DeadBro
|
|
|
39
58
|
fingerprint: DeadBro::Subscriber.compute_error_fingerprint(exception),
|
|
40
59
|
cause_chain: DeadBro::Subscriber.build_cause_chain(exception),
|
|
41
60
|
occurred_at: Time.now.utc.to_i,
|
|
61
|
+
# What the backend needs to store the report as a request.
|
|
62
|
+
status: response_status(exception),
|
|
63
|
+
duration_ms: elapsed_ms(started),
|
|
64
|
+
host: DeadBro.safe_hostname,
|
|
65
|
+
request_host: DeadBro::Subscriber.safe_request_host({env: env}),
|
|
42
66
|
rack:
|
|
43
67
|
{
|
|
44
68
|
method: req&.request_method,
|
|
@@ -69,6 +93,22 @@ module DeadBro
|
|
|
69
93
|
nil
|
|
70
94
|
end
|
|
71
95
|
|
|
96
|
+
# The status Rails answers the exception with: 404 for a RoutingError, else
|
|
97
|
+
# 500 unless the app maps the class elsewhere (action_dispatch.rescue_responses).
|
|
98
|
+
def response_status(exception)
|
|
99
|
+
return 500 unless defined?(::ActionDispatch::ExceptionWrapper)
|
|
100
|
+
|
|
101
|
+
::ActionDispatch::ExceptionWrapper.status_code_for_exception(exception.class.name)
|
|
102
|
+
rescue
|
|
103
|
+
500
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def elapsed_ms(started)
|
|
107
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0).round(2)
|
|
108
|
+
rescue
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
72
112
|
def safe_backtrace(exception)
|
|
73
113
|
Array(exception.backtrace).first(50)
|
|
74
114
|
rescue
|
data/lib/dead_bro/gc_tracker.rb
CHANGED
|
@@ -29,14 +29,17 @@ module DeadBro
|
|
|
29
29
|
# Memory-tracking enrichment (a few extra GC.stat reads). Only the base
|
|
30
30
|
# GC pressure fields above are truly always-on.
|
|
31
31
|
if memory_tracking_enabled?
|
|
32
|
-
# Live heap slots
|
|
33
|
-
#
|
|
32
|
+
# Live heap slots, process-wide. How to read their delta is explained
|
|
33
|
+
# in self.diff below.
|
|
34
34
|
base[:heap_live_slots] = stat[:heap_live_slots] || 0
|
|
35
35
|
# Bytes malloc'd outside the Ruby object heap (big strings/buffers, e.g.
|
|
36
36
|
# parsed JSON response bodies). These are point-in-time gauges reset by
|
|
37
37
|
# GC, so we report the request-end value rather than a diff.
|
|
38
38
|
base[:malloc_increase_bytes] = stat[:malloc_increase_bytes] || 0
|
|
39
39
|
base[:oldmalloc_increase_bytes] = stat[:oldmalloc_increase_bytes] || 0
|
|
40
|
+
# The GC's phase right after those counters were read: :none, :marking
|
|
41
|
+
# or :sweeping. See self.diff below.
|
|
42
|
+
base[:gc_state] = gc_state
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
base
|
|
@@ -44,6 +47,13 @@ module DeadBro
|
|
|
44
47
|
{}
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# A single-key read: no hash is built. nil where the VM doesn't report it.
|
|
51
|
+
def self.gc_state
|
|
52
|
+
GC.latest_gc_info(:state) if GC.respond_to?(:latest_gc_info)
|
|
53
|
+
rescue
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
def self.diff(before, after)
|
|
48
58
|
return {} if before.empty? || after.empty?
|
|
49
59
|
gc_time_ms = if before[:gc_time_ns] && after[:gc_time_ns]
|
|
@@ -58,10 +68,19 @@ module DeadBro
|
|
|
58
68
|
|
|
59
69
|
# Present only when the enrichment was captured (memory tracking enabled).
|
|
60
70
|
if after.key?(:heap_live_slots) || before.key?(:heap_live_slots)
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
71
|
+
# Signed net change in live slots over the request. Like allocated_objects
|
|
72
|
+
# it is process-wide: every thread's allocations and frees count. It only
|
|
73
|
+
# separates transient churn from retention once a GC in the window has
|
|
74
|
+
# finished: without one, slots are freed only by the lazy sweep of an
|
|
75
|
+
# earlier GC, and a GC still running when the window closes hasn't freed
|
|
76
|
+
# all its garbage yet. It goes negative when more is freed than
|
|
77
|
+
# allocated. Read it together with minor_gc_runs/major_gc_runs and
|
|
78
|
+
# gc_state_at_end.
|
|
64
79
|
result[:heap_live_slots_growth] = (after[:heap_live_slots] || 0) - (before[:heap_live_slots] || 0)
|
|
80
|
+
# The GC's phase when the window closed: "none" once the last GC has
|
|
81
|
+
# finished, "marking" or "sweeping" while it's still running (Ruby marks
|
|
82
|
+
# and sweeps in steps between allocations).
|
|
83
|
+
result[:gc_state_at_end] = after[:gc_state].to_s if after[:gc_state]
|
|
65
84
|
# Off-heap malloc pressure pending at request end (see snapshot).
|
|
66
85
|
result[:malloc_increase_bytes] = after[:malloc_increase_bytes] || 0
|
|
67
86
|
result[:oldmalloc_increase_bytes] = after[:oldmalloc_increase_bytes] || 0
|
|
@@ -307,6 +307,7 @@ module DeadBro
|
|
|
307
307
|
count: stats[:count] || 0,
|
|
308
308
|
heap_allocated_pages: stats[:heap_allocated_pages] || 0,
|
|
309
309
|
heap_sorted_pages: stats[:heap_sorted_pages] || 0,
|
|
310
|
+
heap_live_slots: stats[:heap_live_slots] || 0,
|
|
310
311
|
total_allocated_objects: stats[:total_allocated_objects] || 0
|
|
311
312
|
}
|
|
312
313
|
else
|
|
@@ -141,8 +141,9 @@ module DeadBro
|
|
|
141
141
|
def calculate_memory_efficiency(samples)
|
|
142
142
|
return {} if samples.length < 2
|
|
143
143
|
|
|
144
|
+
# memory_usage is RSS in MB; object_count is live heap slots.
|
|
144
145
|
memory_per_object = samples.map do |sample|
|
|
145
|
-
(sample[:object_count] > 0) ? sample[:memory_usage] / sample[:object_count] : 0
|
|
146
|
+
(sample[:object_count] > 0) ? sample[:memory_usage] * 1024.0 / sample[:object_count] : 0
|
|
146
147
|
end
|
|
147
148
|
|
|
148
149
|
gc_efficiency = []
|
data/lib/dead_bro/subscriber.rb
CHANGED
|
@@ -9,6 +9,10 @@ module DeadBro
|
|
|
9
9
|
|
|
10
10
|
def self.subscribe!(client: Client.new)
|
|
11
11
|
ActiveSupport::Notifications.subscribe(EVENT_NAME) do |name, started, finished, _unique_id, data|
|
|
12
|
+
# An exception the action raised is this listener's to report (or to
|
|
13
|
+
# drop with an excluded request) — ErrorMiddleware must not send it again.
|
|
14
|
+
mark_reported(data)
|
|
15
|
+
|
|
12
16
|
# When disabled remotely, fire a heartbeat at most once per minute so the gem
|
|
13
17
|
# can detect when tracking has been re-enabled, then skip all tracking.
|
|
14
18
|
unless DeadBro.configuration.enabled
|
|
@@ -135,13 +139,16 @@ module DeadBro
|
|
|
135
139
|
}
|
|
136
140
|
end
|
|
137
141
|
|
|
142
|
+
# One GC.stat read for both the leak-detection sample and the payload.
|
|
143
|
+
end_gc_stats = gc_stats
|
|
144
|
+
|
|
138
145
|
# Record memory sample for leak detection (only if memory tracking enabled)
|
|
139
146
|
if DeadBro.configuration.memory_tracking_enabled
|
|
140
147
|
DeadBro::MemoryLeakDetector.record_memory_sample({
|
|
141
148
|
memory_usage: memory_usage_mb,
|
|
142
|
-
gc_count:
|
|
143
|
-
heap_pages:
|
|
144
|
-
object_count:
|
|
149
|
+
gc_count: end_gc_stats[:count],
|
|
150
|
+
heap_pages: end_gc_stats[:heap_allocated_pages],
|
|
151
|
+
object_count: end_gc_stats[:heap_live_slots],
|
|
145
152
|
request_id: data[:request_id],
|
|
146
153
|
controller: data[:controller],
|
|
147
154
|
action: data[:action]
|
|
@@ -158,7 +165,7 @@ module DeadBro
|
|
|
158
165
|
view_runtime_ms: data[:view_runtime],
|
|
159
166
|
db_runtime_ms: data[:db_runtime],
|
|
160
167
|
memory_usage: memory_usage_mb,
|
|
161
|
-
gc_stats:
|
|
168
|
+
gc_stats: end_gc_stats,
|
|
162
169
|
sql_count: sql_count(data),
|
|
163
170
|
sql_queries: sql_queries,
|
|
164
171
|
transaction_events: transaction_events,
|
|
@@ -279,6 +286,23 @@ module DeadBro
|
|
|
279
286
|
# Best effort — draining must never raise from the notifications callback.
|
|
280
287
|
end
|
|
281
288
|
|
|
289
|
+
# Set in the Rack env when the controller action raised. The exception then
|
|
290
|
+
# passes ErrorMiddleware on its way out (in production DebugExceptions
|
|
291
|
+
# re-raises it), which skips flagged requests so one error is one POST.
|
|
292
|
+
REPORTED_ENV_KEY = "dead_bro.reported"
|
|
293
|
+
|
|
294
|
+
def self.mark_reported(data)
|
|
295
|
+
return unless data[:exception] || data[:exception_object]
|
|
296
|
+
|
|
297
|
+
# The payload carries the request since Rails 6.1. Not data[:headers]:
|
|
298
|
+
# ActionDispatch::Http::Headers#env is a copy, the flag would be lost.
|
|
299
|
+
request = data[:request]
|
|
300
|
+
env = request.env if request.respond_to?(:env)
|
|
301
|
+
env[REPORTED_ENV_KEY] = true if env.is_a?(Hash)
|
|
302
|
+
rescue
|
|
303
|
+
# Best effort — without the flag ErrorMiddleware reports the exception too.
|
|
304
|
+
end
|
|
305
|
+
|
|
282
306
|
def self.safe_path(data)
|
|
283
307
|
path = data[:path] || (data[:request] && data[:request].path)
|
|
284
308
|
sanitize_string(path)
|
|
@@ -438,6 +462,7 @@ module DeadBro
|
|
|
438
462
|
count: stats[:count] || 0,
|
|
439
463
|
heap_allocated_pages: stats[:heap_allocated_pages] || 0,
|
|
440
464
|
heap_sorted_pages: stats[:heap_sorted_pages] || 0,
|
|
465
|
+
heap_live_slots: stats[:heap_live_slots] || 0,
|
|
441
466
|
total_allocated_objects: stats[:total_allocated_objects] || 0
|
|
442
467
|
}
|
|
443
468
|
else
|
data/lib/dead_bro/version.rb
CHANGED