dead_bro 0.2.33 → 0.2.35

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: f1569a9a3be524d19ba616313570f01cb251647a76384347993655a8ffbaef9e
4
- data.tar.gz: 3a091b0d844b12cfb9b61cf7b0d2eec04514bb5ef988fba7203d45b4ab237e67
3
+ metadata.gz: 2544ca10e878c96153137ba46f40a543e157eff3c877b1bd3211eec15b01a9e7
4
+ data.tar.gz: 046c6f63e058e9273e8f978f42a727164c4e366c65111169c1a3d86b29ebae43
5
5
  SHA512:
6
- metadata.gz: ebc44f95253369e7c8ab6f3873ed9143e0d72382c477bae39ca09491c22c93b236b2bc1dcc07599dd755797564f4a0f25f34104cf1f95246ea8d91fa9b4c1935
7
- data.tar.gz: 6dd40034800398ceac4e82895d427e484ee7fd1b321a22882c1138c0e73514bfb4675a14437abd88cb6f4e5327c01ad85f2c7e75269956c4dd9d4e240a946714
6
+ metadata.gz: badda478e44e521b88d558413370bde506ecb7693c5a77864fc9953f9a082a97722c5c942386e16c8eeb3a15488e196d96da799a494ff9e9ee87cd304ea437bc
7
+ data.tar.gz: 18f19c4e1dc982cb7e6ec422a11ff4e443f1759b73e9841757987c4d8e9b5b3c914dc6995c65cbccf0bdd48110e3214e357b62546585558a3e6cab7ee2de83a1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.35] - 2026-10-21
4
+
5
+ ### Fixed
6
+ - **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.
7
+
8
+ ### Changed
9
+ - **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.
10
+ - `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.
11
+
12
+ ## [0.2.34] - 2026-09-29
13
+
14
+ ### Fixed
15
+ - **A request carrying bytes that aren't valid UTF-8 (e.g. a bot's user agent with a stray `\xA1`) got a 500 from the host app and wasn't recorded.** Rack hands header values over as raw bytes, and the subscriber only stripped NUL bytes, so `JSON.dump` raised `JSON::GeneratorError` inside the `process_action.action_controller` listener. Rails re-raises a listener's exception into the request. `ErrorMiddleware` then reported that error with the same raw bytes, failed the same way and swallowed it. The user agent, path, host, params, exception messages and backtraces (and every `ErrorMiddleware` field) are now converted to valid UTF-8 before sending, with invalid bytes replaced by U+FFFD, and the request is recorded with the scrubbed value.
16
+ - **A DeadBro listener or middleware can no longer raise into the host app.** Every `ActiveSupport::Notifications` callback (requests, jobs, SQL, cache, Redis, Elasticsearch, views, AR instantiation), the outgoing HTTP and Redis hooks, and `SqlTrackingMiddleware`'s setup and teardown now rescue the same set of errors and drop the metric instead. Teardown also finishes when one of its steps fails, so allocation tracing is still stopped. Before, a failing background job could have its own exception replaced by DeadBro's, and a job whose payload couldn't be built left its tracking state behind for the next job on the same worker thread. This is deliberately not `rescue Exception`: `Rack::Timeout`, `Sidekiq::Shutdown`, `Interrupt` and a plain `Timeout.timeout(sec)` that fire while DeadBro code runs still reach the host. `Timeout.timeout(sec, SomeStandardError)` can still be swallowed if it fires mid-callback, as it can by any `rescue => e`.
17
+ - **SQL containing invalid UTF-8 (e.g. a binary literal inlined by an adapter that doesn't use prepared statements) failed the host's query.** The SQL sanitizer's regexes raised `ArgumentError`; the SQL is now scrubbed first and the query is tracked.
18
+ - **A param key that isn't valid UTF-8 made DeadBro drop the request's params.** The sensitive-key check raised on it; keys are now scrubbed first, so the params are kept and still redacted. (Only reachable from a JSON body in an app with no `filter_parameters`; otherwise Rails fails the request on such a key first.)
19
+
20
+ ### Changed
21
+ - Cache keys, Redis keys and commands, job arguments and log messages are scrubbed where they're captured, so a bad byte in one of them (e.g. a cache key built from the user agent) no longer costs the request a full copy of its payload.
22
+ - If a payload still can't be JSON-encoded because some other string isn't valid UTF-8, the client retries once with every string in the payload scrubbed rather than losing it. Payloads that can't be serialized even then are dropped and counted in `DeadBro.client.serialization_failures`.
23
+
3
24
  ## [0.2.32] - 2026-09-22
4
25
 
5
26
  ### 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.
@@ -13,6 +13,7 @@ module DeadBro
13
13
  count = Thread.current[THREAD_KEY]
14
14
  next unless count
15
15
  Thread.current[THREAD_KEY] = count + (data[:record_count] || 1).to_i
16
+ rescue *DeadBro::CONTAINED_ERRORS
16
17
  end
17
18
  rescue StandardError
18
19
  # Never raise from instrumentation install
@@ -30,6 +30,7 @@ module DeadBro
30
30
  if event && should_continue_tracking?
31
31
  Thread.current[THREAD_LOCAL_KEY] << event
32
32
  end
33
+ rescue *DeadBro::CONTAINED_ERRORS
33
34
  end
34
35
  rescue
35
36
  end
@@ -85,7 +86,9 @@ module DeadBro
85
86
 
86
87
  def self.safe_key(key)
87
88
  return nil if key.nil?
88
- s = key.to_s
89
+ # Keys often embed request data (e.g. a throttle keyed by user agent);
90
+ # scrubbing here keeps one bad byte off the client's slow retry path.
91
+ s = DeadBro::Sanitizer.string(key)
89
92
  (s.length > 200) ? s[0, 200] + "…" : s
90
93
  rescue
91
94
  nil
@@ -10,6 +10,13 @@ module DeadBro
10
10
  def initialize(configuration = DeadBro.configuration)
11
11
  @configuration = configuration
12
12
  @circuit_breaker = create_circuit_breaker
13
+ @failures_mutex = Mutex.new
14
+ @serialization_failures = 0
15
+ end
16
+
17
+ # Payloads dropped because they couldn't be JSON-encoded even after scrubbing.
18
+ def serialization_failures
19
+ @failures_mutex.synchronize { @serialization_failures }
13
20
  end
14
21
 
15
22
  def post_metric(event_name:, payload:, force: false)
@@ -41,6 +48,9 @@ module DeadBro
41
48
  if sync
42
49
  # Called from the monitor thread on startup — run inline so settings are
43
50
  # applied before the first collection tick.
51
+ json = serialize(body)
52
+ return unless json
53
+
44
54
  uri = URI.parse(metrics_endpoint_url)
45
55
  http = Net::HTTP.new(uri.host, uri.port)
46
56
  http.use_ssl = (uri.scheme == "https")
@@ -49,7 +59,7 @@ module DeadBro
49
59
  request = Net::HTTP::Post.new(uri.request_uri)
50
60
  request["Content-Type"] = "application/json"
51
61
  request["Authorization"] = "Bearer #{@configuration.api_key}"
52
- request.body = JSON.dump(body)
62
+ request.body = json
53
63
  perform_request(http, request, event_name: "heartbeat", apply_settings: true)
54
64
  else
55
65
  dispatch_request(
@@ -119,6 +129,9 @@ module DeadBro
119
129
  end
120
130
 
121
131
  def dispatch_request(url:, body:, event_name:, apply_settings: false)
132
+ json = serialize(body)
133
+ return unless json
134
+
122
135
  uri = URI.parse(url)
123
136
  http = Net::HTTP.new(uri.host, uri.port)
124
137
  http.use_ssl = (uri.scheme == "https")
@@ -131,13 +144,38 @@ module DeadBro
131
144
  if @configuration.settings_received_at
132
145
  request["X-Settings-Received-At"] = @configuration.settings_received_at.utc.iso8601
133
146
  end
134
- request.body = JSON.dump(body)
147
+ request.body = json
135
148
 
136
149
  DeadBro::Dispatcher.instance.dispatch do
137
150
  perform_request(http, request, event_name: event_name, apply_settings: apply_settings)
138
151
  end
139
152
  end
140
153
 
154
+ # Must not raise: for request and job payloads this runs on the host's own
155
+ # thread. JSON.dump raises on any String that isn't valid UTF-8; the payload
156
+ # builders scrub the fields known to carry raw request bytes, and this
157
+ # catches whatever else slips through (a log line, a cache key, a job
158
+ # argument) at the cost of one retry instead of the whole payload. Returns
159
+ # nil when the payload can't be sent.
160
+ def serialize(body)
161
+ JSON.dump(body)
162
+ rescue JSON::GeneratorError, EncodingError
163
+ begin
164
+ JSON.dump(DeadBro::Sanitizer.deep(body))
165
+ rescue *DeadBro::CONTAINED_ERRORS
166
+ record_serialization_failure
167
+ end
168
+ rescue *DeadBro::CONTAINED_ERRORS
169
+ # Not an encoding problem (e.g. an object whose to_json raises), so
170
+ # scrubbing strings wouldn't help.
171
+ record_serialization_failure
172
+ end
173
+
174
+ def record_serialization_failure
175
+ @failures_mutex.synchronize { @serialization_failures += 1 }
176
+ nil
177
+ end
178
+
141
179
  def perform_request(http, request, event_name:, apply_settings: false)
142
180
  response = http.request(request)
143
181
 
@@ -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))
@@ -123,7 +123,7 @@ module DeadBro
123
123
  tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
124
124
  start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
125
125
  events << build_event(method, path, payload[:status], duration_ms, start_offset_ms)
126
- rescue
126
+ rescue *DeadBro::CONTAINED_ERRORS
127
127
  end
128
128
  end
129
129
  rescue
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "rack"
5
4
 
6
5
  module DeadBro
@@ -13,32 +12,57 @@ module DeadBro
13
12
  end
14
13
 
15
14
  def call(env)
15
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
16
  @app.call(env)
17
17
  rescue Exception => exception # rubocop:disable Lint/RescueException
18
18
  begin
19
- payload = build_payload(exception, env)
20
- # Use the error class name as the event name
21
- event_name = exception.class.name.to_s
22
- event_name = EVENT_NAME if event_name.empty?
23
- @client.post_metric(event_name: event_name, payload: payload, force: true)
24
- rescue
25
- # Never let APM reporting interfere with the host app
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
26
+ rescue *DeadBro::CONTAINED_ERRORS
27
+ # Never let APM reporting interfere with the host app — in particular,
28
+ # never replace the host's exception with one of ours.
26
29
  end
27
30
  raise
28
31
  end
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 = {
36
55
  exception_class: exception.class.name,
37
56
  message: truncate(exception.message.to_s, 1000),
38
57
  backtrace: safe_backtrace(exception),
39
- fingerprint: compute_fingerprint(exception),
40
- cause_chain: build_cause_chain(exception),
58
+ fingerprint: DeadBro::Subscriber.compute_error_fingerprint(exception),
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,
@@ -57,6 +81,10 @@ module DeadBro
57
81
  process_kind: DeadBro.process_kind,
58
82
  logs: DeadBro.logger.logs
59
83
  }
84
+ # Every field above can carry raw client bytes (headers are binary strings),
85
+ # so scrub the whole payload rather than field by field. This path only
86
+ # runs for uncaught exceptions, so the extra walk is cheap.
87
+ DeadBro::Sanitizer.deep(payload)
60
88
  end
61
89
 
62
90
  def rack_request(env)
@@ -65,44 +93,24 @@ module DeadBro
65
93
  nil
66
94
  end
67
95
 
68
- def safe_backtrace(exception)
69
- Array(exception.backtrace).first(50)
70
- rescue
71
- []
72
- end
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)
73
100
 
74
- def normalize_message(msg)
75
- msg.to_s
76
- .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "UUID")
77
- .gsub(/\b\d+\b/, "N")
78
- .gsub(/"[^"]*"/, '"?"')
79
- .gsub(/'[^']*'/, "'?'")
80
- .strip
101
+ ::ActionDispatch::ExceptionWrapper.status_code_for_exception(exception.class.name)
102
+ rescue
103
+ 500
81
104
  end
82
105
 
83
- def compute_fingerprint(exception)
84
- top_frame = Array(exception.backtrace).first.to_s.gsub(/:\d+:in /, ":N:in ")
85
- input = "#{exception.class.name}|#{normalize_message(exception.message)}|#{top_frame}"
86
- Digest::SHA256.hexdigest(input)[0, 16]
106
+ def elapsed_ms(started)
107
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0).round(2)
87
108
  rescue
88
109
  nil
89
110
  end
90
111
 
91
- def build_cause_chain(exception)
92
- return [] unless exception
93
- chain = []
94
- cause = exception.cause
95
- depth = 0
96
- while cause && depth < 5
97
- chain << {
98
- exception_class: cause.class.name,
99
- message: truncate(cause.message.to_s, 500),
100
- backtrace_top: Array(cause.backtrace).first(3)
101
- }
102
- cause = cause.cause
103
- depth += 1
104
- end
105
- chain
112
+ def safe_backtrace(exception)
113
+ Array(exception.backtrace).first(50)
106
114
  rescue
107
115
  []
108
116
  end
@@ -112,20 +120,18 @@ module DeadBro
112
120
 
113
121
  params = req.params || {}
114
122
  # Redact at every nesting level (e.g. user[password]) before serializing.
115
- JSON.parse(JSON.dump(redact_sensitive(params)))
123
+ JSON.parse(JSON.dump(DeadBro::Sanitizer.deep(redact_sensitive(params))))
116
124
  rescue
117
125
  {}
118
126
  end
119
127
 
120
- # Matches a key segment so nested/prefixed/suffixed sensitive keys are caught
121
- # without redacting innocent keys like passenger_count.
122
- SENSITIVE_SEGMENT_RE = /(?:\A|[_\-\[])(password|passwd|secret|token|api_?key|access_?key|auth|authorization|credential|ssn|credit_?card|card_?number|cvv|cvc)(?:\z|[_\-\]])/i
123
-
124
128
  def redact_sensitive(value)
125
129
  case value
126
130
  when Hash
127
131
  value.each_with_object({}) do |(k, v), memo|
128
- memo[k] = SENSITIVE_SEGMENT_RE.match?(k.to_s) ? "[FILTERED]" : redact_sensitive(v)
132
+ # Scrubbed first: the sensitive-key regex raises on a key that isn't valid UTF-8.
133
+ k = DeadBro::Sanitizer.string(k) if k.is_a?(String)
134
+ memo[k] = DeadBro::Subscriber.sensitive_key?(k) ? "[FILTERED]" : redact_sensitive(v)
129
135
  end
130
136
  when Array
131
137
  value.map { |v| redact_sensitive(v) }
@@ -79,7 +79,7 @@ module DeadBro
79
79
  Thread.current[THREAD_LOCAL_KEY] << payload
80
80
  end
81
81
  end
82
- rescue
82
+ rescue *DeadBro::CONTAINED_ERRORS
83
83
  end
84
84
  end
85
85
  end
@@ -120,7 +120,7 @@ module DeadBro
120
120
  Thread.current[THREAD_LOCAL_KEY] << payload
121
121
  end
122
122
  end
123
- rescue
123
+ rescue *DeadBro::CONTAINED_ERRORS
124
124
  end
125
125
  end
126
126
  end
@@ -177,7 +177,7 @@ module DeadBro
177
177
  Thread.current[key] << payload
178
178
  end
179
179
  end
180
- rescue
180
+ rescue *DeadBro::CONTAINED_ERRORS
181
181
  end
182
182
  end
183
183
  end
@@ -42,6 +42,7 @@ module DeadBro
42
42
  end
43
43
 
44
44
  DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
45
+ rescue *DeadBro::CONTAINED_ERRORS
45
46
  end
46
47
  rescue
47
48
  # Never raise from instrumentation install
@@ -15,7 +15,7 @@ module DeadBro
15
15
  ActiveSupport::Notifications.subscribe("perform_start.active_job") do |_name, _started, _finished, _unique_id, _data|
16
16
  DeadBro::GcTracker.start_request_tracking if defined?(DeadBro::GcTracker)
17
17
  DeadBro::ArObjectTracker.start_request_tracking if defined?(DeadBro::ArObjectTracker)
18
- rescue
18
+ rescue *DeadBro::CONTAINED_ERRORS
19
19
  end
20
20
 
21
21
  # Track job execution — success AND failure both land here. ActiveJob wraps
@@ -156,8 +156,8 @@ module DeadBro
156
156
 
157
157
  if has_error
158
158
  payload[:exception_class] = exception.class.name
159
- payload[:message] = exception.message.to_s[0, 1000]
160
- payload[:backtrace] = Array(exception.backtrace).first(50)
159
+ payload[:message] = DeadBro::Sanitizer.string(exception.message.to_s[0, 1000])
160
+ payload[:backtrace] = Array(exception.backtrace).first(50).map { |line| DeadBro::Sanitizer.string(line) }
161
161
  payload[:fingerprint] = DeadBro::Subscriber.compute_error_fingerprint(exception)
162
162
  payload[:cause_chain] = DeadBro::Subscriber.build_cause_chain(exception)
163
163
  payload[:error] = true
@@ -167,6 +167,11 @@ module DeadBro
167
167
  # completions the sampling decision above already accounted for any
168
168
  # per-job-type override, so client#post_metric must not re-roll it globally.
169
169
  client.post_metric(event_name: name, payload: payload, force: true)
170
+ rescue *DeadBro::CONTAINED_ERRORS
171
+ # Anything raised here would fail the host's job (see
172
+ # DeadBro::CONTAINED_ERRORS) — drop this job's metrics instead. Jobs have
173
+ # no Rack middleware to reset thread-locals afterwards, so drain here.
174
+ drain_job_tracking
170
175
  end
171
176
  rescue
172
177
  # Never raise from instrumentation install
@@ -259,17 +264,19 @@ module DeadBro
259
264
  def self.safe_arguments(arguments)
260
265
  return [] unless arguments.is_a?(Array)
261
266
 
262
- # Limit and sanitize job arguments
267
+ # Limit and sanitize job arguments. Strings are scrubbed to valid UTF-8 here
268
+ # so one binary argument doesn't send the payload down the client's slow retry path.
263
269
  arguments.first(10).map do |arg|
264
270
  case arg
265
271
  when String
266
- (arg.length > 200) ? arg[0, 200] + "..." : arg
272
+ str = DeadBro::Sanitizer.string(arg)
273
+ (str.length > 200) ? str[0, 200] + "..." : str
267
274
  when Hash
268
275
  # Filter sensitive keys and limit size
269
276
  filtered = arg.reject { |k, _| %w[password token secret key].include?(k.to_s) }
270
- (filtered.keys.size > 20) ? filtered.first(20).to_h : filtered
277
+ DeadBro::Sanitizer.deep((filtered.keys.size > 20) ? filtered.first(20).to_h : filtered)
271
278
  when Array
272
- arg.first(5)
279
+ DeadBro::Sanitizer.deep(arg.first(5))
273
280
  when ActiveRecord::Base
274
281
  # Handle ActiveRecord objects safely
275
282
  "#{arg.class.name}##{begin
@@ -279,7 +286,8 @@ module DeadBro
279
286
  end}"
280
287
  else
281
288
  # Convert to string and truncate, but avoid object inspection
282
- (arg.to_s.length > 200) ? arg.to_s[0, 200] + "..." : arg.to_s
289
+ str = DeadBro::Sanitizer.string(arg)
290
+ (str.length > 200) ? str[0, 200] + "..." : str
283
291
  end
284
292
  end
285
293
  rescue
@@ -72,6 +72,9 @@ module DeadBro
72
72
 
73
73
  def log(severity, message)
74
74
  timestamp = Time.now.utc
75
+ # Messages often interpolate request data; scrubbed here so one bad byte
76
+ # doesn't send the payload down the client's slow retry path.
77
+ message = DeadBro::Sanitizer.string(message)
75
78
 
76
79
  buffer = (Thread.current[@thread_logs_key] ||= [])
77
80
  if buffer.length >= MAX_LOG_ENTRIES
@@ -80,7 +83,7 @@ module DeadBro
80
83
  else
81
84
  buffer << {
82
85
  sev: severity.to_s,
83
- msg: message.to_s,
86
+ msg: message,
84
87
  time: timestamp.iso8601(3) # Include milliseconds for better precision
85
88
  }
86
89
  end
@@ -91,7 +91,7 @@ module DeadBro
91
91
  state[:buckets][parent[:phase]] += now - parent[:checkpoint]
92
92
  end
93
93
  stack << {phase: phase, checkpoint: now}
94
- rescue StandardError
94
+ rescue *DeadBro::CONTAINED_ERRORS
95
95
  # Best-effort only.
96
96
  end
97
97
 
@@ -110,7 +110,7 @@ module DeadBro
110
110
  if (parent = stack.last)
111
111
  parent[:checkpoint] = now
112
112
  end
113
- rescue StandardError
113
+ rescue *DeadBro::CONTAINED_ERRORS
114
114
  # Best-effort only.
115
115
  end
116
116
 
@@ -39,6 +39,7 @@ module DeadBro
39
39
  allocations: allocations,
40
40
  allocated_bytes: allocated_bytes
41
41
  )
42
+ rescue *DeadBro::CONTAINED_ERRORS
42
43
  end
43
44
  rescue
44
45
  # Allocation tracking might not be available in all Ruby versions
@@ -77,7 +77,7 @@ module DeadBro
77
77
  duration_ms = ((finish_time - start_time) * 1000.0).round(2)
78
78
 
79
79
  begin
80
- cmd_info = extract_command_info(command)
80
+ cmd_info = RedisSubscriber.extract_command(command: command)
81
81
  tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
82
82
  start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
83
83
  event = {
@@ -87,14 +87,14 @@ module DeadBro
87
87
  args_count: cmd_info[:args_count],
88
88
  duration_ms: duration_ms,
89
89
  start_offset_ms: start_offset_ms,
90
- db: safe_db(@db),
90
+ db: RedisSubscriber.safe_db(@db),
91
91
  error: error ? error.class.name : nil
92
92
  }
93
93
 
94
94
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
95
95
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
96
96
  end
97
- rescue
97
+ rescue *DeadBro::CONTAINED_ERRORS
98
98
  end
99
99
  end
100
100
  end
@@ -120,13 +120,13 @@ module DeadBro
120
120
  commands_count: commands_count,
121
121
  duration_ms: duration_ms,
122
122
  start_offset_ms: start_offset_ms,
123
- db: safe_db(@db)
123
+ db: RedisSubscriber.safe_db(@db)
124
124
  }
125
125
 
126
126
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
127
127
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
128
128
  end
129
- rescue
129
+ rescue *DeadBro::CONTAINED_ERRORS
130
130
  end
131
131
  end
132
132
  end
@@ -152,52 +152,16 @@ module DeadBro
152
152
  commands_count: commands_count,
153
153
  duration_ms: duration_ms,
154
154
  start_offset_ms: start_offset_ms,
155
- db: safe_db(@db)
155
+ db: RedisSubscriber.safe_db(@db)
156
156
  }
157
157
 
158
158
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
159
159
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
160
160
  end
161
- rescue
161
+ rescue *DeadBro::CONTAINED_ERRORS
162
162
  end
163
163
  end
164
164
  end
165
-
166
- def extract_command_info(command)
167
- parts = Array(command).map(&:to_s)
168
- command_name = parts.first&.upcase
169
- key = parts[1]
170
- args_count = (parts.length > 1) ? parts.length - 1 : 0
171
-
172
- {
173
- command: safe_command(command_name),
174
- key: safe_key(key),
175
- args_count: args_count
176
- }
177
- rescue
178
- {command: nil, key: nil, args_count: nil}
179
- end
180
-
181
- def safe_command(cmd)
182
- return nil if cmd.nil?
183
- cmd.to_s[0, 20]
184
- rescue
185
- nil
186
- end
187
-
188
- def safe_key(key)
189
- return nil if key.nil?
190
- s = key.to_s
191
- (s.length > 200) ? s[0, 200] + "…" : s
192
- rescue
193
- nil
194
- end
195
-
196
- def safe_db(db)
197
- Integer(db)
198
- rescue
199
- nil
200
- end
201
165
  end
202
166
 
203
167
  ::Redis::Client.prepend(mod) unless ::Redis::Client.ancestors.include?(mod)
@@ -219,6 +183,7 @@ module DeadBro
219
183
  if event && should_continue_tracking?
220
184
  Thread.current[THREAD_LOCAL_KEY] << event
221
185
  end
186
+ rescue *DeadBro::CONTAINED_ERRORS
222
187
  end
223
188
  rescue
224
189
  end
@@ -296,14 +261,16 @@ module DeadBro
296
261
 
297
262
  def self.safe_command(cmd)
298
263
  return nil if cmd.nil?
299
- cmd.to_s[0, 20]
264
+ DeadBro::Sanitizer.string(cmd)[0, 20]
300
265
  rescue
301
266
  nil
302
267
  end
303
268
 
269
+ # Redis keys can be binary (digests, packed ids) or embed request data;
270
+ # scrubbing here keeps one bad byte off the client's slow retry path.
304
271
  def self.safe_key(key)
305
272
  return nil if key.nil?
306
- s = key.to_s
273
+ s = DeadBro::Sanitizer.string(key)
307
274
  (s.length > 200) ? s[0, 200] + "…" : s
308
275
  rescue
309
276
  nil
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ # Makes values safe to JSON-encode and to store on the backend: every String
5
+ # comes out as valid UTF-8 without NUL bytes.
6
+ #
7
+ # Rack hands header values over as binary strings holding whatever bytes the
8
+ # client sent, and JSON.dump raises on any that aren't valid UTF-8 — a bot's
9
+ # user agent carrying a stray "\xA1" was enough to turn a host app's 200 into
10
+ # a 500. NUL bytes are valid JSON, but PostgreSQL rejects them in text columns.
11
+ module Sanitizer
12
+ REPLACEMENT = "�"
13
+ NUL = "\u0000"
14
+
15
+ module_function
16
+
17
+ # Returns value.to_s as valid UTF-8 without NUL bytes. A string that is
18
+ # already clean is returned as is, so the common case allocates nothing.
19
+ def string(value)
20
+ str = value.to_s
21
+ str = to_utf8(str) unless str.ascii_only? || (str.encoding == Encoding::UTF_8 && str.valid_encoding?)
22
+ str.include?(NUL) ? str.delete(NUL) : str
23
+ end
24
+
25
+ # Copies Hashes and Arrays, running every String (keys included) through
26
+ # .string. Anything else is returned untouched — Symbols too, since they come
27
+ # from code rather than from clients, and payload code looks keys up by Symbol.
28
+ def deep(value)
29
+ case value
30
+ when String
31
+ string(value)
32
+ when Hash
33
+ value.each_with_object({}) { |(k, v), out| out[deep(k)] = deep(v) }
34
+ when Array
35
+ value.map { |v| deep(v) }
36
+ else
37
+ value
38
+ end
39
+ end
40
+
41
+ def to_utf8(str)
42
+ case str.encoding
43
+ when Encoding::BINARY, Encoding::US_ASCII, Encoding::UTF_8
44
+ # Raw bytes of unknown origin, or a broken UTF-8 string: read the bytes
45
+ # as UTF-8 and replace only the sequences that aren't.
46
+ utf8 = str.dup.force_encoding(Encoding::UTF_8)
47
+ utf8.valid_encoding? ? utf8 : utf8.scrub(REPLACEMENT)
48
+ else
49
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT)
50
+ end
51
+ rescue EncodingError
52
+ # No converter from this encoding — keep whatever reads as UTF-8.
53
+ str.dup.force_encoding(Encoding::UTF_8).scrub(REPLACEMENT)
54
+ end
55
+ end
56
+ end
@@ -271,6 +271,7 @@ module DeadBro
271
271
  if should_continue_tracking?(current, MAX_TRACKED_QUERIES)
272
272
  current << query_info
273
273
  end
274
+ rescue *DeadBro::CONTAINED_ERRORS
274
275
  end
275
276
  end
276
277
 
@@ -379,6 +380,9 @@ module DeadBro
379
380
  # are big UPDATE/INSERT with long literal blobs; don't burn regex time on
380
381
  # those when we're going to truncate anyway.
381
382
  sql = sql[0..SANITIZE_MAX_LENGTH] + "..." if sql.length > SANITIZE_MAX_LENGTH
383
+ # The regexes below raise on invalid UTF-8 (e.g. a binary literal inlined
384
+ # by an adapter without prepared statements).
385
+ sql = DeadBro::Sanitizer.string(sql)
382
386
 
383
387
  # Only scan for sensitive KV pairs if one of the keywords is actually
384
388
  # present — saves two regex passes on the vast majority of queries.
@@ -419,6 +423,10 @@ module DeadBro
419
423
  return false if stripped.match?(/\AWITH\b/i) && stripped.match?(EXPLAIN_DML_KEYWORD_RE)
420
424
 
421
425
  true
426
+ rescue
427
+ # e.g. invalid UTF-8 in the SQL, which strip and match? raise on — when in
428
+ # doubt, don't run anything against the customer's database.
429
+ false
422
430
  end
423
431
 
424
432
  def self.start_explain_background(sql, connection_id, query_info, binds = nil)
@@ -494,7 +502,7 @@ module DeadBro
494
502
  timestamp = Time.now.utc
495
503
  log_entry = {
496
504
  sev: severity.to_s,
497
- msg: message.to_s,
505
+ msg: DeadBro::Sanitizer.string(message),
498
506
  time: timestamp.iso8601(3)
499
507
  }
500
508
 
@@ -778,7 +786,7 @@ module DeadBro
778
786
  # dominating CPU on N+1-heavy requests (100s of full Thread#backtrace
779
787
  # allocations). The main subscriber now captures a trimmed backtrace
780
788
  # lazily — and only when a query exceeds slow_query_threshold_ms.
781
- rescue
789
+ rescue *DeadBro::CONTAINED_ERRORS
782
790
  end
783
791
 
784
792
  def finish(name, id, payload)
@@ -796,7 +804,7 @@ module DeadBro
796
804
  delta = end_count - start_count
797
805
  results = (Thread.current[DeadBro::SqlSubscriber::THREAD_LOCAL_ALLOC_RESULTS_KEY] ||= {})
798
806
  results[id] = delta
799
- rescue
807
+ rescue *DeadBro::CONTAINED_ERRORS
800
808
  end
801
809
  end
802
810
  end
@@ -9,6 +9,18 @@ module DeadBro
9
9
  def call(env)
10
10
  return @app.call(env) if DeadBro.configuration.skip_tracking?
11
11
 
12
+ start_tracking(env)
13
+ @app.call(env)
14
+ ensure
15
+ stop_tracking
16
+ end
17
+
18
+ private
19
+
20
+ # Setup and teardown both run inside the host's request: if either raised,
21
+ # the host would get a 500 (or, from teardown, have its own response or
22
+ # exception replaced by ours). Tracking is best-effort, the request is not.
23
+ def start_tracking(env)
12
24
  # Capture rack entry time before any setup so middleware overhead is accurately measured.
13
25
  rack_entry = Time.now
14
26
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = rack_entry
@@ -87,9 +99,10 @@ module DeadBro
87
99
 
88
100
  # Start outgoing HTTP accumulation for this request
89
101
  Thread.current[:dead_bro_http_events] = []
102
+ rescue *DeadBro::CONTAINED_ERRORS
103
+ end
90
104
 
91
- @app.call(env)
92
- ensure
105
+ def stop_tracking
93
106
  # Clean up thread-local storage
94
107
  if defined?(DeadBro::SqlSubscriber)
95
108
  Thread.current[:dead_bro_sql_queries]
@@ -117,11 +130,16 @@ module DeadBro
117
130
  Thread.current[:dead_bro_lightweight_memory] = nil
118
131
  end
119
132
 
120
- # Clean up HTTP events, ES events, DB connection tracking, and tracking start time
133
+ # Clean up HTTP events, ES events, DB connection tracking, and tracking start time.
134
+ # The calls below rescue on their own: teardown has to finish, so one
135
+ # failing step must not skip the ones after it.
121
136
  Thread.current[:dead_bro_elasticsearch_events] = nil
122
137
  Thread.current[:dead_bro_http_events] = nil
123
138
  Thread.current[:dead_bro_queue_duration_ms] = nil
124
- DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
139
+ begin
140
+ DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
141
+ rescue *DeadBro::CONTAINED_ERRORS
142
+ end
125
143
  Thread.current[DeadBro::GcTracker::THREAD_KEY] = nil if defined?(DeadBro::GcTracker)
126
144
  # Bypass stop_request_tracking intentionally — cleanup only, no return value needed here.
127
145
  Thread.current[DeadBro::ArObjectTracker::THREAD_KEY] = nil if defined?(DeadBro::ArObjectTracker)
@@ -130,14 +148,18 @@ module DeadBro
130
148
  # Safety net: ensure allocation tracing is never left running across
131
149
  # requests (Subscriber normally stops it after analyzing).
132
150
  if Thread.current[:dead_bro_alloc_active]
133
- DeadBro::AllocationSourceSampler.stop if defined?(DeadBro::AllocationSourceSampler)
151
+ begin
152
+ DeadBro::AllocationSourceSampler.stop if defined?(DeadBro::AllocationSourceSampler)
153
+ rescue *DeadBro::CONTAINED_ERRORS
154
+ end
134
155
  end
156
+ rescue *DeadBro::CONTAINED_ERRORS
157
+ # Backstop for anything unforeseen above.
158
+ ensure
135
159
  Thread.current[:dead_bro_alloc_active] = nil
136
160
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = nil
137
161
  end
138
162
 
139
- private
140
-
141
163
  def parse_queue_start(env, rack_entry)
142
164
  raw = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
143
165
  return nil if raw.nil? || raw.empty?
@@ -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
@@ -189,7 +193,7 @@ module DeadBro
189
193
  begin
190
194
  exception_class, exception_message = data[:exception] if data[:exception]
191
195
  exception_obj = data[:exception_object]
192
- backtrace = Array(exception_obj&.backtrace).first(50)
196
+ backtrace = Array(exception_obj&.backtrace).first(50).map { |line| sanitize_string(line) }
193
197
 
194
198
  error_payload = detail_fields.merge(
195
199
  controller: data[:controller],
@@ -207,7 +211,7 @@ module DeadBro
207
211
  user_agent: safe_user_agent(data),
208
212
  user_id: extract_user_id(data),
209
213
  exception_class: exception_class || exception_obj&.class&.name,
210
- message: (exception_message || exception_obj&.message).to_s[0, 1000],
214
+ message: sanitize_string((exception_message || exception_obj&.message).to_s[0, 1000]),
211
215
  backtrace: backtrace,
212
216
  fingerprint: compute_error_fingerprint(exception_obj),
213
217
  cause_chain: build_cause_chain(exception_obj),
@@ -243,6 +247,10 @@ module DeadBro
243
247
  # already made above; client#post_metric must not re-roll it with the
244
248
  # global-only rate, which would silently override a per-type sample rate.
245
249
  client.post_metric(event_name: name, payload: payload, force: true)
250
+ rescue *DeadBro::CONTAINED_ERRORS
251
+ # Anything raised here would fail the host's request (see
252
+ # DeadBro::CONTAINED_ERRORS) — drop this request's metrics instead.
253
+ drain_request_tracking
246
254
  end
247
255
  end
248
256
 
@@ -275,6 +283,23 @@ module DeadBro
275
283
  # Best effort — draining must never raise from the notifications callback.
276
284
  end
277
285
 
286
+ # Set in the Rack env when the controller action raised. The exception then
287
+ # passes ErrorMiddleware on its way out (in production DebugExceptions
288
+ # re-raises it), which skips flagged requests so one error is one POST.
289
+ REPORTED_ENV_KEY = "dead_bro.reported"
290
+
291
+ def self.mark_reported(data)
292
+ return unless data[:exception] || data[:exception_object]
293
+
294
+ # The payload carries the request since Rails 6.1. Not data[:headers]:
295
+ # ActionDispatch::Http::Headers#env is a copy, the flag would be lost.
296
+ request = data[:request]
297
+ env = request.env if request.respond_to?(:env)
298
+ env[REPORTED_ENV_KEY] = true if env.is_a?(Hash)
299
+ rescue
300
+ # Best effort — without the flag ErrorMiddleware reports the exception too.
301
+ end
302
+
278
303
  def self.safe_path(data)
279
304
  path = data[:path] || (data[:request] && data[:request].path)
280
305
  sanitize_string(path)
@@ -309,7 +334,7 @@ module DeadBro
309
334
  end
310
335
 
311
336
  def self.sanitize_string(str)
312
- str.to_s.gsub("\x00", "")
337
+ DeadBro::Sanitizer.string(str)
313
338
  end
314
339
 
315
340
  # Matched against a key segment (delimited by start/end, underscore, dash, or
@@ -336,6 +361,9 @@ module DeadBro
336
361
  when Hash
337
362
  entries = value.to_a[0, max_hash_keys]
338
363
  entries.each_with_object({}) do |(k, v), memo|
364
+ # Scrubbed first: the sensitive-key regex raises on a key that isn't
365
+ # valid UTF-8, which would drop the whole params hash.
366
+ k = sanitize_string(k) if k.is_a?(String)
339
367
  memo[k] = sensitive_key?(k) ? "[FILTERED]" : truncate_value(v, max_str: max_str, max_array: max_array, max_hash_keys: max_hash_keys)
340
368
  end
341
369
  else
@@ -497,7 +525,7 @@ module DeadBro
497
525
  end
498
526
 
499
527
  def self.normalize_error_message(msg)
500
- msg.to_s
528
+ sanitize_string(msg)
501
529
  .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "UUID")
502
530
  .gsub(/\b\d+\b/, "N")
503
531
  .gsub(/"[^"]*"/, '"?"')
@@ -507,7 +535,7 @@ module DeadBro
507
535
 
508
536
  def self.compute_error_fingerprint(exception)
509
537
  return nil unless exception
510
- top_frame = Array(exception.backtrace).first.to_s.gsub(/:\d+:in /, ":N:in ")
538
+ top_frame = sanitize_string(Array(exception.backtrace).first).gsub(/:\d+:in /, ":N:in ")
511
539
  input = "#{exception.class.name}|#{normalize_error_message(exception.message)}|#{top_frame}"
512
540
  Digest::SHA256.hexdigest(input)[0, 16]
513
541
  rescue
@@ -522,8 +550,8 @@ module DeadBro
522
550
  while cause && depth < 5
523
551
  chain << {
524
552
  exception_class: cause.class.name,
525
- message: cause.message.to_s[0, 500],
526
- backtrace_top: Array(cause.backtrace).first(3)
553
+ message: sanitize_string(cause.message.to_s[0, 500]),
554
+ backtrace_top: Array(cause.backtrace).first(3).map { |line| sanitize_string(line) }
527
555
  }
528
556
  cause = cause.cause
529
557
  depth += 1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.33"
4
+ VERSION = "0.2.35"
5
5
  end
@@ -19,6 +19,7 @@ module DeadBro
19
19
  duration_ms: ((finished - started) * 1000.0).round(2),
20
20
  rendered_at: Time.now.utc.to_i,
21
21
  start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
22
+ rescue *DeadBro::CONTAINED_ERRORS
22
23
  end
23
24
 
24
25
  ActiveSupport::Notifications.subscribe(RENDER_PARTIAL_EVENT) do |_name, started, finished, _uid, data|
@@ -28,6 +29,7 @@ module DeadBro
28
29
  cache_key: data[:cache_key],
29
30
  rendered_at: Time.now.utc.to_i,
30
31
  start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
32
+ rescue *DeadBro::CONTAINED_ERRORS
31
33
  end
32
34
 
33
35
  ActiveSupport::Notifications.subscribe(RENDER_COLLECTION_EVENT) do |_name, started, finished, _uid, data|
@@ -38,6 +40,7 @@ module DeadBro
38
40
  collection_cached_count: (data[:cached_count] || 0).to_i,
39
41
  rendered_at: Time.now.utc.to_i,
40
42
  start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
43
+ rescue *DeadBro::CONTAINED_ERRORS
41
44
  end
42
45
  rescue
43
46
  end
data/lib/dead_bro.rb CHANGED
@@ -30,6 +30,7 @@ module DeadBro
30
30
  autoload :MemoryDetails, "dead_bro/memory_details"
31
31
  autoload :Logger, "dead_bro/logger"
32
32
  autoload :WatchTracker, "dead_bro/watch_tracker"
33
+ autoload :Sanitizer, "dead_bro/sanitizer"
33
34
  begin
34
35
  require "dead_bro/railtie"
35
36
  rescue LoadError
@@ -37,6 +38,23 @@ module DeadBro
37
38
 
38
39
  class Error < StandardError; end
39
40
 
41
+ # What DeadBro code running inside the host's request or job — notification
42
+ # callbacks, middleware — rescues so it can never fail the host. Rails re-raises
43
+ # a notification listener's exception into the instrumented block, so a raise
44
+ # in a process_action callback turns the host's 200 into a 500.
45
+ #
46
+ # Deliberately not Exception: an asynchronous interrupt that happens to land
47
+ # while our code runs (Timeout, Rack::Timeout, Sidekiq::Shutdown, Interrupt)
48
+ # must still reach the host, or its timeout/shutdown handling silently stops
49
+ # working. These are the errors our own code can raise synchronously.
50
+ #
51
+ # One gap remains: Timeout.timeout(sec, SomeStandardError) delivers a
52
+ # StandardError, so it is swallowed if it fires mid-callback — as it would be
53
+ # by any `rescue => e` in the host's own code, on every Ruby. Plain
54
+ # Timeout.timeout(sec) is not affected. Rescuing Exception wouldn't close that
55
+ # gap; it would only open the ones above.
56
+ CONTAINED_ERRORS = [StandardError, ScriptError, SystemStackError].freeze
57
+
40
58
  # Returned by DeadBro.analyze. sql_queries is intentionally omitted from
41
59
  # inspect/to_s/pretty_print to avoid bloating console output.
42
60
  # Access it explicitly via .sql_queries.
@@ -338,6 +356,7 @@ module DeadBro
338
356
  end
339
357
 
340
358
  local_sql_queries << {duration_ms: duration_ms, sql: normalized_sql, query_type: query_type}
359
+ rescue *DeadBro::CONTAINED_ERRORS
341
360
  end
342
361
  end
343
362
  rescue
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.33
4
+ version: 0.2.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -54,6 +54,7 @@ files:
54
54
  - lib/dead_bro/monitor.rb
55
55
  - lib/dead_bro/railtie.rb
56
56
  - lib/dead_bro/redis_subscriber.rb
57
+ - lib/dead_bro/sanitizer.rb
57
58
  - lib/dead_bro/sql_subscriber.rb
58
59
  - lib/dead_bro/sql_tracking_middleware.rb
59
60
  - lib/dead_bro/subscriber.rb
@@ -81,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
82
  - !ruby/object:Gem::Version
82
83
  version: '0'
83
84
  requirements: []
84
- rubygems_version: 4.0.16
85
+ rubygems_version: 4.0.22
85
86
  specification_version: 4
86
87
  summary: Minimal APM for Rails apps.
87
88
  test_files: []