dead_bro 0.2.13 → 0.2.15

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: 2c308968cd84cfa8df47974178b741ab2847809754e86dc49cbe716d07a7a0bb
4
- data.tar.gz: 6cdfb10f1ac9b2ecd9994bf6f35d8468022dc024e16e9ab31c1664fa91db7041
3
+ metadata.gz: 2251b88befeee4d2b7668bc504cc99d94cd154dd4539258545c1084e7a388709
4
+ data.tar.gz: 10141477e9697b91d95290ccfc695abb304e8dfbdfb526f95646269be03be0ef
5
5
  SHA512:
6
- metadata.gz: 2a8f31655b9512739fa991ee94675c1f948c5ae2229dbb9de62a4e3c6ea0b5a8883ea880f986d911044e200d0c8ae6e696f6d8a58e5c338da6c8c3756c08e543
7
- data.tar.gz: ddf1ed72e8310eb1f42db17f5124b704c5214447706ed68c1f9bae9f47f669fbf56006733f430a064af0fc8965be161dbb85bcaca2aee6b6c14a68b4865216f9
6
+ metadata.gz: 39ec9243a8792016f7cf9bf5f8d81856c121365b5cebef91ef7d318f642e7480a66b32a92b63ec944ee3aebb9e92ffdfc27c9e284e41da7daa081661e893b38a
7
+ data.tar.gz: 519f6df31fb8dc567a6974052e58787098021078514eaa2877a2f582651c5a5fec316f4da58a625a4b6d00eed7fae614fe5dd79f1da17ae2d4d7227ce8f7061b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.14] - 2026-05-23
4
+
5
+ ### Added
6
+ - `DbConnectionSubscriber`: measures the time threads spend waiting to acquire a connection from the ActiveRecord connection pool and counts checkouts per request. Uses `prepend` on `ActiveRecord::ConnectionAdapters::ConnectionPool#checkout` so the overhead is minimal and the instrumentation is invisible to application code. `db_connection_wait_ms` and `db_connection_checkouts` are included in both request and job payloads.
7
+ - Queue duration tracking for web requests via `X-Request-Start` / `X-Queue-Start` headers. Parses both the Heroku microsecond format (`t=<µs>`) and the nginx seconds format (`t=<s.ms>`), and applies a 60 s clock-skew cap so a misconfigured proxy timestamp cannot produce a nonsensical value.
8
+ - Queue duration tracking for background jobs: time from `job.enqueued_at` to when `perform` begins is reported as `queue_duration_ms` in every job payload.
9
+ - `RedisSubscriber`: prepend-based instrumentation on `Redis::Client` that records every individual command, pipeline, and `MULTI`/`EXEC` block with command name, key, duration in ms, and database index. Falls back to an `ActiveSupport::Notifications` subscription for `redis.*` events emitted by other libraries. Capped at 1 000 events per request to bound memory growth.
10
+ - DB connection tracking wired into background jobs: `DbConnectionSubscriber` is started and stopped around job execution in both the normal completion path and the exception handler fallback path, with cleanup in `drain_job_tracking`.
11
+
3
12
  ## [0.1.0] - 2025-08-28
4
13
 
5
14
  - Initial release
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ module DbConnectionSubscriber
5
+ WAIT_KEY = :dead_bro_db_connection_wait_ms
6
+ COUNT_KEY = :dead_bro_db_connection_checkouts
7
+
8
+ # Prepended onto ConnectionPool so every checkout is timed.
9
+ # Only accumulates when a request is being tracked (thread-local is a Numeric).
10
+ module CheckoutInstrumentation
11
+ def checkout(*args)
12
+ return super unless Thread.current[DbConnectionSubscriber::WAIT_KEY].is_a?(Numeric)
13
+
14
+ # Initialize conn before calling super so the rescue block can tell whether
15
+ # checkout succeeded before timing code raised (avoids double-checkout).
16
+ conn = nil
17
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ conn = super
19
+ Thread.current[DbConnectionSubscriber::WAIT_KEY] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000.0
20
+ Thread.current[DbConnectionSubscriber::COUNT_KEY] += 1
21
+ conn
22
+ rescue
23
+ conn || super
24
+ end
25
+ end
26
+
27
+ def self.install!
28
+ return unless defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
29
+ return if ActiveRecord::ConnectionAdapters::ConnectionPool.ancestors.include?(CheckoutInstrumentation)
30
+
31
+ ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(CheckoutInstrumentation)
32
+ rescue StandardError => e
33
+ warn "[DeadBro] DbConnectionSubscriber install failed: #{e.class}: #{e.message}"
34
+ end
35
+
36
+ def self.start_request_tracking
37
+ Thread.current[WAIT_KEY] = 0.0
38
+ Thread.current[COUNT_KEY] = 0
39
+ end
40
+
41
+ def self.stop_request_tracking
42
+ wait_ms = Thread.current[WAIT_KEY]
43
+ checkouts = Thread.current[COUNT_KEY]
44
+ Thread.current[WAIT_KEY] = nil
45
+ Thread.current[COUNT_KEY] = nil
46
+ { wait_ms: wait_ms&.round(2), checkouts: checkouts }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ module GcTracker
5
+ THREAD_KEY = :dead_bro_gc_start
6
+
7
+ def self.start_request_tracking
8
+ Thread.current[THREAD_KEY] = snapshot
9
+ end
10
+
11
+ def self.stop_request_tracking
12
+ before = Thread.current[THREAD_KEY]
13
+ return {} if before.nil? || before.empty?
14
+ diff(before, snapshot)
15
+ ensure
16
+ Thread.current[THREAD_KEY] = nil
17
+ end
18
+
19
+ def self.snapshot
20
+ return {} unless defined?(GC) && GC.respond_to?(:stat)
21
+ stat = GC.stat
22
+ {
23
+ minor_gc_count: stat[:minor_gc_count] || 0,
24
+ major_gc_count: stat[:major_gc_count] || 0,
25
+ total_allocated_objects: stat[:total_allocated_objects] || 0,
26
+ gc_time_ns: GC.respond_to?(:total_time) ? GC.total_time : nil
27
+ }
28
+ rescue
29
+ {}
30
+ end
31
+
32
+ def self.diff(before, after)
33
+ return {} if before.empty? || after.empty?
34
+ gc_time_ms = if before[:gc_time_ns] && after[:gc_time_ns]
35
+ ((after[:gc_time_ns] - before[:gc_time_ns]) / 1_000_000.0).round(3)
36
+ end
37
+ {
38
+ minor_gc_runs: (after[:minor_gc_count] || 0) - (before[:minor_gc_count] || 0),
39
+ major_gc_runs: (after[:major_gc_count] || 0) - (before[:major_gc_count] || 0),
40
+ allocated_objects: (after[:total_allocated_objects] || 0) - (before[:total_allocated_objects] || 0),
41
+ gc_time_ms: gc_time_ms
42
+ }
43
+ rescue
44
+ {}
45
+ end
46
+ end
47
+ end
@@ -24,6 +24,11 @@ module DeadBro
24
24
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
25
25
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
26
26
  end
27
+
28
+ # Start DB connection pool wait tracking
29
+ if defined?(DeadBro::DbConnectionSubscriber)
30
+ DeadBro::DbConnectionSubscriber.start_request_tracking
31
+ end
27
32
  end
28
33
  rescue
29
34
  # Never raise from instrumentation install
@@ -12,6 +12,12 @@ module DeadBro
12
12
  JOB_EXCEPTION_EVENT_NAME = "exception.active_job"
13
13
 
14
14
  def self.subscribe!(client: Client.new)
15
+ # Snap GC state before the job runs so stop_request_tracking gets a valid diff
16
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |_name, _started, _finished, _unique_id, _data|
17
+ DeadBro::GcTracker.start_request_tracking if defined?(DeadBro::GcTracker)
18
+ rescue
19
+ end
20
+
15
21
  # Track job execution
16
22
  ActiveSupport::Notifications.subscribe(JOB_EVENT_NAME) do |name, started, finished, _unique_id, data|
17
23
  begin
@@ -43,6 +49,7 @@ module DeadBro
43
49
  end
44
50
 
45
51
  duration_ms = ((finished - started) * 1000.0).round(2)
52
+ queue_duration_ms = job_queue_duration_ms(data[:job], started)
46
53
 
47
54
  # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
48
55
  # This handles job backends that don't emit perform_start events
@@ -50,6 +57,7 @@ module DeadBro
50
57
  DeadBro.logger.clear
51
58
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
52
59
  DeadBro::SqlSubscriber.start_request_tracking
60
+ DeadBro::DbConnectionSubscriber.start_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
53
61
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
54
62
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
55
63
  else
@@ -59,6 +67,8 @@ module DeadBro
59
67
 
60
68
  # Get SQL queries executed during this job
61
69
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
70
+ db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
71
+ gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
62
72
 
63
73
  # Stop memory tracking and get collected memory data
64
74
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
@@ -94,6 +104,10 @@ module DeadBro
94
104
  queue_name: data[:job].queue_name,
95
105
  arguments: safe_arguments(data[:job].arguments),
96
106
  duration_ms: duration_ms,
107
+ queue_duration_ms: queue_duration_ms,
108
+ db_connection_wait_ms: db_connection_stats[:wait_ms],
109
+ db_connection_checkouts: db_connection_stats[:checkouts],
110
+ gc_pressure: gc_pressure,
97
111
  status: "completed",
98
112
  sql_queries: sql_queries,
99
113
  rails_env: DeadBro.env,
@@ -129,13 +143,14 @@ module DeadBro
129
143
 
130
144
  duration_ms = ((finished - started) * 1000.0).round(2)
131
145
  exception = data[:exception_object]
132
- data[:job].class.name
146
+ queue_duration_ms = job_queue_duration_ms(data[:job], started)
133
147
 
134
148
  # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
135
149
  unless DeadBro::SqlSubscriber.tracking_active?
136
150
  DeadBro.logger.clear
137
151
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
138
152
  DeadBro::SqlSubscriber.start_request_tracking
153
+ DeadBro::DbConnectionSubscriber.start_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
139
154
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
140
155
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
141
156
  else
@@ -145,6 +160,8 @@ module DeadBro
145
160
 
146
161
  # Get SQL queries executed during this job
147
162
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
163
+ db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
164
+ gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
148
165
 
149
166
  # Stop memory tracking and get collected memory data
150
167
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
@@ -180,6 +197,10 @@ module DeadBro
180
197
  queue_name: data[:job].queue_name,
181
198
  arguments: safe_arguments(data[:job].arguments),
182
199
  duration_ms: duration_ms,
200
+ queue_duration_ms: queue_duration_ms,
201
+ db_connection_wait_ms: db_connection_stats[:wait_ms],
202
+ db_connection_checkouts: db_connection_stats[:checkouts],
203
+ gc_pressure: gc_pressure,
183
204
  status: "failed",
184
205
  sql_queries: sql_queries,
185
206
  exception_class: exception&.class&.name,
@@ -205,6 +226,8 @@ module DeadBro
205
226
  # build a payload (excluded job / sampled out). Matches Subscriber.drain_request_tracking.
206
227
  def self.drain_job_tracking
207
228
  DeadBro::SqlSubscriber.stop_request_tracking if defined?(DeadBro::SqlSubscriber)
229
+ DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
230
+ DeadBro::GcTracker.stop_request_tracking if defined?(DeadBro::GcTracker)
208
231
  DeadBro::LightweightMemoryTracker.stop_request_tracking if defined?(DeadBro::LightweightMemoryTracker)
209
232
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
210
233
  DeadBro::MemoryTrackingSubscriber.stop_request_tracking
@@ -215,6 +238,17 @@ module DeadBro
215
238
 
216
239
  private
217
240
 
241
+ def self.job_queue_duration_ms(job, perform_started)
242
+ enqueued_at = job.enqueued_at
243
+ return nil if enqueued_at.nil?
244
+
245
+ enqueued_time = enqueued_at.is_a?(Time) ? enqueued_at : Time.parse(enqueued_at.to_s)
246
+ diff_ms = ((perform_started - enqueued_time) * 1000.0).round(2)
247
+ diff_ms >= 0 ? diff_ms : nil
248
+ rescue
249
+ nil
250
+ end
251
+
218
252
  def self.safe_arguments(arguments)
219
253
  return [] unless arguments.is_a?(Array)
220
254
 
@@ -36,6 +36,10 @@ if defined?(Rails) && defined?(Rails::Railtie)
36
36
  require "dead_bro/elasticsearch_subscriber"
37
37
  DeadBro::ElasticsearchSubscriber.subscribe!
38
38
 
39
+ # Install DB connection pool wait tracking
40
+ require "dead_bro/db_connection_subscriber"
41
+ DeadBro::DbConnectionSubscriber.install!
42
+
39
43
  # Install view rendering tracking
40
44
  require "dead_bro/view_rendering_subscriber"
41
45
  DeadBro::ViewRenderingSubscriber.subscribe!(client: shared_client)
@@ -9,6 +9,15 @@ module DeadBro
9
9
  def call(env)
10
10
  return @app.call(env) if DeadBro.configuration.skip_tracking?
11
11
 
12
+ # Capture rack entry time before any setup so middleware overhead is accurately measured.
13
+ rack_entry = Time.now
14
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = rack_entry
15
+
16
+ # Queue time: gap between when the upstream proxy accepted the connection and when a Rack
17
+ # worker picked it up. Heroku sets X-Request-Start as "t=<microseconds>"; nginx typically
18
+ # uses "t=<seconds.ms>". Both are parsed below.
19
+ Thread.current[:dead_bro_queue_duration_ms] = parse_queue_start(env, rack_entry)
20
+
12
21
  # Clear logs for this request
13
22
  DeadBro.logger.clear
14
23
 
@@ -47,12 +56,17 @@ module DeadBro
47
56
  DeadBro::ElasticsearchSubscriber.start_request_tracking
48
57
  end
49
58
 
59
+ # Start DB connection pool wait tracking
60
+ if defined?(DeadBro::DbConnectionSubscriber)
61
+ DeadBro::DbConnectionSubscriber.start_request_tracking
62
+ end
63
+
64
+ # Start GC pressure tracking — snapshot before any app code runs
65
+ DeadBro::GcTracker.start_request_tracking if defined?(DeadBro::GcTracker)
66
+
50
67
  # Start outgoing HTTP accumulation for this request
51
68
  Thread.current[:dead_bro_http_events] = []
52
69
 
53
- # Set tracking start time once for all subscribers (before starting any tracking)
54
- Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
55
-
56
70
  @app.call(env)
57
71
  ensure
58
72
  # Clean up thread-local storage
@@ -81,10 +95,41 @@ module DeadBro
81
95
  Thread.current[:dead_bro_lightweight_memory] = nil
82
96
  end
83
97
 
84
- # Clean up HTTP events, ES events, and tracking start time
98
+ # Clean up HTTP events, ES events, DB connection tracking, and tracking start time
85
99
  Thread.current[:dead_bro_elasticsearch_events] = nil
86
100
  Thread.current[:dead_bro_http_events] = nil
101
+ Thread.current[:dead_bro_queue_duration_ms] = nil
102
+ DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
103
+ Thread.current[DeadBro::GcTracker::THREAD_KEY] = nil if defined?(DeadBro::GcTracker)
87
104
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = nil
88
105
  end
106
+
107
+ private
108
+
109
+ def parse_queue_start(env, rack_entry)
110
+ raw = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
111
+ return nil if raw.nil? || raw.empty?
112
+
113
+ # Strip "t=" prefix used by Heroku and nginx
114
+ raw = raw.sub(/\At=/, "")
115
+ num = raw.to_f
116
+ return nil if num <= 0
117
+
118
+ request_start =
119
+ if num > 1_000_000_000_000_000 # microseconds (Heroku)
120
+ Time.at(num / 1_000_000.0)
121
+ elsif num > 1_000_000_000_000 # milliseconds
122
+ Time.at(num / 1_000.0)
123
+ else # seconds (nginx)
124
+ Time.at(num)
125
+ end
126
+
127
+ # Guard against clocks being out of sync or wildly misconfigured proxy timestamps.
128
+ # Cap at 60 s — anything larger almost certainly means the header value is wrong.
129
+ diff_ms = ((rack_entry - request_start) * 1000.0).round(2)
130
+ diff_ms >= 0 && diff_ms <= 60_000 ? diff_ms : nil
131
+ rescue
132
+ nil
133
+ end
89
134
  end
90
135
  end
@@ -49,6 +49,11 @@ module DeadBro
49
49
  end
50
50
 
51
51
  duration_ms = ((finished - started) * 1000.0).round(2)
52
+
53
+ # Time spent in Rack middleware before ActionController took over (routing, session, auth, etc.)
54
+ rack_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
55
+ rack_duration_ms = rack_start ? ([((started - rack_start) * 1000.0), 0].max).round(2) : nil
56
+
52
57
  # Stop SQL tracking and get collected queries (this was started by the request)
53
58
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
54
59
 
@@ -57,6 +62,12 @@ module DeadBro
57
62
  redis_events = defined?(DeadBro::RedisSubscriber) ? DeadBro::RedisSubscriber.stop_request_tracking : []
58
63
  elasticsearch_events = defined?(DeadBro::ElasticsearchSubscriber) ? DeadBro::ElasticsearchSubscriber.stop_request_tracking : []
59
64
 
65
+ # Stop DB connection pool wait tracking
66
+ db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
67
+
68
+ # Stop GC pressure tracking — diff minor/major runs, objects allocated, GC time
69
+ gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
70
+
60
71
  # Stop view rendering tracking and get collected view events
61
72
  view_events = DeadBro::ViewRenderingSubscriber.stop_request_tracking
62
73
  view_performance = DeadBro::ViewRenderingSubscriber.analyze_view_performance(view_events)
@@ -166,6 +177,11 @@ module DeadBro
166
177
  view_performance: view_performance,
167
178
  memory_events: memory_events,
168
179
  memory_performance: memory_performance,
180
+ rack_duration_ms: rack_duration_ms,
181
+ queue_duration_ms: Thread.current[:dead_bro_queue_duration_ms],
182
+ db_connection_wait_ms: db_connection_stats[:wait_ms],
183
+ db_connection_checkouts: db_connection_stats[:checkouts],
184
+ gc_pressure: gc_pressure,
169
185
  logs: DeadBro.logger.logs
170
186
  }
171
187
  client.post_metric(event_name: name, payload: payload)
@@ -186,6 +202,8 @@ module DeadBro
186
202
  DeadBro::MemoryTrackingSubscriber.stop_request_tracking
187
203
  end
188
204
  Thread.current[:dead_bro_http_events] = nil
205
+ DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
206
+ DeadBro::GcTracker.stop_request_tracking if defined?(DeadBro::GcTracker)
189
207
  rescue
190
208
  # Best effort — draining must never raise from the notifications callback.
191
209
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.13"
4
+ VERSION = "0.2.15"
5
5
  end
data/lib/dead_bro.rb CHANGED
@@ -18,6 +18,7 @@ module DeadBro
18
18
  autoload :MemoryTrackingSubscriber, "dead_bro/memory_tracking_subscriber"
19
19
  autoload :MemoryLeakDetector, "dead_bro/memory_leak_detector"
20
20
  autoload :LightweightMemoryTracker, "dead_bro/lightweight_memory_tracker"
21
+ autoload :GcTracker, "dead_bro/gc_tracker"
21
22
  autoload :MemoryHelpers, "dead_bro/memory_helpers"
22
23
  autoload :JobSubscriber, "dead_bro/job_subscriber"
23
24
  autoload :JobSqlTrackingMiddleware, "dead_bro/job_sql_tracking_middleware"
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.13
4
+ version: 0.2.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -33,9 +33,11 @@ files:
33
33
  - lib/dead_bro/collectors/sample_store.rb
34
34
  - lib/dead_bro/collectors/system.rb
35
35
  - lib/dead_bro/configuration.rb
36
+ - lib/dead_bro/db_connection_subscriber.rb
36
37
  - lib/dead_bro/dispatcher.rb
37
38
  - lib/dead_bro/elasticsearch_subscriber.rb
38
39
  - lib/dead_bro/error_middleware.rb
40
+ - lib/dead_bro/gc_tracker.rb
39
41
  - lib/dead_bro/http_instrumentation.rb
40
42
  - lib/dead_bro/job_sql_tracking_middleware.rb
41
43
  - lib/dead_bro/job_subscriber.rb