dead_bro 0.2.30 → 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: cccae4ac857f38e889e12c292943b25fad11f7e372d7f13e4eb838d13c6330ee
4
- data.tar.gz: 4d459c4d049219747ed5c1b6f10e1417648282eca2e3e81c7eeef132e8a347bd
3
+ metadata.gz: 22b021f41cc62097a8fc1f1aedf489e8ee1dca10b2b2abc6edbce6809763b39a
4
+ data.tar.gz: 30c442462399195cec1edb2f9505aa51896230da92d18ad97f5eca35d2749221
5
5
  SHA512:
6
- metadata.gz: d12c33c0902c95f318cd716d7c88c78925543b235729ee90525d0453b16ed46521e2b00cc1daa5131b673abb7ee7e74e0968f299d56da055fd36452c2e314b90
7
- data.tar.gz: e04ca78806e4d9a07757c0fedc177c2e4b77b295437d415fab94546d84add717ceab84791e34991f4955197ab1d8036ad498d679b089e795e5356beb0662d286
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
 
data/README.md CHANGED
@@ -65,115 +65,9 @@ Use the DeadBro UI to turn features on or off, set sample rates, define controll
65
65
 
66
66
  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.
67
67
 
68
- ## Optional: configuration in Ruby
68
+ ## Request Sampling, Exclusions & Whitelisting
69
69
 
70
- The sections below describe the same knobs you can manage in the DeadBro app. Use them only when you want values in source control, per-environment initializer logic, or other overrides outside the UI.
71
-
72
- ## Request Sampling
73
-
74
- DeadBro supports configurable request sampling to reduce the volume of metrics sent to your APM endpoint, which is useful for high-traffic applications. Prefer setting this in the DeadBro app; use Ruby if you need a local override.
75
-
76
- ### Configuration
77
-
78
- Set the sample rate as a percentage (1-100):
79
-
80
- ```ruby
81
- # Track 50% of requests
82
- DeadBro.configure do |config|
83
- config.sample_rate = 50
84
- end
85
-
86
- # Track 10% of requests (useful for high-traffic apps)
87
- DeadBro.configure do |config|
88
- config.sample_rate = 10
89
- end
90
-
91
- # Track all requests (default)
92
- DeadBro.configure do |config|
93
- config.sample_rate = 100
94
- end
95
- ```
96
-
97
- ### How It Works
98
-
99
- - **Random Sampling**: Each request has a random chance of being tracked based on the sample rate
100
- - **Consistent Per-Request**: The sampling decision is made once per request and applies to all metrics for that request
101
- - **Debug Logging**: Skipped requests do not count towards the montly limit
102
- - **Error Tracking**: Errors are still tracked regardless of sampling
103
-
104
- ### Use Cases
105
-
106
- - **High-Traffic Applications**: Reduce APM data volume and costs
107
- - **Development/Staging**: Sample fewer requests to reduce noise
108
- - **Performance Testing**: Track a subset of requests during load testing
109
- - **Cost Optimization**: Balance monitoring coverage with data costs
110
-
111
-
112
- ## Excluding Controllers and Jobs
113
-
114
- You can exclude specific controllers and jobs from APM tracking (dashboard first; Ruby optional).
115
-
116
- ### Configuration
117
-
118
-
119
- ```ruby
120
- DeadBro.configure do |config|
121
- # Controller-only or controller#action patterns in one list (wildcards supported)
122
- config.excluded_controllers = [
123
- "HealthChecksController",
124
- "Admin::*",
125
- "UsersController#show",
126
- "Admin::ReportsController#index",
127
- "Admin::*#*"
128
- ]
129
-
130
- config.excluded_jobs = [
131
- "ActiveStorage::AnalyzeJob",
132
- "Admin::*"
133
- ]
134
- end
135
- ```
136
-
137
- Notes:
138
- - Wildcards `*` are supported (e.g., `Admin::*`, `Admin::*#*`).
139
- - Matching uses full names like `UsersController`, `Admin::ReportsController#index`, `MyJob`.
140
-
141
- ## Exclusive Tracking (Whitelist Mode)
142
-
143
- You can configure DeadBro to **only** track specific controllers, actions, or jobs. Prefer the dashboard; use Ruby for overrides.
144
-
145
- ### Configuration
146
-
147
- ```ruby
148
- DeadBro.configure do |config|
149
- # Only track these controllers/actions (patterns can include #action or wildcards)
150
- config.exclusive_controllers = [
151
- "UsersController#show",
152
- "UsersController#index",
153
- "Admin::ReportsController#*",
154
- "Api::*#*"
155
- ]
156
-
157
- config.exclusive_jobs = [
158
- "PaymentProcessingJob",
159
- "EmailDeliveryJob",
160
- "Admin::*"
161
- ]
162
- end
163
- ```
164
-
165
- ### How It Works
166
-
167
- - **If `exclusive_controllers` or `exclusive_jobs` is empty/not defined**: All controllers/actions/jobs are tracked (default behavior)
168
- - **If `exclusive_controllers` or `exclusive_jobs` is defined with values**: Only matching controllers/actions/jobs are tracked
169
- - **Exclusion takes precedence**: If something matches both `excluded_*` and `exclusive_*`, it is excluded (exclusion is checked first)
170
-
171
- ### Use Cases
172
-
173
- - **Focus on Critical Paths**: Monitor only your most important endpoints
174
- - **Cost Optimization**: Track only specific high-value operations
175
- - **Debugging**: Temporarily focus on specific controllers/jobs during investigation
176
- - **Compliance**: Track only operations that require monitoring for compliance reasons
70
+ To control data volume, DeadBro supports request sampling (track a percentage of requests), excluding specific controllers/jobs from tracking, and whitelisting (tracking *only* specific controllers/jobs). All of this is configured in the DeadBro dashboard — sample rate, `excluded_controllers`/`excluded_jobs`, and `exclusive_controllers`/`exclusive_jobs` patterns (wildcards like `Admin::*` and `Admin::*#*` supported). Exclusion always takes precedence over whitelisting when a pattern matches both.
177
71
 
178
72
  ## SQL Query Tracking
179
73
 
@@ -198,43 +92,19 @@ DeadBro can automatically capture the query plan (`EXPLAIN`) of slow SELECT quer
198
92
  - **Value scrubbing**: quoted string literals echoed in plan text (e.g. `Filter: email = 'a@b.com'`) are replaced with `?` before the plan leaves your app.
199
93
  - **Bounded overhead**: plans are captured on background threads (max 3 concurrent per request); at request end DeadBro waits at most 0.5s for stragglers, then drops them.
200
94
 
201
- ### How It Works
202
-
203
- - **Automatic Detection**: when a SELECT exceeds `slow_query_threshold_ms`, DeadBro captures its plan in the background
204
- - **Database Support**: PostgreSQL, MySQL, SQLite (`EXPLAIN QUERY PLAN`), and any adapter with a standard `EXPLAIN`
205
-
206
95
  ### Configuration
207
96
 
208
- - **`explain_analyze_enabled`** (default: `false`) - local opt-in for automatic EXPLAIN plan capture. Must be set in Ruby; the DeadBro UI toggle can only turn capture off, not on.
209
- - **`slow_query_threshold_ms`** (default: `500`) - queries taking longer than this threshold will have their execution plan captured
97
+ - **`explain_analyze_enabled`** (default: `false`) — this is the one setting that must be turned on in Ruby; the dashboard toggle can only turn capture *off*, never on, as an extra safety rail:
210
98
 
211
- ### Example Configuration
212
-
213
- ```ruby
214
- DeadBro.configure do |config|
215
- # Capture EXPLAIN plans for SELECTs slower than 500ms
216
- config.explain_analyze_enabled = true
217
- config.slow_query_threshold_ms = 500
218
-
219
- # Or use a higher threshold for production
220
- # config.slow_query_threshold_ms = 1000 # Only explain queries > 1 second
221
- end
222
- ```
99
+ ```ruby
100
+ DeadBro.configure do |config|
101
+ config.explain_analyze_enabled = true
102
+ end
103
+ ```
223
104
 
224
- ### What You Get
105
+ - Everything else — the `slow_query_threshold_ms` cutoff and enabling/disabling the feature — is managed from the dashboard.
225
106
 
226
- When a slow query is detected, the `explain_plan` field in the SQL query data will contain:
227
- - **PostgreSQL / MySQL**: `EXPLAIN` output — the planner's chosen strategy, estimated costs and row counts
228
- - **SQLite**: `EXPLAIN QUERY PLAN` output
229
- - **Other databases**: standard `EXPLAIN` output
230
-
231
- This execution plan helps you:
232
- - Identify missing indexes
233
- - Understand query execution order
234
- - Spot full table scans
235
- - Optimize JOIN operations
236
-
237
- Because the statement is not executed, plans show the planner's *estimates* rather than actual runtimes — usually exactly what you need to spot a missing index or an unexpected sequential scan.
107
+ When a slow query is detected, the `explain_plan` field in the SQL query data contains the database's `EXPLAIN` (or `EXPLAIN QUERY PLAN` on SQLite) output — the planner's chosen strategy, estimated costs and row counts — useful for spotting missing indexes or unexpected sequential scans. Because the statement is never executed, plans show estimates rather than actual runtimes.
238
108
 
239
109
  ## View Rendering Tracking
240
110
 
@@ -259,20 +129,7 @@ By default, DeadBro uses **lightweight memory tracking** that has minimal perfor
259
129
  - **GC Efficiency Analysis**: Monitor garbage collection effectiveness
260
130
  - **Zero Allocation Tracking**: No object allocation tracking by default (can be enabled)
261
131
 
262
- ### Configuration Options
263
-
264
- Usually managed in the dashboard; Ruby example:
265
-
266
- ```ruby
267
- DeadBro.configure do |config|
268
- config.memory_tracking_enabled = true # Enable lightweight memory tracking (default: true)
269
- config.allocation_tracking_enabled = false # Enable detailed allocation tracking (default: false)
270
- config.allocation_sample_rate = 100 # % of requests that pay for allocation tracking when enabled (1-100, default: 100)
271
-
272
- # Sampling configuration
273
- config.sample_rate = 100 # Percentage of requests to track (1-100, default: 100)
274
- end
275
- ```
132
+ `memory_tracking_enabled`, `allocation_tracking_enabled`, and `allocation_sample_rate` are all managed from the dashboard.
276
133
 
277
134
  **Performance Impact:**
278
135
  - **Lightweight mode** (`memory_tracking_enabled`, ~0.1ms overhead per request): RSS before/after, GC pressure, the retained-vs-transient signals (`heap_live_slots_growth`, `malloc_increase_bytes`), and per-phase allocation attribution (`allocation_phases` — which of `sql`/`view`/`elasticsearch` allocated the request's objects).
@@ -310,25 +167,7 @@ Everything is **best effort** and designed to be **safe and low overhead**:
310
167
 
311
168
  ### Configuration
312
169
 
313
- Enable or disable collectors in the DeadBro app, or use `DeadBro.configure` for code-based overrides:
314
-
315
- ```ruby
316
- DeadBro.configure do |config|
317
- # Enable the periodic job queue monitor (disabled by default)
318
- config.job_queue_monitoring_enabled = true
319
-
320
- # Enable best-effort collectors (all default to false)
321
- config.enable_db_stats = true # ActiveRecord pool + ping latency
322
- config.enable_process_stats = true # pid, hostname, RSS, GC, threads, fds
323
- config.enable_system_stats = true # CPU%, memory, disk, network
324
-
325
- # Filesystem paths to report disk usage for (default: ["/"])
326
- config.disk_paths = ["/", "/var"]
327
-
328
- # Network interfaces to ignore when computing rx/tx stats
329
- config.interfaces_ignore = %w[lo docker0]
330
- end
331
- ```
170
+ Enable or disable the job queue monitor and the individual collectors (`enable_db_stats`, `enable_process_stats`, `enable_system_stats`), plus `disk_paths` and `interfaces_ignore`, from the DeadBro dashboard.
332
171
 
333
172
  ### Example Payload Shape
334
173
 
@@ -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],
@@ -164,6 +201,7 @@ module DeadBro
164
201
  duration_ms: duration_ms,
165
202
  rails_env: DeadBro.env,
166
203
  host: DeadBro.safe_hostname,
204
+ request_host: safe_request_host(data),
167
205
  process_kind: DeadBro.process_kind,
168
206
  params: safe_params(data),
169
207
  user_agent: safe_user_agent(data),
@@ -173,9 +211,8 @@ module DeadBro
173
211
  backtrace: backtrace,
174
212
  fingerprint: compute_error_fingerprint(exception_obj),
175
213
  cause_chain: build_cause_chain(exception_obj),
176
- error: true,
177
- logs: DeadBro.logger.logs
178
- }
214
+ error: true
215
+ )
179
216
 
180
217
  event_name = (exception_class || exception_obj&.class&.name || "exception").to_s
181
218
  client.post_metric(event_name: event_name, payload: error_payload, force: true)
@@ -185,7 +222,7 @@ module DeadBro
185
222
  end
186
223
  end
187
224
 
188
- payload = {
225
+ payload = detail_fields.merge(
189
226
  controller: data[:controller],
190
227
  action: data[:action],
191
228
  format: data[:format],
@@ -194,39 +231,14 @@ module DeadBro
194
231
  status: data[:status],
195
232
  started_at: started.utc.iso8601(3),
196
233
  duration_ms: duration_ms,
197
- view_runtime_ms: data[:view_runtime],
198
- db_runtime_ms: data[:db_runtime],
199
234
  host: DeadBro.safe_hostname,
235
+ request_host: safe_request_host(data),
200
236
  rails_env: DeadBro.env,
201
237
  process_kind: DeadBro.process_kind,
202
238
  params: safe_params(data),
203
239
  user_agent: safe_user_agent(data),
204
- user_id: extract_user_id(data),
205
- memory_usage: memory_usage_mb,
206
- gc_stats: gc_stats,
207
- sql_count: sql_count(data),
208
- sql_queries: sql_queries,
209
- http_outgoing: Thread.current[:dead_bro_http_events] || [],
210
- cache_events: cache_events,
211
- redis_events: redis_events,
212
- elasticsearch_events: elasticsearch_events,
213
- cache_hits: cache_hits(data),
214
- cache_misses: cache_misses(data),
215
- view_events: view_events,
216
- view_performance: view_performance,
217
- memory_events: memory_events,
218
- memory_performance: memory_performance,
219
- allocation_phases: allocation_phases,
220
- rack_duration_ms: rack_duration_ms,
221
- queue_duration_ms: Thread.current[:dead_bro_queue_duration_ms],
222
- db_connection_wait_ms: db_connection_stats[:wait_ms],
223
- db_connection_checkouts: db_connection_stats[:checkouts],
224
- gc_pressure: gc_pressure,
225
- ar_instantiation_count: ar_instantiation_count,
226
- cpu_time_ms: cpu_time_ms,
227
- watch_events: watch_events,
228
- logs: DeadBro.logger.logs
229
- }
240
+ user_id: extract_user_id(data)
241
+ )
230
242
  # force: true — the sampling decision (global or per-request-type) was
231
243
  # already made above; client#post_metric must not re-roll it with the
232
244
  # global-only rate, which would silently override a per-type sample rate.
@@ -240,6 +252,7 @@ module DeadBro
240
252
  def self.drain_request_tracking
241
253
  # wait_for_explains: false — the result is discarded, so don't block this
242
254
  # thread waiting on pending EXPLAIN plans.
255
+ # stop_request_tracking also pops the transaction-events stack (see its comment).
243
256
  DeadBro::SqlSubscriber.stop_request_tracking(wait_for_explains: false) if defined?(DeadBro::SqlSubscriber)
244
257
  DeadBro::CacheSubscriber.stop_request_tracking if defined?(DeadBro::CacheSubscriber)
245
258
  DeadBro::RedisSubscriber.stop_request_tracking if defined?(DeadBro::RedisSubscriber)
@@ -330,6 +343,37 @@ module DeadBro
330
343
  end
331
344
  end
332
345
 
346
+ # The domain the request was served on (request.host) — distinct from `host`,
347
+ # which is the machine's OS hostname. Lets one app that answers on several
348
+ # domains be sliced by domain in the dashboard. Port and userinfo are dropped;
349
+ # value is lowercased so "Example.com" and "example.com" aren't two rows.
350
+ def self.safe_request_host(data)
351
+ raw =
352
+ if data[:request] && data[:request].respond_to?(:host)
353
+ data[:request].host
354
+ elsif data[:headers]
355
+ headers = data[:headers]
356
+ if headers.respond_to?(:[])
357
+ headers["HTTP_HOST"] || headers["Host"] || headers["host"]
358
+ elsif headers.respond_to?(:env)
359
+ headers.env && headers.env["HTTP_HOST"]
360
+ end
361
+ elsif data[:env].is_a?(Hash)
362
+ data[:env]["HTTP_HOST"]
363
+ end
364
+
365
+ host = sanitize_string(raw)
366
+ return "" if host.empty?
367
+
368
+ # Strip any userinfo@ and a trailing :port so only the hostname remains.
369
+ # Only a numeric trailing port is removed, so IPv6 literals ([::1]) survive.
370
+ host = host.split("@").last.to_s
371
+ host = host.sub(/:\d+\z/, "")
372
+ host.downcase[0, 255]
373
+ rescue
374
+ ""
375
+ end
376
+
333
377
  def self.safe_user_agent(data)
334
378
  begin
335
379
  # Prefer request object if available
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.30"
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.30
4
+ version: 0.2.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa