dead_bro 0.2.20 → 0.2.21

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: 46ac9efa7b56801c96f5f9e20f113613fa60c10337b94ee84adc59d0ea38234f
4
- data.tar.gz: fa10f3f9436a350d8cea97caa71541ade145c88e479cd84195a2814dc43e4395
3
+ metadata.gz: 96a0bc8cf707ea8af5d3bcd0be76fa02e28d09031c8a7897a311754cda8f7440
4
+ data.tar.gz: 4a1407455415636db2858a28f0f77a8bb530d45fa916a359f85ae22f5f4294bb
5
5
  SHA512:
6
- metadata.gz: 935d4045021860632e6121b844afe1adca986297e03b0c54c0322721b6877112e1dd1e34ccda5ad1b91d52a6a5a05e829fc8c5f045ad74623d6a1b0a5e19ef66
7
- data.tar.gz: 9171b13a16ba440015e4df1e44f260b362224c8f0ed8752f6c1a3420c97667f35ffffc97879ed2c34a829be2f95b216b552bb5b52525aa1403c988d1c3955b24
6
+ metadata.gz: 493d8d79e5eedd0d0443ddc3922ace02ed2f606220e7cff07e07484c9a628a85b54a5160551cb816630e06db34886e22ebae5c6c95e3697784b67a8c71b5b1ad
7
+ data.tar.gz: 715064a097fadfb5ef97b96be9086f760f91efa004d3d339978f87ab87b6dce935ba5111c60c92a9f1849ec5a17a80e83efbd9f9cab031a963392f03a1055b2e
data/CHANGELOG.md CHANGED
@@ -3,6 +3,39 @@
3
3
  ### Added
4
4
  - 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
5
 
6
+ ## [0.2.21] - 2026-06-02
7
+
8
+ ### Added
9
+ - **Per-span timing for the Request Trace view.** Every instrumented event now includes a `start_offset_ms` field — the wall-clock milliseconds from rack entry to when that event started. This powers the waterfall visualisation in the DeadBro dashboard without any configuration changes.
10
+ - `SqlSubscriber`: `start_offset_ms` is computed from `TRACKING_START_TIME_KEY` using the `started` timestamp provided by `ActiveSupport::Notifications`. Stored on both the raw per-query hash and on the first-occurrence aggregate entry (so the bar is positioned at the actual time the query first ran, not a fabricated cumulative offset).
11
+ - `ViewRenderingSubscriber`: same pattern applied to template, partial, and collection render events. `start_offset_ms` is captured for the first render of each unique identifier and stored on the aggregate.
12
+ - `CacheSubscriber`: `start_offset_ms` derived from the `started` `ActiveSupport::Notifications` timestamp, added to every cache event hash and passed through `build_event`.
13
+ - `RedisSubscriber`: `wall_start = Time.now` is captured at the entry point of each instrumented block (`call`, `call_pipeline`, `call_multi`). The monotonic clock is still used for duration accuracy; `wall_start` is used only for the offset relative to `TRACKING_START_TIME_KEY`. Also applied to the `ActiveSupport::Notifications` fallback path in `install_notifications_subscription!`.
14
+ - `ElasticsearchSubscriber`: `start_offset_ms` added to `build_event`; the `record` method (called from `HttpInstrumentation` for Net::HTTP-based ES requests) now accepts a `start_offset_ms:` keyword argument. The `ActiveSupport::Notifications` subscription path (`request.elasticsearch` / `request.elastic_transport`) computes it from `started`.
15
+ - `HttpInstrumentation`: `wall_start = Time.now` captured alongside the existing monotonic `start_time`. `start_offset_ms` is included in the HTTP outgoing payload and forwarded to `ElasticsearchSubscriber.record` for requests routed to an ES host.
16
+
17
+ ## [0.2.20] - 2026-05-29
18
+
19
+ ### Added
20
+ - Monitor thread sends a synchronous heartbeat immediately on startup (before the first scheduled collection tick) so that remote settings — including `monitor_enabled` — are applied from the very first reporting cycle. Sidekiq workers and other non-web processes now receive the correct configuration on boot rather than waiting up to 60 seconds for the first tick.
21
+ - `gem_version` field added to every heartbeat payload so the dashboard can display and compare the running gem version per application.
22
+ - `process_kind` included in all system monitor payloads, linking server metrics to the correct process type.
23
+ - `post_heartbeat` now accepts a `sync: true` keyword for situations that require a blocking network call before proceeding (used by the monitor startup path).
24
+
25
+ ## [0.2.19] - 2026-05-28
26
+
27
+ ### Added
28
+ - **Error fingerprinting**: every unhandled exception payload now includes a stable `fingerprint` string derived from the exception class, a normalised version of the message (numeric IDs and UUIDs stripped), and the top application stack frame. Identical errors that differ only in record IDs or UUIDs produce the same fingerprint, enabling reliable grouping and deduplication on the server.
29
+ - `DeadBro.process_kind` auto-detects the type of the current Ruby process by inspecting `$PROGRAM_NAME` and `/proc/self/cmdline`: returns `"web"` (Puma/Passenger/Unicorn/Falcon), `"worker"` (Sidekiq/GoodJob/SolidQueue/DelayedJob), `"console"`, `"task"`, or `"app"` as a fallback. The value is memoised after the first call.
30
+ - `process_kind` included in error event payloads so the backend knows whether an exception came from a web request or a background worker.
31
+
32
+ ## [0.2.18] - 2026-05-27
33
+
34
+ ### Added
35
+ - **N+1 detection in the gem**: SQL queries are normalised (bind parameters, numeric literals, and `IN (...)` lists replaced with `?`) and counted per request. When the same normalised query fires 5 or more times, it is flagged as `n_plus_one: true` on its aggregate entry. A backtrace is captured exactly at the N+1 threshold rather than on every execution, keeping overhead low while still pointing to the callsite.
36
+ - **SQL aggregation**: instead of shipping a raw array of every query, the gem now groups queries by normalised SQL and sends one aggregate entry per unique pattern with `count`, `total_duration_ms`, `min_duration_ms`, `max_duration_ms`, `total_allocations`, and `cached_count`. This reduces payload size on N+1-heavy requests and makes the SQL breakdown directly usable without server-side grouping.
37
+ - **View rendering aggregation**: template, partial, and collection renders are aggregated per identifier (last three path segments). Each entry carries `count`, `total_duration_ms`, `min_duration_ms`, `max_duration_ms`, `rendered_at_min/max`, and cache hit counts. Aggregation happens on the thread-local stack so there is no GC pressure from intermediate arrays.
38
+
6
39
  ## [0.2.17] - 2026-05-25
7
40
 
8
41
  ### Added
@@ -13,6 +46,12 @@
13
46
  ### Added
14
47
  - `ArObjectTracker`: subscribes to Rails' built-in `instantiation.active_record` notification to count the total number of ActiveRecord model instances hydrated during each request or background job. The count is reported as `ar_instantiation_count` in every payload. Uses a thread-local counter with an idempotent `subscribe!` guard, matching the same start/stop lifecycle as `GcTracker`. No monkey-patching required — Rails emits this event natively with a `record_count` field that accumulates correctly across batch loads.
15
48
 
49
+ ## [0.2.15] - 2026-05-24
50
+
51
+ ### Added
52
+ - **`GcTracker`**: records a GC snapshot at the start and end of every request and background job. Reports `gc_minor_runs`, `gc_major_runs`, `gc_allocated_objects`, `gc_time_ms`, and `heap_pages_increase` as a `gc_pressure` hash in every payload. Uses `GC.stat` and `GC::Profiler` with an idempotent subscribe guard; overhead is negligible when no GC cycles occur.
53
+ - **`SqlAllocListener`**: measures GC allocation deltas per SQL event by snapshotting `GC.stat[:total_allocated_objects]` in the notification `start` callback and diffing in `finish`. The delta is stored by notification ID and merged into the corresponding query's `allocations` field, allowing the dashboard to surface allocation-heavy queries independently of their duration.
54
+
16
55
  ## [0.2.14] - 2026-05-23
17
56
 
18
57
  ### Added
@@ -24,7 +24,9 @@ module DeadBro
24
24
  next unless Thread.current[THREAD_LOCAL_KEY]
25
25
 
26
26
  duration_ms = ((finished - started) * 1000.0).round(2)
27
- event = build_event(name, data, duration_ms)
27
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
28
+ start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
29
+ event = build_event(name, data, duration_ms, start_offset_ms)
28
30
  if event && should_continue_tracking?
29
31
  Thread.current[THREAD_LOCAL_KEY] << event
30
32
  end
@@ -63,12 +65,13 @@ module DeadBro
63
65
  true
64
66
  end
65
67
 
66
- def self.build_event(name, data, duration_ms)
68
+ def self.build_event(name, data, duration_ms, start_offset_ms = nil)
67
69
  return nil unless data.is_a?(Hash)
68
70
 
69
71
  {
70
72
  event: name,
71
73
  duration_ms: duration_ms,
74
+ start_offset_ms: start_offset_ms,
72
75
  key: safe_key(data[:key]),
73
76
  keys_count: safe_keys_count(data[:keys]),
74
77
  hit: infer_hit(name, data),
@@ -13,12 +13,12 @@ module DeadBro
13
13
  end
14
14
 
15
15
  # Called by HttpInstrumentation when it detects a Net::HTTP request to an ES host.
16
- def self.record(method:, path:, status:, duration_ms:)
16
+ def self.record(method:, path:, status:, duration_ms:, start_offset_ms: nil)
17
17
  events = Thread.current[THREAD_LOCAL_KEY]
18
18
  return unless events
19
19
  return unless should_continue_tracking?
20
20
 
21
- events << build_event(method, path, status, duration_ms)
21
+ events << build_event(method, path, status, duration_ms, start_offset_ms)
22
22
  rescue
23
23
  end
24
24
 
@@ -120,20 +120,23 @@ module DeadBro
120
120
  duration_ms = ((finished - started) * 1000.0).round(2)
121
121
  method = payload[:method].to_s.upcase
122
122
  path = payload[:path].to_s
123
- events << build_event(method, path, payload[:status], duration_ms)
123
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
124
+ start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
125
+ events << build_event(method, path, payload[:status], duration_ms, start_offset_ms)
124
126
  rescue
125
127
  end
126
128
  end
127
129
  rescue
128
130
  end
129
131
 
130
- def build_event(method, path, status, duration_ms)
132
+ def build_event(method, path, status, duration_ms, start_offset_ms = nil)
131
133
  {
132
134
  method: method.to_s.upcase,
133
135
  path: sanitize_path(path),
134
136
  operation: extract_operation(method, path),
135
137
  status: status,
136
- duration_ms: duration_ms
138
+ duration_ms: duration_ms,
139
+ start_offset_ms: start_offset_ms
137
140
  }
138
141
  end
139
142
  end
@@ -21,6 +21,7 @@ module DeadBro
21
21
  mod = Module.new do
22
22
  define_method(:request) do |req, body = nil, &block|
23
23
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ wall_start = Time.now
24
25
  response = nil
25
26
  error = nil
26
27
  begin
@@ -46,6 +47,9 @@ module DeadBro
46
47
  # must still be tracked; only skip the deadbro backend itself.
47
48
  skip_instrumentation = !is_es_host && uri && (uri.to_s.include?("localhost") || uri.to_s.include?("aberatii.com"))
48
49
 
50
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
51
+ start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
52
+
49
53
  if is_es_host
50
54
  # Route to elasticsearch subscriber instead of http_outgoing
51
55
  if Thread.current[DeadBro::ElasticsearchSubscriber::THREAD_LOCAL_KEY]
@@ -54,7 +58,8 @@ module DeadBro
54
58
  method: req.method,
55
59
  path: path,
56
60
  status: response && response.code.to_i,
57
- duration_ms: duration_ms
61
+ duration_ms: duration_ms,
62
+ start_offset_ms: start_offset_ms
58
63
  )
59
64
  end
60
65
  elsif !skip_instrumentation
@@ -67,6 +72,7 @@ module DeadBro
67
72
  path: (uri && uri.path) || req.path,
68
73
  status: response && response.code.to_i,
69
74
  duration_ms: duration_ms,
75
+ start_offset_ms: start_offset_ms,
70
76
  exception: error && error.class.name
71
77
  }
72
78
  if Thread.current[THREAD_LOCAL_KEY] && DeadBro::HttpInstrumentation.should_continue_tracking?
@@ -63,6 +63,7 @@ module DeadBro
63
63
  def record_redis_command(command)
64
64
  return yield unless Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
65
65
 
66
+ wall_start = Time.now
66
67
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
68
  error = nil
68
69
  begin
@@ -77,12 +78,15 @@ module DeadBro
77
78
 
78
79
  begin
79
80
  cmd_info = extract_command_info(command)
81
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
82
+ start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
80
83
  event = {
81
84
  event: "redis.command",
82
85
  command: cmd_info[:command],
83
86
  key: cmd_info[:key],
84
87
  args_count: cmd_info[:args_count],
85
88
  duration_ms: duration_ms,
89
+ start_offset_ms: start_offset_ms,
86
90
  db: safe_db(@db),
87
91
  error: error ? error.class.name : nil
88
92
  }
@@ -98,6 +102,7 @@ module DeadBro
98
102
  def record_redis_pipeline(pipeline)
99
103
  return yield unless Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
100
104
 
105
+ wall_start = Time.now
101
106
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
107
  begin
103
108
  result = yield
@@ -108,10 +113,13 @@ module DeadBro
108
113
 
109
114
  begin
110
115
  commands_count = pipeline.commands&.length || 0
116
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
117
+ start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
111
118
  event = {
112
119
  event: "redis.pipeline",
113
120
  commands_count: commands_count,
114
121
  duration_ms: duration_ms,
122
+ start_offset_ms: start_offset_ms,
115
123
  db: safe_db(@db)
116
124
  }
117
125
 
@@ -126,6 +134,7 @@ module DeadBro
126
134
  def record_redis_multi(multi)
127
135
  return yield unless Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
128
136
 
137
+ wall_start = Time.now
129
138
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
139
  begin
131
140
  result = yield
@@ -136,10 +145,13 @@ module DeadBro
136
145
 
137
146
  begin
138
147
  commands_count = multi.commands&.length || 0
148
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
149
+ start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
139
150
  event = {
140
151
  event: "redis.multi",
141
152
  commands_count: commands_count,
142
153
  duration_ms: duration_ms,
154
+ start_offset_ms: start_offset_ms,
143
155
  db: safe_db(@db)
144
156
  }
145
157
 
@@ -201,7 +213,9 @@ module DeadBro
201
213
  ActiveSupport::Notifications.subscribe(/\Aredis\..+\z/) do |name, started, finished, _unique_id, data|
202
214
  next unless Thread.current[THREAD_LOCAL_KEY]
203
215
  duration_ms = ((finished - started) * 1000.0).round(2)
204
- event = build_event(name, data, duration_ms)
216
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
217
+ start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
218
+ event = build_event(name, data, duration_ms, start_offset_ms)
205
219
  if event && should_continue_tracking?
206
220
  Thread.current[THREAD_LOCAL_KEY] << event
207
221
  end
@@ -239,7 +253,7 @@ module DeadBro
239
253
  true
240
254
  end
241
255
 
242
- def self.build_event(name, data, duration_ms)
256
+ def self.build_event(name, data, duration_ms, start_offset_ms = nil)
243
257
  cmd = extract_command(data)
244
258
  {
245
259
  event: name.to_s,
@@ -247,6 +261,7 @@ module DeadBro
247
261
  key: cmd[:key],
248
262
  args_count: cmd[:args_count],
249
263
  duration_ms: duration_ms,
264
+ start_offset_ms: start_offset_ms,
250
265
  db: safe_db(data[:db])
251
266
  }
252
267
  rescue
@@ -106,6 +106,9 @@ module DeadBro
106
106
  duration_ms = ((finished - started) * 1000.0).round(2)
107
107
  original_sql = data[:sql]
108
108
 
109
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
110
+ start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
111
+
109
112
  threshold = begin
110
113
  DeadBro.configuration.slow_query_threshold_ms
111
114
  rescue
@@ -133,6 +136,7 @@ module DeadBro
133
136
  sql: sanitized_sql,
134
137
  name: data[:name],
135
138
  duration_ms: duration_ms,
139
+ start_offset_ms: start_offset_ms,
136
140
  cached: data[:cached] || false,
137
141
  connection_id: data[:connection_id],
138
142
  trace: captured_trace,
@@ -167,6 +171,7 @@ module DeadBro
167
171
  total_allocations: allocations || 0,
168
172
  cached_count: (data[:cached] ? 1 : 0),
169
173
  n_plus_one: false,
174
+ start_offset_ms: start_offset_ms,
170
175
  backtrace: captured_trace,
171
176
  explain_plan: nil
172
177
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.20"
4
+ VERSION = "0.2.21"
5
5
  end
@@ -14,24 +14,30 @@ module DeadBro
14
14
 
15
15
  def self.subscribe!(client: Client.new)
16
16
  ActiveSupport::Notifications.subscribe(RENDER_TEMPLATE_EVENT) do |_name, started, finished, _uid, data|
17
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
17
18
  add_view_event(type: "template", identifier: safe_identifier(data[:identifier]),
18
19
  duration_ms: ((finished - started) * 1000.0).round(2),
19
- rendered_at: Time.now.utc.to_i)
20
+ rendered_at: Time.now.utc.to_i,
21
+ start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
20
22
  end
21
23
 
22
24
  ActiveSupport::Notifications.subscribe(RENDER_PARTIAL_EVENT) do |_name, started, finished, _uid, data|
25
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
23
26
  add_view_event(type: "partial", identifier: safe_identifier(data[:identifier]),
24
27
  duration_ms: ((finished - started) * 1000.0).round(2),
25
28
  cache_key: data[:cache_key],
26
- rendered_at: Time.now.utc.to_i)
29
+ rendered_at: Time.now.utc.to_i,
30
+ start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
27
31
  end
28
32
 
29
33
  ActiveSupport::Notifications.subscribe(RENDER_COLLECTION_EVENT) do |_name, started, finished, _uid, data|
34
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
30
35
  add_view_event(type: "collection", identifier: safe_identifier(data[:identifier]),
31
36
  duration_ms: ((finished - started) * 1000.0).round(2),
32
37
  collection_count: (data[:count] || 0).to_i,
33
38
  collection_cached_count: (data[:cached_count] || 0).to_i,
34
- rendered_at: Time.now.utc.to_i)
39
+ rendered_at: Time.now.utc.to_i,
40
+ start_offset_ms: tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil)
35
41
  end
36
42
  rescue
37
43
  end
@@ -80,7 +86,8 @@ module DeadBro
80
86
  rendered_at_max: rendered_at,
81
87
  cache_hit_count: (view_info[:cache_key] ? 1 : 0),
82
88
  collection_count: view_info[:collection_count].to_i,
83
- collection_cached_count: view_info[:collection_cached_count].to_i
89
+ collection_cached_count: view_info[:collection_cached_count].to_i,
90
+ start_offset_ms: view_info[:start_offset_ms]
84
91
  }
85
92
  end
86
93
  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.20
4
+ version: 0.2.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa