dead_bro 0.2.31 → 0.2.32

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: b6c0f13b23ff342d0ea4365872e2f380a949a8aaf3fd69d98606336854d14c9c
4
- data.tar.gz: a9b8a2fae51c26e7cd9b2f1d676c6742a5a5b4829f0c182417af7eabdf2581c6
3
+ metadata.gz: 22b021f41cc62097a8fc1f1aedf489e8ee1dca10b2b2abc6edbce6809763b39a
4
+ data.tar.gz: 30c442462399195cec1edb2f9505aa51896230da92d18ad97f5eca35d2749221
5
5
  SHA512:
6
- metadata.gz: e70b0d0b091dfacf1db61ea287a47bb1944153968bc3f225c943baf6a195c099dfd88199e59a942230284f3cfc94dcbf207d942e03630ac14850f818c1dc0928
7
- data.tar.gz: 1e5f8605da3bf8c418d4c16025be5a24b7f89af1ca47d7bb6c767f790205c498e3023543b739b1568156547c21b8cfaf2bf327c573d8177aad88049e395352f2
6
+ metadata.gz: 8d0cbef4fcc8b05de9e8a4c1c357352a17cd133b372ab0239b2402b4d8e7322728a881f0679cbfe0df812a0c3e4143e78d35279a7780e525810efc438b6d5d90
7
+ data.tar.gz: 81bd8d1e49b663c5ae1d651e26abcaa2eb3dcc10f7bf71155d79da9a59d03aafaee9dc282670fd376b9b99991a3074763ed2abf5461f405022d26f86fe4ecc23
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.32] - 2026-09-22
4
+
5
+ ### Fixed
6
+ - **Errored requests now ship the SQL/cache/memory/etc data captured before the exception, not just the exception itself.** Previously, as soon as a request raised (e.g. an `ActiveRecord::LockWaitTimeout` mid-transaction), the subscriber discarded every already-captured `sql_queries`, `cache_events`, `ar_instantiation_count`, `memory_events`, `gc_stats`, etc. entry and sent only exception metadata — so error pages showed a backtrace with zero query context even when the gem had captured it. The error payload now carries the same detail fields a successful request would.
7
+ - **Background job failures were reported to the dashboard as successful completions, not dropped.** There is no `exception.active_job` event anywhere in Rails/ActiveJob — the handler that subscribed to it never fired. `perform.active_job` (the real event) fires whether or not the job raised, but its handler built `status: "completed"` unconditionally and never inspected `data[:exception_object]`, so a failing job wasn't invisible — it was recorded as if it had succeeded. This silently skewed job success-rate and duration numbers for every account on a version prior to this one; there's nothing to backfill, since no exception data was ever sent for those occurrences. The handler now branches on `data[:exception_object]`: a failing job reports `status: "failed"` with `error`, `fingerprint`, and `cause_chain`, and ships regardless of sampling.
8
+ - **Known limitation:** this only covers a job whose exception escapes `perform_now` uncaught. A job using `retry_on`, `discard_on`, or a custom `rescue_from` has the exception handled *inside* `perform_now`, before `perform.active_job`'s payload is ever built — so a retried or discarded job still reports `status: "completed"` for every handled attempt. Only the final attempt of a `retry_on` job that exhausts its retries with no block given re-raises far enough to be visible. Covering retried/discarded attempts would need separate instrumentation on `enqueue_retry.active_job` / `retry_stopped.active_job` / `discard.active_job` — not done here.
9
+ - **A request opening 5 or more transactions/savepoints could trip a false N+1 flag.** Every Rails adapter checked (MySQL/PostgreSQL/SQLite3, 7.1–8.1) logs transaction-control statements under the single name `"TRANSACTION"`, which the old skip list (`data[:name] == "BEGIN" || ...`) never matched — so `BEGIN`/`COMMIT`/etc. weren't ignored, they were tracked as ordinary queries: sanitized, counted toward N+1 detection, and folded into one aggregate per normalized statement. Five or more transactions in one request hit `N_PLUS_ONE_THRESHOLD` and flagged that aggregate `n_plus_one: true`, which the backend trusts directly for the aggregate payload format. Fixed as a consequence of the `TXN_CONTROL_NAMES` match below, not a separate change.
10
+
11
+ ### Added
12
+ - Transaction-control statements (`BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`RELEASE`) are now captured as lightweight breadcrumbs (`transaction_events`, with offsets) instead of being tracked as ordinary queries — so a transaction that rolled back before an error shows up explicitly in the request trace, instead of as an anonymous, sanitized `sql_queries` entry.
13
+
14
+ ## [0.2.31] - 2026-08-30
15
+
3
16
  ### Added
4
17
  - Monitor thread now sends a synchronous heartbeat on startup before the first collection tick. This ensures remote settings — including `monitor_enabled` — are applied from the very first reporting cycle, so Sidekiq workers and other non-web processes that have not yet sent any metrics still receive the correct configuration immediately on boot rather than waiting up to 60 seconds for the first scheduled tick.
5
18
 
@@ -9,7 +9,6 @@ end
9
9
  module DeadBro
10
10
  class JobSubscriber
11
11
  JOB_EVENT_NAME = "perform.active_job"
12
- JOB_EXCEPTION_EVENT_NAME = "exception.active_job"
13
12
 
14
13
  def self.subscribe!(client: Client.new)
15
14
  # Snap GC state before the job runs so stop_request_tracking gets a valid diff
@@ -19,7 +18,28 @@ module DeadBro
19
18
  rescue
20
19
  end
21
20
 
22
- # Track job execution
21
+ # Track job execution — success AND failure both land here. ActiveJob wraps
22
+ # perform with `instrument(:perform) { super }` (see
23
+ # ActiveJob::Instrumentation#instrument), and AS::Notifications.instrument
24
+ # still runs its "finish" listeners (with :exception / :exception_object set
25
+ # in the payload) when the block raises, then re-raises. There is no separate
26
+ # "exception.active_job" event anywhere in Rails/ActiveJob — a prior version
27
+ # of this file subscribed to one, so it never fired; the job wasn't dropped,
28
+ # it fired *this* event and built status: "completed" unconditionally,
29
+ # silently reporting every failing job as a success. Branching on
30
+ # data[:exception_object] here is the only place a job failure can actually
31
+ # be detected.
32
+ #
33
+ # Known gap: this only sees an exception that escapes perform_now uncaught.
34
+ # ActiveJob::Base#perform_now (Execution) rescues internally and hands off to
35
+ # rescue_with_handler — which is exactly what retry_on/discard_on/rescue_from
36
+ # are built on (ActiveJob::Exceptions) — before Instrumentation's `super`
37
+ # even returns. A handled retry or discard returns normally with no
38
+ # exception attached, so perform.active_job still reports status:
39
+ # "completed" for every handled attempt; only a retry_on job's final,
40
+ # unhandled raise (attempts exhausted, no block given) is visible here.
41
+ # Covering handled attempts would need separate instrumentation on
42
+ # enqueue_retry.active_job / retry_stopped.active_job / discard.active_job.
23
43
  ActiveSupport::Notifications.subscribe(JOB_EVENT_NAME) do |name, started, finished, _unique_id, data|
24
44
  begin
25
45
  if DeadBro.configuration.skip_tracking?
@@ -40,12 +60,14 @@ module DeadBro
40
60
  rescue
41
61
  end
42
62
 
63
+ exception = data[:exception_object]
64
+ has_error = !exception.nil?
65
+
43
66
  # Skip out via sampling before we build any payload — jobs can be chatty
44
67
  # enough that even the "cheap" stop/analyze work matters under load.
45
- # Completions have no exception attached; the exception subscriber below
46
- # always sends errors with force: true.
68
+ # Errors always ship regardless of sampling, matching Subscriber's web path.
47
69
  job_type_key = "#{job_class_name}#perform"
48
- unless DeadBro.configuration.should_sample?(job_type_key)
70
+ unless has_error || DeadBro.configuration.should_sample?(job_type_key)
49
71
  drain_job_tracking
50
72
  next
51
73
  end
@@ -71,6 +93,7 @@ module DeadBro
71
93
 
72
94
  # Get SQL queries executed during this job
73
95
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
96
+ transaction_events = DeadBro::SqlSubscriber.last_transaction_events
74
97
  dependency_events = job_dependency_payload
75
98
  db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
76
99
  gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
@@ -117,8 +140,9 @@ module DeadBro
117
140
  db_connection_checkouts: db_connection_stats[:checkouts],
118
141
  gc_pressure: gc_pressure,
119
142
  ar_instantiation_count: ar_instantiation_count,
120
- status: "completed",
143
+ status: has_error ? "failed" : "completed",
121
144
  sql_queries: sql_queries,
145
+ transaction_events: transaction_events,
122
146
  rails_env: DeadBro.env,
123
147
  host: DeadBro.safe_hostname,
124
148
  process_kind: DeadBro.process_kind,
@@ -130,115 +154,19 @@ module DeadBro
130
154
  logs: DeadBro.logger.logs
131
155
  }.merge(dependency_events)
132
156
 
133
- # force: true — the sampling decision above already accounted for any
134
- # per-job-type override; client#post_metric must not re-roll it globally.
135
- client.post_metric(event_name: name, payload: payload, force: true)
136
- end
137
-
138
- # Track job exceptions
139
- ActiveSupport::Notifications.subscribe(JOB_EXCEPTION_EVENT_NAME) do |name, started, finished, _unique_id, data|
140
- begin
141
- if DeadBro.configuration.skip_tracking?
142
- drain_job_tracking
143
- next
144
- end
145
-
146
- job_class_name = data[:job].class.name
147
- if DeadBro.configuration.excluded_job?(job_class_name)
148
- next
149
- end
150
- # If exclusive_jobs is defined and not empty, only track matching jobs
151
- unless DeadBro.configuration.exclusive_job?(job_class_name)
152
- next
153
- end
154
- rescue
155
- end
156
-
157
- duration_ms = ((finished - started) * 1000.0).round(2)
158
- exception = data[:exception_object]
159
- queue_duration_ms = job_queue_duration_ms(data[:job], started)
160
-
161
- # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
162
- unless DeadBro::SqlSubscriber.tracking_active?
163
- DeadBro.logger.clear
164
- Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
165
- DeadBro::SqlSubscriber.start_request_tracking
166
- start_job_dependency_tracking
167
- DeadBro::DbConnectionSubscriber.start_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
168
- DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
169
- if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
170
- DeadBro::MemoryTrackingSubscriber.start_request_tracking
171
- else
172
- DeadBro::LightweightMemoryTracker.start_request_tracking if defined?(DeadBro::LightweightMemoryTracker)
173
- end
174
- end
175
-
176
- # Get SQL queries executed during this job
177
- sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
178
- dependency_events = job_dependency_payload
179
- db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
180
- gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
181
- ar_instantiation_count = defined?(DeadBro::ArObjectTracker) ? DeadBro::ArObjectTracker.stop_request_tracking : nil
182
- watch_events = defined?(DeadBro::WatchTracker) ? DeadBro::WatchTracker.stop_request_tracking : []
183
-
184
- # Stop memory tracking and get collected memory data
185
- if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
186
- detailed_memory = DeadBro::MemoryTrackingSubscriber.stop_request_tracking
187
- memory_performance = DeadBro::MemoryTrackingSubscriber.analyze_memory_performance(detailed_memory)
188
- # Keep memory_events compact and user-friendly (no large raw arrays)
189
- memory_events = {
190
- memory_before: detailed_memory[:memory_before],
191
- memory_after: detailed_memory[:memory_after],
192
- duration_seconds: detailed_memory[:duration_seconds],
193
- allocations_count: (detailed_memory[:allocations] || []).length,
194
- memory_snapshots_count: (detailed_memory[:memory_snapshots] || []).length,
195
- large_objects_count: (detailed_memory[:large_objects] || []).length
196
- }
197
- else
198
- lightweight_memory = DeadBro::LightweightMemoryTracker.stop_request_tracking
199
- # Separate raw readings from derived performance metrics to avoid duplicating data
200
- memory_events = {
201
- memory_before: lightweight_memory[:memory_before],
202
- memory_after: lightweight_memory[:memory_after]
203
- }
204
- memory_performance = {
205
- memory_growth_mb: lightweight_memory[:memory_growth_mb],
206
- gc_count_increase: lightweight_memory[:gc_count_increase],
207
- heap_pages_increase: lightweight_memory[:heap_pages_increase],
208
- duration_seconds: lightweight_memory[:duration_seconds]
209
- }
157
+ if has_error
158
+ payload[:exception_class] = exception.class.name
159
+ payload[:message] = exception.message.to_s[0, 1000]
160
+ payload[:backtrace] = Array(exception.backtrace).first(50)
161
+ payload[:fingerprint] = DeadBro::Subscriber.compute_error_fingerprint(exception)
162
+ payload[:cause_chain] = DeadBro::Subscriber.build_cause_chain(exception)
163
+ payload[:error] = true
210
164
  end
211
165
 
212
- payload = {
213
- job_class: data[:job].class.name,
214
- job_id: data[:job].job_id,
215
- queue_name: data[:job].queue_name,
216
- arguments: safe_arguments(data[:job].arguments),
217
- started_at: started.utc.iso8601(3),
218
- duration_ms: duration_ms,
219
- queue_duration_ms: queue_duration_ms,
220
- db_connection_wait_ms: db_connection_stats[:wait_ms],
221
- db_connection_checkouts: db_connection_stats[:checkouts],
222
- gc_pressure: gc_pressure,
223
- ar_instantiation_count: ar_instantiation_count,
224
- status: "failed",
225
- sql_queries: sql_queries,
226
- exception_class: exception&.class&.name,
227
- message: exception&.message&.to_s&.[](0, 1000),
228
- backtrace: Array(exception&.backtrace).first(50),
229
- rails_env: DeadBro.env,
230
- host: DeadBro.safe_hostname,
231
- process_kind: DeadBro.process_kind,
232
- memory_usage: memory_usage_mb,
233
- gc_stats: gc_stats,
234
- memory_events: memory_events,
235
- memory_performance: memory_performance,
236
- watch_events: watch_events,
237
- logs: DeadBro.logger.logs
238
- }.merge(dependency_events)
239
-
240
- event_name = exception&.class&.name || "ActiveJob::Exception"
241
- client.post_metric(event_name: event_name, payload: payload, force: true)
166
+ # force: true — errors always ship, bypassing sampling by design; for
167
+ # completions the sampling decision above already accounted for any
168
+ # per-job-type override, so client#post_metric must not re-roll it globally.
169
+ client.post_metric(event_name: name, payload: payload, force: true)
242
170
  end
243
171
  rescue
244
172
  # Never raise from instrumentation install
@@ -248,6 +176,7 @@ module DeadBro
248
176
  # build a payload (excluded job / sampled out). Matches Subscriber.drain_request_tracking.
249
177
  def self.drain_job_tracking
250
178
  # wait_for_explains: false — result is discarded, don't block on pending plans.
179
+ # stop_request_tracking also pops the transaction-events stack (see its comment).
251
180
  DeadBro::SqlSubscriber.stop_request_tracking(wait_for_explains: false) if defined?(DeadBro::SqlSubscriber)
252
181
  Thread.current[:dead_bro_http_events] = nil
253
182
  DeadBro::CacheSubscriber.stop_request_tracking if defined?(DeadBro::CacheSubscriber)
@@ -16,7 +16,11 @@ module DeadBro
16
16
  THREAD_LOCAL_EXPLAIN_PENDING_KEY = :dead_bro_explain_pending
17
17
  THREAD_LOCAL_CALL_COUNTS_KEY = :dead_bro_sql_call_counts
18
18
  THREAD_LOCAL_AGGREGATES_KEY = :dead_bro_sql_aggregates
19
+ THREAD_LOCAL_TXN_EVENTS_KEY = :dead_bro_sql_txn_events
19
20
  MAX_TRACKED_QUERIES = 1000
21
+ # Transaction control breadcrumbs (BEGIN/COMMIT/ROLLBACK/...) are cheap and rare
22
+ # compared to queries, but a pathological retry loop could still spam them.
23
+ MAX_TRACKED_TXN_EVENTS = 200
20
24
 
21
25
  # Number of identical queries within one request that triggers N+1 detection.
22
26
  N_PLUS_ONE_THRESHOLD = 5
@@ -37,6 +41,43 @@ module DeadBro
37
41
  SANITIZE_SKIP_SENSITIVE_WHEN_NO_KEYWORDS = /password|token|secret|key|ssn|credit_card/i
38
42
  SANITIZE_SKIP_WHERE_WHEN_NO_KEYWORD = /WHERE/i
39
43
 
44
+ # Rails' own adapters (MySQL, PostgreSQL, SQLite3, at least 7.1 through 8.1 —
45
+ # the versions checked directly in this repo) log every transaction-control
46
+ # statement under the single generic name "TRANSACTION"
47
+ # (`internal_execute("BEGIN", "TRANSACTION", ...)`, `internal_execute("COMMIT",
48
+ # "TRANSACTION", ...)`, etc. — see AbstractMysqlAdapter/PostgreSQL::DatabaseStatements/
49
+ # SQLite3::DatabaseStatements). "BEGIN"/"COMMIT"/"ROLLBACK"/"SAVEPOINT"/"RELEASE"
50
+ # as literal `name` values are kept here only as a defensive fallback for
51
+ # adapters/older Rails versions that might still emit them directly — the actual
52
+ # operation for a "TRANSACTION"-named event is derived from the SQL text itself
53
+ # (see transaction_operation_for below), since the name alone can't distinguish
54
+ # BEGIN from COMMIT from a SAVEPOINT.
55
+ TXN_CONTROL_NAMES = %w[BEGIN COMMIT ROLLBACK SAVEPOINT RELEASE TRANSACTION].freeze
56
+
57
+ TXN_OPERATION_FROM_SQL = [
58
+ [/\A\s*ROLLBACK\s+TO\s+SAVEPOINT/i, "ROLLBACK TO SAVEPOINT"],
59
+ [/\A\s*RELEASE\s+SAVEPOINT/i, "RELEASE SAVEPOINT"],
60
+ [/\A\s*SAVEPOINT/i, "SAVEPOINT"],
61
+ [/\A\s*BEGIN/i, "BEGIN"],
62
+ [/\A\s*COMMIT/i, "COMMIT"],
63
+ [/\A\s*ROLLBACK/i, "ROLLBACK"]
64
+ ].freeze
65
+
66
+ # data[:name] is only ever "TRANSACTION" in current Rails — this derives the
67
+ # actual verb (BEGIN/COMMIT/ROLLBACK/SAVEPOINT/RELEASE SAVEPOINT/ROLLBACK TO
68
+ # SAVEPOINT) from the SQL text so breadcrumbs are still meaningful. A literal
69
+ # non-"TRANSACTION" name (older Rails/adapter) is passed through unchanged.
70
+ def self.transaction_operation_for(name, sql)
71
+ return name unless name == "TRANSACTION"
72
+ sql_str = sql.to_s
73
+ TXN_OPERATION_FROM_SQL.each do |re, op|
74
+ return op if sql_str.match?(re)
75
+ end
76
+ name
77
+ rescue
78
+ name
79
+ end
80
+
40
81
  # True when there is at least one active tracking context (e.g. for nested jobs).
41
82
  def self.tracking_active?
42
83
  stack = Thread.current[THREAD_LOCAL_KEY]
@@ -50,6 +91,13 @@ module DeadBro
50
91
  stack.last
51
92
  end
52
93
 
94
+ # Current transaction-events array (top of stack); nil if no active tracking.
95
+ def self.current_txn_events_array
96
+ stack = Thread.current[THREAD_LOCAL_TXN_EVENTS_KEY]
97
+ return nil unless stack.is_a?(Array) && stack.any?
98
+ stack.last
99
+ end
100
+
53
101
  # Sum of SQL counts and durations recorded so far in the active tracking
54
102
  # context. Used by WatchTracker to attribute SQL to a DeadBro.watch block.
55
103
  def self.current_sql_metrics
@@ -96,6 +144,27 @@ module DeadBro
96
144
  sql.to_s.downcase
97
145
  end
98
146
 
147
+ # Records a lightweight breadcrumb for a transaction-control statement
148
+ # (BEGIN/COMMIT/ROLLBACK/SAVEPOINT/RELEASE) — enough to show, e.g., that the
149
+ # queries preceding an error ran inside a transaction that was rolled back,
150
+ # without the cost of treating it like a tracked query (no backtrace/EXPLAIN).
151
+ def self.record_transaction_event(name, started, finished)
152
+ current = current_txn_events_array
153
+ return unless current
154
+ return if current.length >= MAX_TRACKED_TXN_EVENTS
155
+
156
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
157
+ start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
158
+
159
+ current << {
160
+ event: name,
161
+ duration_ms: ((finished - started) * 1000.0).round(2),
162
+ start_offset_ms: start_offset_ms
163
+ }
164
+ rescue
165
+ nil
166
+ end
167
+
99
168
  def self.subscribe!
100
169
  # Subscribe with a start/finish listener to measure allocations per query
101
170
  if ActiveSupport::Notifications.notifier.respond_to?(:subscribe)
@@ -106,7 +175,13 @@ module DeadBro
106
175
  end
107
176
 
108
177
  ActiveSupport::Notifications.subscribe(SQL_EVENT_NAME) do |name, started, finished, _unique_id, data|
109
- next if data[:name] == "SCHEMA" || data[:name] == "CACHE" || data[:name] == "BEGIN" || data[:name] == "COMMIT" || data[:name] == "ROLLBACK" || data[:name] == "SAVEPOINT" || data[:name] == "RELEASE"
178
+ next if data[:name] == "SCHEMA" || data[:name] == "CACHE"
179
+
180
+ if TXN_CONTROL_NAMES.include?(data[:name])
181
+ record_transaction_event(transaction_operation_for(data[:name], data[:sql]), started, finished)
182
+ next
183
+ end
184
+
110
185
  # Only track queries that are part of the current request (top of stack for nested jobs)
111
186
  current = current_queries_array
112
187
  next unless current
@@ -208,6 +283,7 @@ module DeadBro
208
283
  Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] = []
209
284
  (Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY] ||= []) << {}
210
285
  (Thread.current[THREAD_LOCAL_AGGREGATES_KEY] ||= []) << {}
286
+ (Thread.current[THREAD_LOCAL_TXN_EVENTS_KEY] ||= []) << []
211
287
  end
212
288
 
213
289
  # wait_for_explains: false on drain paths (excluded / sampled-out requests)
@@ -223,6 +299,21 @@ module DeadBro
223
299
  cc_stack = Thread.current[THREAD_LOCAL_CALL_COUNTS_KEY]
224
300
  cc_stack.pop if cc_stack.is_a?(Array) && cc_stack.any?
225
301
 
302
+ # Transaction-control breadcrumbs are popped here, as part of this method's
303
+ # existing lifecycle, rather than through a separate stop_transaction_tracking
304
+ # call. start_request_tracking has many callers across this gem (and its own
305
+ # specs) that only know about query tracking; requiring every one of them to
306
+ # also remember a second, separately-paired stop call is exactly how a frame
307
+ # gets pushed and never popped — leaking across every later request/job on
308
+ # the same thread. Piggybacking on stop_request_tracking, which is already
309
+ # reliably paired with every start_request_tracking call, means there is
310
+ # nothing new for any caller to remember. A caller that wants the events
311
+ # reads last_transaction_events immediately afterward.
312
+ txn_stack = Thread.current[THREAD_LOCAL_TXN_EVENTS_KEY]
313
+ Thread.current[:dead_bro_last_transaction_events] =
314
+ (txn_stack.is_a?(Array) && txn_stack.any?) ? txn_stack.pop : []
315
+ Thread.current[THREAD_LOCAL_TXN_EVENTS_KEY] = nil if txn_stack.nil? || txn_stack.empty?
316
+
226
317
  # Fold any completed EXPLAIN plans from raw queries into their aggregate entry
227
318
  raw_queries.each do |q|
228
319
  next unless q[:explain_plan]
@@ -244,6 +335,17 @@ module DeadBro
244
335
  aggregates_h.values.sort_by { |a| -a[:total_duration_ms] }
245
336
  end
246
337
 
338
+ # The transaction-control breadcrumbs captured during the tracking window that
339
+ # this thread's most recent stop_request_tracking call just ended. Consumes
340
+ # (clears) the thread-local on read, not just on write: otherwise it would sit
341
+ # pinned in Thread.current between requests (a Puma/Sidekiq thread is reused),
342
+ # and — worse — any caller that reads it without an immediately-preceding
343
+ # stop_request_tracking would silently get the *previous* request's
344
+ # breadcrumbs instead of an empty result.
345
+ def self.last_transaction_events
346
+ Thread.current[:dead_bro_last_transaction_events].tap { Thread.current[:dead_bro_last_transaction_events] = nil } || []
347
+ end
348
+
247
349
  # Upper bound on pending EXPLAIN threads per request. Each thread checks
248
350
  # out an AR pool connection, so this must stay well below common pool
249
351
  # sizes (Rails default is 5) to avoid starving the app under a storm.
@@ -94,6 +94,7 @@ module DeadBro
94
94
  if defined?(DeadBro::SqlSubscriber)
95
95
  Thread.current[:dead_bro_sql_queries]
96
96
  Thread.current[:dead_bro_sql_queries] = nil
97
+ Thread.current[DeadBro::SqlSubscriber::THREAD_LOCAL_TXN_EVENTS_KEY] = nil
97
98
  end
98
99
 
99
100
  if defined?(DeadBro::CacheSubscriber)
@@ -58,6 +58,7 @@ module DeadBro
58
58
 
59
59
  # Stop SQL tracking and get collected queries (this was started by the request)
60
60
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
61
+ transaction_events = DeadBro::SqlSubscriber.last_transaction_events
61
62
 
62
63
  # Stop cache, redis, and elasticsearch tracking
63
64
  cache_events = defined?(DeadBro::CacheSubscriber) ? DeadBro::CacheSubscriber.stop_request_tracking : []
@@ -147,6 +148,42 @@ module DeadBro
147
148
  })
148
149
  end
149
150
 
151
+ # Everything captured during the request regardless of outcome — shared between
152
+ # the error and success payloads below so an errored request (e.g. a lock wait
153
+ # timeout raised mid-transaction) still ships the SQL/cache/memory/etc data that
154
+ # led up to it, not just the exception. This used to be built only for the
155
+ # success path, so every errored request shipped with sql_count: 0 and no trace
156
+ # even though the gem had already captured it.
157
+ detail_fields = {
158
+ view_runtime_ms: data[:view_runtime],
159
+ db_runtime_ms: data[:db_runtime],
160
+ memory_usage: memory_usage_mb,
161
+ gc_stats: gc_stats,
162
+ sql_count: sql_count(data),
163
+ sql_queries: sql_queries,
164
+ transaction_events: transaction_events,
165
+ http_outgoing: Thread.current[:dead_bro_http_events] || [],
166
+ cache_events: cache_events,
167
+ redis_events: redis_events,
168
+ elasticsearch_events: elasticsearch_events,
169
+ cache_hits: cache_hits(data),
170
+ cache_misses: cache_misses(data),
171
+ view_events: view_events,
172
+ view_performance: view_performance,
173
+ memory_events: memory_events,
174
+ memory_performance: memory_performance,
175
+ allocation_phases: allocation_phases,
176
+ rack_duration_ms: rack_duration_ms,
177
+ queue_duration_ms: Thread.current[:dead_bro_queue_duration_ms],
178
+ db_connection_wait_ms: db_connection_stats[:wait_ms],
179
+ db_connection_checkouts: db_connection_stats[:checkouts],
180
+ gc_pressure: gc_pressure,
181
+ ar_instantiation_count: ar_instantiation_count,
182
+ cpu_time_ms: cpu_time_ms,
183
+ watch_events: watch_events,
184
+ logs: DeadBro.logger.logs
185
+ }
186
+
150
187
  # Report exceptions attached to this action (e.g. controller/view errors)
151
188
  if data[:exception] || data[:exception_object]
152
189
  begin
@@ -154,7 +191,7 @@ module DeadBro
154
191
  exception_obj = data[:exception_object]
155
192
  backtrace = Array(exception_obj&.backtrace).first(50)
156
193
 
157
- error_payload = {
194
+ error_payload = detail_fields.merge(
158
195
  controller: data[:controller],
159
196
  action: data[:action],
160
197
  format: data[:format],
@@ -174,9 +211,8 @@ module DeadBro
174
211
  backtrace: backtrace,
175
212
  fingerprint: compute_error_fingerprint(exception_obj),
176
213
  cause_chain: build_cause_chain(exception_obj),
177
- error: true,
178
- logs: DeadBro.logger.logs
179
- }
214
+ error: true
215
+ )
180
216
 
181
217
  event_name = (exception_class || exception_obj&.class&.name || "exception").to_s
182
218
  client.post_metric(event_name: event_name, payload: error_payload, force: true)
@@ -186,7 +222,7 @@ module DeadBro
186
222
  end
187
223
  end
188
224
 
189
- payload = {
225
+ payload = detail_fields.merge(
190
226
  controller: data[:controller],
191
227
  action: data[:action],
192
228
  format: data[:format],
@@ -195,40 +231,14 @@ module DeadBro
195
231
  status: data[:status],
196
232
  started_at: started.utc.iso8601(3),
197
233
  duration_ms: duration_ms,
198
- view_runtime_ms: data[:view_runtime],
199
- db_runtime_ms: data[:db_runtime],
200
234
  host: DeadBro.safe_hostname,
201
235
  request_host: safe_request_host(data),
202
236
  rails_env: DeadBro.env,
203
237
  process_kind: DeadBro.process_kind,
204
238
  params: safe_params(data),
205
239
  user_agent: safe_user_agent(data),
206
- user_id: extract_user_id(data),
207
- memory_usage: memory_usage_mb,
208
- gc_stats: gc_stats,
209
- sql_count: sql_count(data),
210
- sql_queries: sql_queries,
211
- http_outgoing: Thread.current[:dead_bro_http_events] || [],
212
- cache_events: cache_events,
213
- redis_events: redis_events,
214
- elasticsearch_events: elasticsearch_events,
215
- cache_hits: cache_hits(data),
216
- cache_misses: cache_misses(data),
217
- view_events: view_events,
218
- view_performance: view_performance,
219
- memory_events: memory_events,
220
- memory_performance: memory_performance,
221
- allocation_phases: allocation_phases,
222
- rack_duration_ms: rack_duration_ms,
223
- queue_duration_ms: Thread.current[:dead_bro_queue_duration_ms],
224
- db_connection_wait_ms: db_connection_stats[:wait_ms],
225
- db_connection_checkouts: db_connection_stats[:checkouts],
226
- gc_pressure: gc_pressure,
227
- ar_instantiation_count: ar_instantiation_count,
228
- cpu_time_ms: cpu_time_ms,
229
- watch_events: watch_events,
230
- logs: DeadBro.logger.logs
231
- }
240
+ user_id: extract_user_id(data)
241
+ )
232
242
  # force: true — the sampling decision (global or per-request-type) was
233
243
  # already made above; client#post_metric must not re-roll it with the
234
244
  # global-only rate, which would silently override a per-type sample rate.
@@ -242,6 +252,7 @@ module DeadBro
242
252
  def self.drain_request_tracking
243
253
  # wait_for_explains: false — the result is discarded, so don't block this
244
254
  # thread waiting on pending EXPLAIN plans.
255
+ # stop_request_tracking also pops the transaction-events stack (see its comment).
245
256
  DeadBro::SqlSubscriber.stop_request_tracking(wait_for_explains: false) if defined?(DeadBro::SqlSubscriber)
246
257
  DeadBro::CacheSubscriber.stop_request_tracking if defined?(DeadBro::CacheSubscriber)
247
258
  DeadBro::RedisSubscriber.stop_request_tracking if defined?(DeadBro::RedisSubscriber)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.31"
4
+ VERSION = "0.2.32"
5
5
  end
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.31
4
+ version: 0.2.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa