dead_bro 0.2.33 → 0.2.34

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: 1e87bf1af27a0f9c5e26679757060bbef1d1a84e3a9ff597e997ee6665041f06
4
+ data.tar.gz: 9872d696d014494ed15f7ac83e809530a1d4f54f5d5db23d783e8c6af99f2fd2
5
5
  SHA512:
6
- metadata.gz: ebc44f95253369e7c8ab6f3873ed9143e0d72382c477bae39ca09491c22c93b236b2bc1dcc07599dd755797564f4a0f25f34104cf1f95246ea8d91fa9b4c1935
7
- data.tar.gz: 6dd40034800398ceac4e82895d427e484ee7fd1b321a22882c1138c0e73514bfb4675a14437abd88cb6f4e5327c01ad85f2c7e75269956c4dd9d4e240a946714
6
+ metadata.gz: ae026a67741f9561d7b2423cd9c35a6bd80b3c1b1b0365279b665caea0cd2d45f5e7519bae715890114458be8dbe284bab766a7437a41f8c841557fbfbb00ee2
7
+ data.tar.gz: 87c6441aa1789ca958c5980edadef4d5e1d5a73e03573d8684a02dbb62c26a31f8ae13300487901b63e4ba1fcca62fc4ba0676badd059d44783613dc21fb09af
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.34] - 2026-09-29
4
+
5
+ ### Fixed
6
+ - **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.
7
+ - **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`.
8
+ - **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.
9
+ - **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.)
10
+
11
+ ### Changed
12
+ - 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.
13
+ - 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`.
14
+
3
15
  ## [0.2.32] - 2026-09-22
4
16
 
5
17
  ### Fixed
@@ -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
 
@@ -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
@@ -21,8 +20,9 @@ module DeadBro
21
20
  event_name = exception.class.name.to_s
22
21
  event_name = EVENT_NAME if event_name.empty?
23
22
  @client.post_metric(event_name: event_name, payload: payload, force: true)
24
- rescue
25
- # Never let APM reporting interfere with the host app
23
+ rescue *DeadBro::CONTAINED_ERRORS
24
+ # Never let APM reporting interfere with the host app — in particular,
25
+ # never replace the host's exception with one of ours.
26
26
  end
27
27
  raise
28
28
  end
@@ -32,12 +32,12 @@ module DeadBro
32
32
  def build_payload(exception, env)
33
33
  req = rack_request(env)
34
34
 
35
- {
35
+ payload = {
36
36
  exception_class: exception.class.name,
37
37
  message: truncate(exception.message.to_s, 1000),
38
38
  backtrace: safe_backtrace(exception),
39
- fingerprint: compute_fingerprint(exception),
40
- cause_chain: build_cause_chain(exception),
39
+ fingerprint: DeadBro::Subscriber.compute_error_fingerprint(exception),
40
+ cause_chain: DeadBro::Subscriber.build_cause_chain(exception),
41
41
  occurred_at: Time.now.utc.to_i,
42
42
  rack:
43
43
  {
@@ -57,6 +57,10 @@ module DeadBro
57
57
  process_kind: DeadBro.process_kind,
58
58
  logs: DeadBro.logger.logs
59
59
  }
60
+ # Every field above can carry raw client bytes (headers are binary strings),
61
+ # so scrub the whole payload rather than field by field. This path only
62
+ # runs for uncaught exceptions, so the extra walk is cheap.
63
+ DeadBro::Sanitizer.deep(payload)
60
64
  end
61
65
 
62
66
  def rack_request(env)
@@ -71,61 +75,23 @@ module DeadBro
71
75
  []
72
76
  end
73
77
 
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
81
- end
82
-
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]
87
- rescue
88
- nil
89
- end
90
-
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
106
- rescue
107
- []
108
- end
109
-
110
78
  def safe_params(req)
111
79
  return {} unless req
112
80
 
113
81
  params = req.params || {}
114
82
  # Redact at every nesting level (e.g. user[password]) before serializing.
115
- JSON.parse(JSON.dump(redact_sensitive(params)))
83
+ JSON.parse(JSON.dump(DeadBro::Sanitizer.deep(redact_sensitive(params))))
116
84
  rescue
117
85
  {}
118
86
  end
119
87
 
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
88
  def redact_sensitive(value)
125
89
  case value
126
90
  when Hash
127
91
  value.each_with_object({}) do |(k, v), memo|
128
- memo[k] = SENSITIVE_SEGMENT_RE.match?(k.to_s) ? "[FILTERED]" : redact_sensitive(v)
92
+ # Scrubbed first: the sensitive-key regex raises on a key that isn't valid UTF-8.
93
+ k = DeadBro::Sanitizer.string(k) if k.is_a?(String)
94
+ memo[k] = DeadBro::Subscriber.sensitive_key?(k) ? "[FILTERED]" : redact_sensitive(v)
129
95
  end
130
96
  when Array
131
97
  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?
@@ -189,7 +189,7 @@ module DeadBro
189
189
  begin
190
190
  exception_class, exception_message = data[:exception] if data[:exception]
191
191
  exception_obj = data[:exception_object]
192
- backtrace = Array(exception_obj&.backtrace).first(50)
192
+ backtrace = Array(exception_obj&.backtrace).first(50).map { |line| sanitize_string(line) }
193
193
 
194
194
  error_payload = detail_fields.merge(
195
195
  controller: data[:controller],
@@ -207,7 +207,7 @@ module DeadBro
207
207
  user_agent: safe_user_agent(data),
208
208
  user_id: extract_user_id(data),
209
209
  exception_class: exception_class || exception_obj&.class&.name,
210
- message: (exception_message || exception_obj&.message).to_s[0, 1000],
210
+ message: sanitize_string((exception_message || exception_obj&.message).to_s[0, 1000]),
211
211
  backtrace: backtrace,
212
212
  fingerprint: compute_error_fingerprint(exception_obj),
213
213
  cause_chain: build_cause_chain(exception_obj),
@@ -243,6 +243,10 @@ module DeadBro
243
243
  # already made above; client#post_metric must not re-roll it with the
244
244
  # global-only rate, which would silently override a per-type sample rate.
245
245
  client.post_metric(event_name: name, payload: payload, force: true)
246
+ rescue *DeadBro::CONTAINED_ERRORS
247
+ # Anything raised here would fail the host's request (see
248
+ # DeadBro::CONTAINED_ERRORS) — drop this request's metrics instead.
249
+ drain_request_tracking
246
250
  end
247
251
  end
248
252
 
@@ -309,7 +313,7 @@ module DeadBro
309
313
  end
310
314
 
311
315
  def self.sanitize_string(str)
312
- str.to_s.gsub("\x00", "")
316
+ DeadBro::Sanitizer.string(str)
313
317
  end
314
318
 
315
319
  # Matched against a key segment (delimited by start/end, underscore, dash, or
@@ -336,6 +340,9 @@ module DeadBro
336
340
  when Hash
337
341
  entries = value.to_a[0, max_hash_keys]
338
342
  entries.each_with_object({}) do |(k, v), memo|
343
+ # Scrubbed first: the sensitive-key regex raises on a key that isn't
344
+ # valid UTF-8, which would drop the whole params hash.
345
+ k = sanitize_string(k) if k.is_a?(String)
339
346
  memo[k] = sensitive_key?(k) ? "[FILTERED]" : truncate_value(v, max_str: max_str, max_array: max_array, max_hash_keys: max_hash_keys)
340
347
  end
341
348
  else
@@ -497,7 +504,7 @@ module DeadBro
497
504
  end
498
505
 
499
506
  def self.normalize_error_message(msg)
500
- msg.to_s
507
+ sanitize_string(msg)
501
508
  .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
509
  .gsub(/\b\d+\b/, "N")
503
510
  .gsub(/"[^"]*"/, '"?"')
@@ -507,7 +514,7 @@ module DeadBro
507
514
 
508
515
  def self.compute_error_fingerprint(exception)
509
516
  return nil unless exception
510
- top_frame = Array(exception.backtrace).first.to_s.gsub(/:\d+:in /, ":N:in ")
517
+ top_frame = sanitize_string(Array(exception.backtrace).first).gsub(/:\d+:in /, ":N:in ")
511
518
  input = "#{exception.class.name}|#{normalize_error_message(exception.message)}|#{top_frame}"
512
519
  Digest::SHA256.hexdigest(input)[0, 16]
513
520
  rescue
@@ -522,8 +529,8 @@ module DeadBro
522
529
  while cause && depth < 5
523
530
  chain << {
524
531
  exception_class: cause.class.name,
525
- message: cause.message.to_s[0, 500],
526
- backtrace_top: Array(cause.backtrace).first(3)
532
+ message: sanitize_string(cause.message.to_s[0, 500]),
533
+ backtrace_top: Array(cause.backtrace).first(3).map { |line| sanitize_string(line) }
527
534
  }
528
535
  cause = cause.cause
529
536
  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.34"
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.34
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: []