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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e87bf1af27a0f9c5e26679757060bbef1d1a84e3a9ff597e997ee6665041f06
4
- data.tar.gz: 9872d696d014494ed15f7ac83e809530a1d4f54f5d5db23d783e8c6af99f2fd2
3
+ metadata.gz: 7f33da7799522248140cc65b3637ae2a1dede4295b3696370bd3aabea0d09c7b
4
+ data.tar.gz: 376071ecd3eafe0e41695246074ccea3b3a2c479532d08eb24d46fa0d471c94e
5
5
  SHA512:
6
- metadata.gz: ae026a67741f9561d7b2423cd9c35a6bd80b3c1b1b0365279b665caea0cd2d45f5e7519bae715890114458be8dbe284bab766a7437a41f8c841557fbfbb00ee2
7
- data.tar.gz: 87c6441aa1789ca958c5980edadef4d5e1d5a73e03573d8684a02dbb62c26a31f8ae13300487901b63e4ba1fcca62fc4ba0676badd059d44783613dc21fb09af
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
- payload = build_payload(exception, env)
19
- # Use the error class name as the event name
20
- event_name = exception.class.name.to_s
21
- event_name = EVENT_NAME if event_name.empty?
22
- @client.post_metric(event_name: event_name, payload: payload, force: true)
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
- def build_payload(exception, env)
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
@@ -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 — net retained objects. Comparing this delta against
33
- # allocated_objects separates transient churn from genuine retention.
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
- # Net change in live slots over the request. A small value alongside a
62
- # large allocated_objects means the memory was transient (reclaimed by
63
- # GC); a large value means objects were retained — the real leak signal.
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 = []
@@ -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: gc_stats[:count],
143
- heap_pages: gc_stats[:heap_allocated_pages],
144
- object_count: gc_stats[:heap_live_slots],
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: 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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.34"
4
+ VERSION = "0.2.36"
5
5
  end
6
+
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dead_bro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.34
4
+ version: 0.2.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa