dead_bro 0.2.29 → 0.2.30

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: 7a338a291bd4ec9c69be1850484d683b67cca4bccd4efc29b73e8469130997cb
4
- data.tar.gz: 9c77106ee1b182ebd76928929bca1ca143e9a06647fcb9f5cc4cf00b1c67490b
3
+ metadata.gz: cccae4ac857f38e889e12c292943b25fad11f7e372d7f13e4eb838d13c6330ee
4
+ data.tar.gz: 4d459c4d049219747ed5c1b6f10e1417648282eca2e3e81c7eeef132e8a347bd
5
5
  SHA512:
6
- metadata.gz: 19af4bc9fc35ea4823a6a66fb20dd39e6fe41c778dd34a5b3d5d7109fac62b90170506f5c3d1359d864590b5e61fe61d3d8db79ca20be535bef80f7938801a2f
7
- data.tar.gz: 30271ddbb549baf03d2acc8885dd45c19c49778893d427eeb79ef8e23b0b39467b5dbf65f6d47b84c2d555117ddf36929e604bdbde73171e46c27b9073ae36b9
6
+ metadata.gz: d12c33c0902c95f318cd716d7c88c78925543b235729ee90525d0453b16ed46521e2b00cc1daa5131b673abb7ee7e74e0968f299d56da055fd36452c2e314b90
7
+ data.tar.gz: e04ca78806e4d9a07757c0fedc177c2e4b77b295437d415fab94546d84add717ceab84791e34991f4955197ab1d8036ad498d679b089e795e5356beb0662d286
data/CHANGELOG.md CHANGED
@@ -3,6 +3,11 @@
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.30] - 2026-06-19
7
+
8
+ ### Added
9
+ - **`DeadBro.watch(label, **tags) { ... }`**: opt-in scoped timing for arbitrary code during instrumented web requests and background jobs. Records elapsed time, `start_offset_ms`, nesting depth, SQL count/duration attributable to the block, optional tags, and exception metadata (then re-raises). Spans are sent as `watch_events` in the normal APM payload for the Request Trace waterfall. Disabled by default; enable locally via `DeadBro.configure { |c| c.watch_enabled = true }` or remotely via the `watch_enabled` setting. Guardrails: max depth 10, max 50 spans per request, label length 200 chars, max 5 tags per span with tag values truncated to 100 chars. **Nested SQL:** each span diffs the request-wide SQL counter at enter/exit; parent spans include SQL from nested child spans (inclusive totals — use inner spans for per-block SQL, do not sum parent + child counts).
10
+
6
11
  ## [0.2.25] - 2026-06-14
7
12
 
8
13
  ### Added
@@ -17,7 +17,7 @@ module DeadBro
17
17
 
18
18
  # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
19
19
  attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled, :allocation_sample_rate,
20
- :sample_rate, :slow_query_threshold_ms,
20
+ :sample_rate, :slow_query_threshold_ms, :watch_enabled,
21
21
  :monitor_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
22
22
  :max_sql_queries_to_send, :max_logs_to_send
23
23
 
@@ -67,7 +67,7 @@ module DeadBro
67
67
  REMOTE_SETTING_KEYS = %w[
68
68
  enabled sample_rate memory_tracking_enabled allocation_tracking_enabled allocation_sample_rate
69
69
  explain_analyze_enabled slow_query_threshold_ms max_sql_queries_to_send max_logs_to_send
70
- excluded_controllers excluded_jobs exclusive_controllers exclusive_jobs
70
+ watch_enabled excluded_controllers excluded_jobs exclusive_controllers exclusive_jobs
71
71
  monitor_enabled enable_db_stats enable_process_stats enable_system_stats
72
72
  sample_rates_by_type
73
73
  ].freeze
@@ -102,6 +102,7 @@ module DeadBro
102
102
  @slow_query_threshold_ms = 500
103
103
  @max_sql_queries_to_send = 500
104
104
  @max_logs_to_send = 100
105
+ @watch_enabled = false
105
106
  self.excluded_controllers = []
106
107
  self.excluded_jobs = []
107
108
  self.exclusive_controllers = []
@@ -178,7 +179,7 @@ module DeadBro
178
179
  # must never be able to switch it on — only off. The local opt-in
179
180
  # (explain_analyze_enabled) stays untouched; see #explain_analyze_active?
180
181
  @remote_explain_analyze_enabled = !!value
181
- when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled",
182
+ when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled", "watch_enabled",
182
183
  "monitor_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats"
183
184
  send(:"#{k}=", !!value)
184
185
  when "excluded_controllers", "excluded_jobs", "exclusive_controllers", "exclusive_jobs"
@@ -208,6 +209,10 @@ module DeadBro
208
209
  Time.now.utc < t
209
210
  end
210
211
 
212
+ def watch_enabled?
213
+ !!@watch_enabled
214
+ end
215
+
211
216
  def resolve_deploy_id
212
217
  explicit = @explicit_deploy_revision&.to_s&.strip
213
218
  return explicit unless explicit.nil? || explicit.empty?
@@ -40,6 +40,8 @@ module DeadBro
40
40
  if defined?(DeadBro::DbConnectionSubscriber)
41
41
  DeadBro::DbConnectionSubscriber.start_request_tracking
42
42
  end
43
+
44
+ DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
43
45
  end
44
46
  rescue
45
47
  # Never raise from instrumentation install
@@ -61,6 +61,7 @@ module DeadBro
61
61
  DeadBro::SqlSubscriber.start_request_tracking
62
62
  start_job_dependency_tracking
63
63
  DeadBro::DbConnectionSubscriber.start_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
64
+ DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
64
65
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
65
66
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
66
67
  else
@@ -74,6 +75,7 @@ module DeadBro
74
75
  db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
75
76
  gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
76
77
  ar_instantiation_count = defined?(DeadBro::ArObjectTracker) ? DeadBro::ArObjectTracker.stop_request_tracking : nil
78
+ watch_events = defined?(DeadBro::WatchTracker) ? DeadBro::WatchTracker.stop_request_tracking : []
77
79
 
78
80
  # Stop memory tracking and get collected memory data
79
81
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
@@ -124,6 +126,7 @@ module DeadBro
124
126
  gc_stats: gc_stats,
125
127
  memory_events: memory_events,
126
128
  memory_performance: memory_performance,
129
+ watch_events: watch_events,
127
130
  logs: DeadBro.logger.logs
128
131
  }.merge(dependency_events)
129
132
 
@@ -162,6 +165,7 @@ module DeadBro
162
165
  DeadBro::SqlSubscriber.start_request_tracking
163
166
  start_job_dependency_tracking
164
167
  DeadBro::DbConnectionSubscriber.start_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
168
+ DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
165
169
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
166
170
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
167
171
  else
@@ -175,6 +179,7 @@ module DeadBro
175
179
  db_connection_stats = defined?(DeadBro::DbConnectionSubscriber) ? DeadBro::DbConnectionSubscriber.stop_request_tracking : {}
176
180
  gc_pressure = defined?(DeadBro::GcTracker) ? DeadBro::GcTracker.stop_request_tracking : {}
177
181
  ar_instantiation_count = defined?(DeadBro::ArObjectTracker) ? DeadBro::ArObjectTracker.stop_request_tracking : nil
182
+ watch_events = defined?(DeadBro::WatchTracker) ? DeadBro::WatchTracker.stop_request_tracking : []
178
183
 
179
184
  # Stop memory tracking and get collected memory data
180
185
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
@@ -228,6 +233,7 @@ module DeadBro
228
233
  gc_stats: gc_stats,
229
234
  memory_events: memory_events,
230
235
  memory_performance: memory_performance,
236
+ watch_events: watch_events,
231
237
  logs: DeadBro.logger.logs
232
238
  }.merge(dependency_events)
233
239
 
@@ -255,6 +261,7 @@ module DeadBro
255
261
  if DeadBro.configuration.allocation_tracking_enabled && defined?(DeadBro::MemoryTrackingSubscriber)
256
262
  DeadBro::MemoryTrackingSubscriber.stop_request_tracking
257
263
  end
264
+ DeadBro::WatchTracker.stop_request_tracking if defined?(DeadBro::WatchTracker)
258
265
  rescue
259
266
  # Best effort
260
267
  end
@@ -50,6 +50,21 @@ module DeadBro
50
50
  stack.last
51
51
  end
52
52
 
53
+ # Sum of SQL counts and durations recorded so far in the active tracking
54
+ # context. Used by WatchTracker to attribute SQL to a DeadBro.watch block.
55
+ def self.current_sql_metrics
56
+ agg_stack = Thread.current[THREAD_LOCAL_AGGREGATES_KEY]
57
+ aggregates_h = (agg_stack.is_a?(Array) && agg_stack.any?) ? agg_stack.last : {}
58
+ return {count: 0, duration_ms: 0.0} unless aggregates_h.is_a?(Hash) && aggregates_h.any?
59
+
60
+ {
61
+ count: aggregates_h.values.sum { |a| (a[:count] || 0).to_i },
62
+ duration_ms: aggregates_h.values.sum { |a| (a[:total_duration_ms] || 0.0).to_f }.round(2)
63
+ }
64
+ rescue StandardError
65
+ {count: 0, duration_ms: 0.0}
66
+ end
67
+
53
68
  # Check if we should continue tracking based on count and time limits
54
69
  def self.should_continue_tracking?(current_queries_array, max_count)
55
70
  return false unless current_queries_array.is_a?(Array)
@@ -82,6 +82,9 @@ module DeadBro
82
82
  # Start CPU time tracking for this request (thread-local clock)
83
83
  DeadBro::CpuTracker.start_request_tracking if defined?(DeadBro::CpuTracker)
84
84
 
85
+ # Start user-defined watch block tracking for this request
86
+ DeadBro::WatchTracker.start_request_tracking if defined?(DeadBro::WatchTracker)
87
+
85
88
  # Start outgoing HTTP accumulation for this request
86
89
  Thread.current[:dead_bro_http_events] = []
87
90
 
@@ -80,6 +80,8 @@ module DeadBro
80
80
  view_events = DeadBro::ViewRenderingSubscriber.stop_request_tracking
81
81
  view_performance = DeadBro::ViewRenderingSubscriber.analyze_view_performance(view_events)
82
82
 
83
+ watch_events = defined?(DeadBro::WatchTracker) ? DeadBro::WatchTracker.stop_request_tracking : []
84
+
83
85
  # Per-phase allocation attribution (under memory tracking) — which phase
84
86
  # allocated the request's objects (sql / view / elasticsearch).
85
87
  allocation_phases = if DeadBro.configuration.memory_tracking_enabled && defined?(DeadBro::MemoryPhaseTracker)
@@ -222,6 +224,7 @@ module DeadBro
222
224
  gc_pressure: gc_pressure,
223
225
  ar_instantiation_count: ar_instantiation_count,
224
226
  cpu_time_ms: cpu_time_ms,
227
+ watch_events: watch_events,
225
228
  logs: DeadBro.logger.logs
226
229
  }
227
230
  # force: true — the sampling decision (global or per-request-type) was
@@ -254,6 +257,7 @@ module DeadBro
254
257
  DeadBro::GcTracker.stop_request_tracking if defined?(DeadBro::GcTracker)
255
258
  DeadBro::ArObjectTracker.stop_request_tracking if defined?(DeadBro::ArObjectTracker)
256
259
  DeadBro::CpuTracker.stop_request_tracking if defined?(DeadBro::CpuTracker)
260
+ DeadBro::WatchTracker.stop_request_tracking if defined?(DeadBro::WatchTracker)
257
261
  rescue
258
262
  # Best effort — draining must never raise from the notifications callback.
259
263
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.29"
4
+ VERSION = "0.2.30"
5
5
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ # Records user-defined timed blocks (DeadBro.watch) during an instrumented
5
+ # request or background job. Spans include wall duration, nesting depth, and
6
+ # SQL attributable to the block (delta from SqlSubscriber aggregates).
7
+ #
8
+ # SQL attribution: each span diffs the global SQL counter at block entry vs
9
+ # exit. Nested blocks each get their own diff; a parent span's sql_count /
10
+ # sql_duration_ms therefore includes queries run inside nested child spans
11
+ # (inclusive totals, not exclusive). Compare inner spans when you need
12
+ # per-block SQL; do not sum parent and child counts.
13
+ #
14
+ # Unlike DeadBro.analyze, watch spans are sent to the DeadBro backend as part
15
+ # of the normal request/job payload when watch_enabled is on.
16
+ module WatchTracker
17
+ THREAD_LOCAL_EVENTS_KEY = :dead_bro_watch_events
18
+ THREAD_LOCAL_ACTIVE_STACK_KEY = :dead_bro_watch_active_stack
19
+
20
+ # Deepest depth index still recorded — depths 0..MAX_DEPTH are kept, anything
21
+ # nested below that yields untracked.
22
+ MAX_DEPTH = 10
23
+ MAX_SPANS_PER_REQUEST = 50
24
+ MAX_LABEL_LENGTH = 200
25
+ MAX_TAGS = 5
26
+ MAX_TAG_VALUE_LENGTH = 100
27
+
28
+ module_function
29
+
30
+ def enabled?
31
+ DeadBro.configuration.watch_enabled?
32
+ rescue StandardError
33
+ false
34
+ end
35
+
36
+ def tracking_active?
37
+ Thread.current[THREAD_LOCAL_EVENTS_KEY].is_a?(Array)
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ def start_request_tracking
43
+ Thread.current[THREAD_LOCAL_EVENTS_KEY] = []
44
+ Thread.current[THREAD_LOCAL_ACTIVE_STACK_KEY] = []
45
+ end
46
+
47
+ def stop_request_tracking
48
+ events = Thread.current[THREAD_LOCAL_EVENTS_KEY]
49
+ Thread.current[THREAD_LOCAL_EVENTS_KEY] = nil
50
+ Thread.current[THREAD_LOCAL_ACTIVE_STACK_KEY] = nil
51
+ # compact drops reserved slots whose block never completed — can't happen
52
+ # for a lexical block, but keeps a nil out of the payload if it ever does.
53
+ events.is_a?(Array) ? events.compact : []
54
+ rescue StandardError
55
+ []
56
+ end
57
+
58
+ def watch(label = nil, **tags)
59
+ return yield unless block_given?
60
+ return yield unless enabled?
61
+ return yield unless tracking_active?
62
+
63
+ events = Thread.current[THREAD_LOCAL_EVENTS_KEY]
64
+ active_stack = Thread.current[THREAD_LOCAL_ACTIVE_STACK_KEY]
65
+ return yield unless events.is_a?(Array) && active_stack.is_a?(Array)
66
+
67
+ depth = active_stack.length
68
+ return yield if depth > MAX_DEPTH
69
+ return yield if events.length >= MAX_SPANS_PER_REQUEST
70
+
71
+ sanitized_label = sanitize_label(label)
72
+ sanitized_tags = sanitize_tags(tags)
73
+ start_offset_ms = compute_start_offset_ms
74
+ sql_before = sql_metrics_snapshot
75
+ block_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+
77
+ frame = {
78
+ label: sanitized_label,
79
+ depth: depth,
80
+ tags: sanitized_tags,
81
+ start_offset_ms: start_offset_ms,
82
+ sql_before: sql_before,
83
+ block_start: block_start,
84
+ error: false,
85
+ exception_class: nil
86
+ }
87
+ # Reserve the slot on entry so spans come out in start order. Blocks only
88
+ # complete inner-first, so appending on completion would emit a nested span
89
+ # ahead of its own parent; the placeholder keeps parents before children.
90
+ # It also makes MAX_SPANS_PER_REQUEST count blocks still in flight.
91
+ frame[:slot] = events.length
92
+ events << nil
93
+ active_stack << frame
94
+
95
+ begin
96
+ yield
97
+ rescue StandardError => e
98
+ frame[:error] = true
99
+ # Anonymous classes (Class.new(StandardError)) have a nil name — leave the
100
+ # field unset rather than shipping an empty string the dashboard would
101
+ # render as a blank exception.
102
+ class_name = e.class.name
103
+ frame[:exception_class] = class_name[0, 200] if class_name
104
+ raise
105
+ ensure
106
+ active_stack.pop if active_stack.last.equal?(frame)
107
+ append_completed_span(events, frame)
108
+ end
109
+ end
110
+
111
+ def sanitize_label(label)
112
+ text = label.to_s.strip
113
+ text = "block" if text.empty?
114
+ (text.length > MAX_LABEL_LENGTH) ? text[0, MAX_LABEL_LENGTH] + "..." : text
115
+ rescue StandardError
116
+ "block"
117
+ end
118
+
119
+ def sanitize_tags(tags)
120
+ return {} unless tags.is_a?(Hash) && tags.any?
121
+
122
+ tags.first(MAX_TAGS).each_with_object({}) do |(key, value), out|
123
+ k = key.to_s.strip[0, 50]
124
+ next if k.empty?
125
+
126
+ v = value.to_s.strip
127
+ v = v[0, MAX_TAG_VALUE_LENGTH] + "..." if v.length > MAX_TAG_VALUE_LENGTH
128
+ out[k] = v
129
+ end
130
+ rescue StandardError
131
+ {}
132
+ end
133
+
134
+ def compute_start_offset_ms
135
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
136
+ return 0.0 unless tracking_start
137
+
138
+ ((Time.now - tracking_start) * 1000.0).round(2)
139
+ rescue StandardError
140
+ 0.0
141
+ end
142
+
143
+ def sql_metrics_snapshot
144
+ return {count: 0, duration_ms: 0.0} unless defined?(DeadBro::SqlSubscriber)
145
+
146
+ DeadBro::SqlSubscriber.current_sql_metrics
147
+ rescue StandardError
148
+ {count: 0, duration_ms: 0.0}
149
+ end
150
+
151
+ def append_completed_span(events, frame)
152
+ return unless events.is_a?(Array)
153
+
154
+ slot = frame[:slot]
155
+ return unless slot.is_a?(Integer) && slot < events.length
156
+
157
+ elapsed_ms = begin
158
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - frame[:block_start]) * 1000.0).round(2)
159
+ rescue StandardError
160
+ 0.0
161
+ end
162
+
163
+ sql_after = sql_metrics_snapshot
164
+ sql_before = frame[:sql_before] || {count: 0, duration_ms: 0.0}
165
+ sql_count = [(sql_after[:count] || 0).to_i - (sql_before[:count] || 0).to_i, 0].max
166
+ sql_duration_ms = [
167
+ (sql_after[:duration_ms] || 0.0).to_f - (sql_before[:duration_ms] || 0.0).to_f,
168
+ 0.0
169
+ ].max.round(2)
170
+
171
+ span = {
172
+ label: frame[:label],
173
+ duration_ms: elapsed_ms,
174
+ start_offset_ms: frame[:start_offset_ms],
175
+ depth: frame[:depth],
176
+ sql_count: sql_count,
177
+ sql_duration_ms: sql_duration_ms,
178
+ error: frame[:error] == true
179
+ }
180
+ span[:exception_class] = frame[:exception_class] if frame[:exception_class]
181
+ tags = frame[:tags]
182
+ span[:tags] = tags if tags.is_a?(Hash) && tags.any?
183
+
184
+ events[slot] = span
185
+ rescue StandardError
186
+ # Never raise from instrumentation
187
+ end
188
+ end
189
+ end
data/lib/dead_bro.rb CHANGED
@@ -29,6 +29,7 @@ module DeadBro
29
29
  autoload :Monitor, "dead_bro/monitor"
30
30
  autoload :MemoryDetails, "dead_bro/memory_details"
31
31
  autoload :Logger, "dead_bro/logger"
32
+ autoload :WatchTracker, "dead_bro/watch_tracker"
32
33
  begin
33
34
  require "dead_bro/railtie"
34
35
  rescue LoadError
@@ -458,4 +459,28 @@ module DeadBro
458
459
  raise error if error
459
460
  analysis_result
460
461
  end
462
+
463
+ # Time a named block during an instrumented request or background job.
464
+ #
465
+ # When watch_enabled is on and tracking is active (web request or job),
466
+ # records elapsed time, optional tags, nested SQL count/duration, and errors
467
+ # as watch_events in the APM payload for the Request Trace waterfall.
468
+ #
469
+ # Nested watch blocks: each span's sql_count/sql_duration_ms is the SQL that
470
+ # ran while that block was active. Parent spans include SQL from nested child
471
+ # spans (inclusive). Inner spans show their own slice; do not add parent and
472
+ # child SQL counts together.
473
+ #
474
+ # When disabled or outside an instrumented context, yields with no overhead
475
+ # beyond a single config check.
476
+ #
477
+ # Usage:
478
+ # DeadBro.watch("load users") { User.where(active: true).load }
479
+ # DeadBro.watch("sync billing", customer_id: user.id) { sync!(user) }
480
+ #
481
+ # Compare to DeadBro.analyze — analyze is local-only console profiling;
482
+ # watch spans are sent to the DeadBro dashboard when enabled.
483
+ def self.watch(label = nil, **tags, &block)
484
+ WatchTracker.watch(label, **tags, &block)
485
+ end
461
486
  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.29
4
+ version: 0.2.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -58,6 +58,7 @@ files:
58
58
  - lib/dead_bro/subscriber.rb
59
59
  - lib/dead_bro/version.rb
60
60
  - lib/dead_bro/view_rendering_subscriber.rb
61
+ - lib/dead_bro/watch_tracker.rb
61
62
  - lib/generators/dead_bro/install/install_generator.rb
62
63
  - lib/generators/dead_bro/install/templates/dead_bro.rb
63
64
  homepage: https://www.deadbro.com