dead_bro 0.2.37 → 0.2.38

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: 85f75545759422dfd14db796ac17035d9d6722e8e68e9c31e9ef0382358e93f8
4
- data.tar.gz: beed782f1043421e364b1548db3dcd29dd1389d54d46440c2ff65aaf81a4b6af
3
+ metadata.gz: 4258f98fde5b0e222593ebfadee095b6402576d94a33a2b57ac26c71f0b75d47
4
+ data.tar.gz: 4a268f4eb24b882bf2dddd36a45db694f664319d3fa9650c3587eb59dd37f2af
5
5
  SHA512:
6
- metadata.gz: 1dd65b6b589c013f24d18a548491aa6203c990cf0522c23c0e9000939898aed0e6927a26793c1a8eb80e4fe96e7a9c72176436f2cf8d3ae84848562b2fb82c2d
7
- data.tar.gz: c0c020935c96898d9c54bd1b9f39ee77bf7040b8ad0cf2a9d51487af14199dca91b46e6b89b66e99c73cce470452136cb71d49ae2535f725b05edcd8b1511bdc
6
+ metadata.gz: 94f6cccf6594f0c50467b61a5d2bf53d6b73e7e72e6f042c2eae3cea2bad12e674e8a2f525bb7880f51f3b6cc679e3f3951dfba224b07e9701b48669060ffd51
7
+ data.tar.gz: eae0e565ae2d1b43f20df7c3fd14311c7543f29b1680f0495c1fa1b4ca86a30c880b20dc94ba9cc147288dff41fa39eb7aae360dafb76139b751dccc380d02e4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.38] - 2026-10-26
4
+
5
+ ### Added
6
+ - **Nested Request Trace.** Each request now sends its span tree as `spans`: the Rack middleware chain, the router, the controller action, view renders (layouts, templates, partials and collections), `DeadBro.watch` blocks, the block a `Rails.cache.fetch` miss runs, and SQL queries, each with an `id` and the id of the span it ran inside (`parent_id`). Repeated calls on the same path share one span with a `count`: a partial rendered 50 times from one template is one span, and the query it runs each time is one span under it. A span's `total_ms` is its calls' summed time with the spans nested in it included, and `start_ms` / `end_ms` are its first call's start and its last call's end. Cache, Redis, HTTP, Elasticsearch and transaction events carry the `parent_id` of the span they ran in, the cache event of a `Rails.cache.fetch` block and `DeadBro.watch` events the `span_id` of their span, and `sql_queries` entries the `id` their SQL spans refer to. DeadBro shows the tree with each span's total time and self time (total minus the spans nested in it). Up to 500 spans are kept per request; `spans_dropped` counts the ones left out. Background jobs don't send `spans`. An action that uses `ActionController::Live` runs on a thread of its own; its spans are recorded there, without the middleware chain.
7
+ - **Middleware timing.** Each middleware of the app's Rails middleware stack and the router it ends in are timed as spans, so the trace shows the time each one spends before and after the app. The dashboard's middleware spans setting turns their recording off and on without a restart, and `config.middleware_spans_enabled = false` in an initializer leaves the middleware stack as Rails builds it, without the probes. Either way, the trace then starts at the controller action.
8
+
9
+ ### Changed
10
+ - A request's payload is sent once the outermost middleware has returned instead of when the controller action finishes, so that the middleware spans are complete. If that middleware never returns through DeadBro's probe, for example when a timeout outside the Rails middleware stack raises into the thread at that moment, the payload is sent when the thread's next request comes in, and the next request's spans start afresh.
11
+
12
+ ### Fixed
13
+ - **An HTTP request made through Faraday was recorded twice:** once by DeadBro's Faraday middleware and once by its `Net::HTTP` or Typhoeus hook underneath, so its time was counted twice. It is now recorded once, by the Faraday middleware, which also sends the request's start offset and exception class as the `Net::HTTP` hook does. Elasticsearch requests sent through Faraday are recorded once too. A request the Faraday middleware leaves out (one to localhost) is still left to the `Net::HTTP` or Typhoeus hook, as before.
14
+
3
15
  ## [0.2.37] - 2026-10-26
4
16
 
5
17
  ### Fixed
data/README.md CHANGED
@@ -118,6 +118,20 @@ DeadBro automatically tracks view rendering performance for each request. This i
118
118
  - **Slow view detection**: Identification of the slowest rendering views
119
119
  - **Frequency analysis**: Most frequently rendered views
120
120
 
121
+ ## Request Trace (span tree)
122
+
123
+ Each web request carries its span tree as `spans`, which DeadBro shows as a nested Request Trace with each span's total and self time:
124
+
125
+ - **Spans**: every middleware of the Rails middleware stack and the router it ends in, the controller action, view renders (layouts, templates, partials, collections), `DeadBro.watch` blocks, the block a `Rails.cache.fetch` miss runs, and SQL queries. Each has an `id`, the `parent_id` of the span it ran inside (`nil` at the top) and a `kind` (`middleware`, `endpoint`, `action`, `view`, `watch`, `cache`, `sql`).
126
+ - **Repeated calls**: calls with the same name under the same parent share one span with a `count`. A partial rendered 50 times from one template is one span; the query it runs each time is one span under it. `Rails.cache.fetch` blocks whose keys differ only in their digits share one span, named by the key with each run of digits as `?`.
127
+ - **Times**: `total_ms` is the calls' summed duration, nested spans included; `start_ms` and `end_ms` are the first call's start and the last call's end, as offsets from the request's tracking start like every `start_offset_ms` (middleware that runs before DeadBro's own has negative offsets).
128
+ - **Other events**: cache, Redis, HTTP, Elasticsearch and transaction events stay in their own lists and carry the `parent_id` of the span they ran in. The cache event of a `Rails.cache.fetch` block and `DeadBro.watch` events carry the `span_id` of their span, and SQL spans refer to their `sql_queries` entry by `ref`.
129
+ - **Limits**: up to 500 spans per request; `spans_dropped` counts the ones left out, whose time stays in their parent's self time.
130
+
131
+ Middleware is timed by a probe Rails places around each middleware when it builds the middleware stack. The middleware stack returns after the controller action has finished, so a request's payload is sent once the outermost middleware has returned. An action that uses `ActionController::Live` runs on a thread of its own, and its spans are recorded there, without the middleware chain. Background jobs don't send `spans`.
132
+
133
+ The dashboard's **Middleware spans** setting (`middleware_spans_enabled`, on by default) turns their recording off and on without a restart. While it's off, the probes stay in place but record nothing: the trace starts at the controller action, and a request's payload is sent when the action finishes. To leave the middleware stack as Rails builds it, without the probes, set `config.middleware_spans_enabled = false` in `config/initializers/dead_bro.rb` (default `true`). It's read when Rails builds the middleware stack, after the app's initializers have run, so a change takes effect when the app restarts; while it's `false`, the dashboard setting can't turn the spans on.
134
+
121
135
  ## Memory Tracking & Leak Detection
122
136
 
123
137
  DeadBro automatically tracks memory usage and detects memory leaks with minimal performance impact. This includes:
@@ -69,7 +69,7 @@ module DeadBro
69
69
  def self.build_event(name, data, duration_ms, start_offset_ms = nil)
70
70
  return nil unless data.is_a?(Hash)
71
71
 
72
- {
72
+ event = {
73
73
  event: name,
74
74
  duration_ms: duration_ms,
75
75
  start_offset_ms: start_offset_ms,
@@ -80,6 +80,17 @@ module DeadBro
80
80
  namespace: safe_namespace(data[:namespace]),
81
81
  at: Time.now.utc.to_i
82
82
  }
83
+ # A Rails.cache.fetch block is a span of the request's tree: its event
84
+ # names that span, which ran in the span the event's parent_id gives.
85
+ node = DeadBro::SpanTracker.cache_node(data)
86
+ if node
87
+ event[:span_id] = node[:id]
88
+ event[:parent_id] = node[:parent_id] if node[:parent_id]
89
+ else
90
+ parent_id = DeadBro::SpanTracker.current_id
91
+ event[:parent_id] = parent_id if parent_id
92
+ end
93
+ event
83
94
  rescue
84
95
  nil
85
96
  end
@@ -19,6 +19,14 @@ module DeadBro
19
19
  alias_method :explain_analyze_enabled, :explain_enabled
20
20
  alias_method :explain_analyze_enabled=, :explain_enabled=
21
21
 
22
+ # Middleware spans. false in the app's initializer leaves the middleware
23
+ # stack as Rails builds it, without a MiddlewareProbe around each
24
+ # middleware and the router, so the span tree starts at the controller
25
+ # action; it's read when Rails builds the stack, after the initializers.
26
+ # With the probes in place, the dashboard's setting of the same name turns
27
+ # their recording on and off (see #middleware_spans_active?).
28
+ attr_accessor :middleware_spans_enabled
29
+
22
30
  # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
23
31
  attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled, :allocation_sample_rate,
24
32
  :sample_rate, :slow_query_threshold_ms, :watch_enabled,
@@ -73,7 +81,7 @@ module DeadBro
73
81
  explain_enabled slow_query_threshold_ms max_sql_queries_to_send max_logs_to_send
74
82
  watch_enabled excluded_controllers excluded_jobs exclusive_controllers exclusive_jobs
75
83
  monitor_enabled enable_db_stats enable_process_stats enable_system_stats
76
- sample_rates_by_type report_routing_errors
84
+ sample_rates_by_type report_routing_errors middleware_spans_enabled
77
85
  ].freeze
78
86
 
79
87
  def initialize
@@ -89,6 +97,10 @@ module DeadBro
89
97
  @explicit_deploy_revision = nil
90
98
  @disk_paths = ["/"]
91
99
  @interfaces_ignore = %w[lo lo0 docker0]
100
+ @middleware_spans_enabled = true
101
+ # The dashboard's switch for recording middleware spans; on until a
102
+ # settings response says otherwise (older backends never send it).
103
+ @remote_middleware_spans_enabled = true
92
104
 
93
105
  # Remote-managed defaults (used until backend sends real values)
94
106
  @sample_rate = 100
@@ -193,6 +205,10 @@ module DeadBro
193
205
  # must never be able to switch it on — only off. The local opt-in
194
206
  # (explain_enabled) stays untouched; see #explain_active?
195
207
  @remote_explain_enabled = !!value
208
+ when "middleware_spans_enabled"
209
+ # The probes are already in place (or not) by now; this only turns
210
+ # their recording on and off. The local value stays as it was.
211
+ @remote_middleware_spans_enabled = !!value
196
212
  when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled", "watch_enabled",
197
213
  "monitor_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats",
198
214
  "report_routing_errors"
@@ -212,6 +228,13 @@ module DeadBro
212
228
  !!(@explain_enabled && @remote_explain_enabled)
213
229
  end
214
230
 
231
+ # Middleware spans are recorded when the app hasn't turned them off in its
232
+ # initializer (which also leaves the stack without probes) and the
233
+ # dashboard hasn't either.
234
+ def middleware_spans_active?
235
+ !!(@middleware_spans_enabled && @remote_middleware_spans_enabled)
236
+ end
237
+
215
238
  alias_method :explain_analyze_active?, :explain_active?
216
239
 
217
240
  def heartbeat_due?
@@ -130,7 +130,7 @@ module DeadBro
130
130
  end
131
131
 
132
132
  def build_event(method, path, status, duration_ms, start_offset_ms = nil)
133
- {
133
+ event = {
134
134
  method: method.to_s.upcase,
135
135
  path: sanitize_path(path),
136
136
  operation: extract_operation(method, path),
@@ -138,6 +138,9 @@ module DeadBro
138
138
  duration_ms: duration_ms,
139
139
  start_offset_ms: start_offset_ms
140
140
  }
141
+ parent_id = DeadBro::SpanTracker.current_id
142
+ event[:parent_id] = parent_id if parent_id
143
+ event
141
144
  end
142
145
  end
143
146
  end
@@ -8,6 +8,10 @@ module DeadBro
8
8
  EVENT_NAME = "outgoing.http"
9
9
  THREAD_LOCAL_KEY = :dead_bro_http_events
10
10
  MAX_TRACKED_EVENTS = 1000
11
+ # Set while DeadBro's Faraday middleware runs the request: the adapter's
12
+ # own Net::HTTP or Typhoeus call is that request, recorded once by the
13
+ # middleware.
14
+ IN_FARADAY_KEY = :dead_bro_http_in_faraday
11
15
 
12
16
  def self.install!(client: Client.new)
13
17
  install_net_http!(client)
@@ -50,7 +54,11 @@ module DeadBro
50
54
  tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
51
55
  start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
52
56
 
53
- if is_es_host
57
+ if Thread.current[DeadBro::HttpInstrumentation::IN_FARADAY_KEY]
58
+ # Faraday sent it through this adapter; DeadBro's Faraday
59
+ # middleware around the request records it.
60
+ nil
61
+ elsif is_es_host
54
62
  # Route to elasticsearch subscriber instead of http_outgoing
55
63
  if Thread.current[DeadBro::ElasticsearchSubscriber::THREAD_LOCAL_KEY]
56
64
  path = (uri && uri.path) || req.path
@@ -75,6 +83,8 @@ module DeadBro
75
83
  start_offset_ms: start_offset_ms,
76
84
  exception: error && error.class.name
77
85
  }
86
+ parent_id = DeadBro::SpanTracker.current_id
87
+ payload[:parent_id] = parent_id if parent_id
78
88
  if Thread.current[THREAD_LOCAL_KEY] && DeadBro::HttpInstrumentation.should_continue_tracking?
79
89
  Thread.current[THREAD_LOCAL_KEY] << payload
80
90
  end
@@ -106,7 +116,9 @@ module DeadBro
106
116
  (respond_to?(:base_url) ? base_url : nil)
107
117
  end
108
118
 
109
- skip_instrumentation = req_url && (req_url.include?("localhost:3100/apm/v1/metrics") || req_url.include?("deadbro.aberatii.com/apm/v1/metrics"))
119
+ # Inside DeadBro's Faraday middleware, which records the request.
120
+ skip_instrumentation = Thread.current[DeadBro::HttpInstrumentation::IN_FARADAY_KEY] ||
121
+ (req_url && (req_url.include?("localhost:3100/apm/v1/metrics") || req_url.include?("deadbro.aberatii.com/apm/v1/metrics")))
110
122
 
111
123
  unless skip_instrumentation
112
124
  payload = {
@@ -116,6 +128,8 @@ module DeadBro
116
128
  status: response && response.code,
117
129
  duration_ms: duration_ms
118
130
  }
131
+ parent_id = DeadBro::SpanTracker.current_id
132
+ payload[:parent_id] = parent_id if parent_id
119
133
  if Thread.current[THREAD_LOCAL_KEY] && DeadBro::HttpInstrumentation.should_continue_tracking?
120
134
  Thread.current[THREAD_LOCAL_KEY] << payload
121
135
  end
@@ -137,10 +151,21 @@ module DeadBro
137
151
  middleware_klass = Class.new(::Faraday::Middleware) do
138
152
  def call(env)
139
153
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
+ wall_start = Time.now
140
155
  response = nil
156
+ error = nil
157
+ # A request this middleware leaves out (below) is left to the
158
+ # adapter's own Net::HTTP or Typhoeus hook, as before.
159
+ records = DeadBro::HttpInstrumentation.faraday_records?(env)
160
+ in_faraday = Thread.current[DeadBro::HttpInstrumentation::IN_FARADAY_KEY]
161
+ Thread.current[DeadBro::HttpInstrumentation::IN_FARADAY_KEY] = true if records
141
162
  begin
142
163
  response = @app.call(env)
164
+ rescue => e
165
+ error = e
166
+ raise
143
167
  ensure
168
+ Thread.current[DeadBro::HttpInstrumentation::IN_FARADAY_KEY] = in_faraday
144
169
  finish_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
170
  duration_ms = ((finish_time - start_time) * 1000.0).round(2)
146
171
  begin
@@ -150,7 +175,9 @@ module DeadBro
150
175
  url_str = url.to_s
151
176
 
152
177
  is_es_host = DeadBro::HttpInstrumentation.elasticsearch_host?(host, port)
153
- skip = !is_es_host && (url_str.include?("localhost") || url_str.include?("aberatii.com"))
178
+
179
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
180
+ start_offset_ms = tracking_start ? ((wall_start - tracking_start) * 1000.0).round(2) : nil
154
181
 
155
182
  if is_es_host
156
183
  if Thread.current[DeadBro::ElasticsearchSubscriber::THREAD_LOCAL_KEY]
@@ -158,10 +185,11 @@ module DeadBro
158
185
  method: env.method.to_s.upcase,
159
186
  path: url.path,
160
187
  status: response&.status,
161
- duration_ms: duration_ms
188
+ duration_ms: duration_ms,
189
+ start_offset_ms: start_offset_ms
162
190
  )
163
191
  end
164
- elsif !skip
192
+ elsif records
165
193
  lib = DeadBro::HttpInstrumentation.typesense_host?(host, port) ? "typesense" : "faraday"
166
194
  payload = {
167
195
  library: lib,
@@ -170,8 +198,12 @@ module DeadBro
170
198
  host: host,
171
199
  path: url.path,
172
200
  status: response&.status,
173
- duration_ms: duration_ms
201
+ duration_ms: duration_ms,
202
+ start_offset_ms: start_offset_ms,
203
+ exception: error&.class&.name
174
204
  }
205
+ parent_id = DeadBro::SpanTracker.current_id
206
+ payload[:parent_id] = parent_id if parent_id
175
207
  key = DeadBro::HttpInstrumentation::THREAD_LOCAL_KEY
176
208
  if Thread.current[key] && DeadBro::HttpInstrumentation.should_continue_tracking?
177
209
  Thread.current[key] << payload
@@ -202,6 +234,16 @@ module DeadBro
202
234
  rescue
203
235
  end
204
236
 
237
+ # Whether DeadBro's Faraday middleware records the request: everything but
238
+ # localhost and DeadBro's own endpoint, Elasticsearch hosts always.
239
+ def self.faraday_records?(env)
240
+ url = env.url
241
+ url_str = url.to_s
242
+ elasticsearch_host?(url.host.to_s, url.port) || !(url_str.include?("localhost") || url_str.include?("aberatii.com"))
243
+ rescue *DeadBro::CONTAINED_ERRORS
244
+ false
245
+ end
246
+
205
247
  def self.elasticsearch_host?(host, port)
206
248
  return false if host.nil?
207
249
  return true if port == 9200
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ # Times one Rack middleware — or the router the stack ends in — as a span of
5
+ # the request's tree (see SpanTracker). Rails wraps every middleware in one
6
+ # when it builds the app's middleware stack; the probe reads a clock on the
7
+ # way in and out and allocates nothing.
8
+ class MiddlewareProbe
9
+ attr_reader :span_name, :span_kind
10
+
11
+ def initialize(app, name, kind)
12
+ @app = app
13
+ @span_name = name
14
+ @span_kind = kind
15
+ end
16
+
17
+ def call(env)
18
+ frame = SpanTracker.rack_enter(self)
19
+ @app.call(env)
20
+ ensure
21
+ SpanTracker.rack_leave(frame) if frame
22
+ end
23
+
24
+ # Anything else asked of the middleware goes to it unchanged.
25
+ def respond_to_missing?(method_name, include_private = false)
26
+ @app.respond_to?(method_name, include_private) || super
27
+ end
28
+
29
+ def method_missing(method_name, *args, &block)
30
+ if @app.respond_to?(method_name)
31
+ @app.public_send(method_name, *args, &block)
32
+ else
33
+ super
34
+ end
35
+ end
36
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
37
+
38
+ # ActionDispatch::MiddlewareStack::Middleware#build: one probe per middleware.
39
+ module MiddlewareBuild
40
+ def build(app)
41
+ return super unless MiddlewareProbe.enabled?
42
+
43
+ MiddlewareProbe.wrap(super, inspect, SpanTracker::MIDDLEWARE)
44
+ end
45
+ end
46
+
47
+ # ActionDispatch::MiddlewareStack#build: a probe around the endpoint (the
48
+ # app's routes), so routing and controller setup aren't counted as the
49
+ # innermost middleware's own time.
50
+ module StackBuild
51
+ def build(app = nil, &block)
52
+ endpoint = app || block
53
+ return super if endpoint.nil? || !MiddlewareProbe.enabled?
54
+
55
+ super(MiddlewareProbe.wrap(endpoint, endpoint.class.name, SpanTracker::ENDPOINT))
56
+ end
57
+ end
58
+
59
+ # config.middleware_spans_enabled, read when Rails builds the stack.
60
+ def self.enabled?
61
+ DeadBro.configuration.middleware_spans_enabled ? true : false
62
+ rescue
63
+ true
64
+ end
65
+
66
+ def self.wrap(app, name, kind)
67
+ new(app, name.to_s.dup.freeze, kind)
68
+ rescue
69
+ app
70
+ end
71
+
72
+ # Must run before Rails builds the middleware stack (the
73
+ # build_middleware_stack initializer); a stack built earlier has no probes.
74
+ def self.install!
75
+ return false unless defined?(::ActionDispatch::MiddlewareStack)
76
+
77
+ stack = ::ActionDispatch::MiddlewareStack
78
+ stack.prepend(StackBuild) unless stack.ancestors.include?(StackBuild)
79
+ stack::Middleware.prepend(MiddlewareBuild) unless stack::Middleware.ancestors.include?(MiddlewareBuild)
80
+ true
81
+ rescue
82
+ false
83
+ end
84
+ end
85
+ end
@@ -53,6 +53,11 @@ if defined?(Rails) && defined?(Rails::Railtie)
53
53
  require "dead_bro/view_rendering_subscriber"
54
54
  DeadBro::ViewRenderingSubscriber.subscribe!(client: shared_client)
55
55
 
56
+ # Install the span tree (controller action and view spans; the
57
+ # middleware spans come from the probes installed below)
58
+ require "dead_bro/span_tracker"
59
+ DeadBro::SpanTracker.subscribe!
60
+
56
61
  # Install lightweight memory tracking (default)
57
62
  require "dead_bro/lightweight_memory_tracker"
58
63
  require "dead_bro/memory_leak_detector"
@@ -104,6 +109,17 @@ if defined?(Rails) && defined?(Rails::Railtie)
104
109
  # Never raise in Railtie init
105
110
  end
106
111
 
112
+ # Time every middleware and the router for the span tree. Railtie
113
+ # initializers run before the application's build_middleware_stack, so
114
+ # the probes are in place when Rails builds the stack.
115
+ initializer "dead_bro.middleware_probes" do
116
+ require "dead_bro/span_tracker"
117
+ require "dead_bro/middleware_probe"
118
+ DeadBro::MiddlewareProbe.install!
119
+ rescue
120
+ # Never raise in Railtie init
121
+ end
122
+
107
123
  # Insert SQL tracking middleware
108
124
  initializer "dead_bro.sql_tracking_middleware" do |app|
109
125
  require "dead_bro/sql_tracking_middleware"
@@ -90,6 +90,8 @@ module DeadBro
90
90
  db: RedisSubscriber.safe_db(@db),
91
91
  error: error ? error.class.name : nil
92
92
  }
93
+ parent_id = DeadBro::SpanTracker.current_id
94
+ event[:parent_id] = parent_id if parent_id
93
95
 
94
96
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
95
97
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
@@ -122,6 +124,8 @@ module DeadBro
122
124
  start_offset_ms: start_offset_ms,
123
125
  db: RedisSubscriber.safe_db(@db)
124
126
  }
127
+ parent_id = DeadBro::SpanTracker.current_id
128
+ event[:parent_id] = parent_id if parent_id
125
129
 
126
130
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
127
131
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
@@ -154,6 +158,8 @@ module DeadBro
154
158
  start_offset_ms: start_offset_ms,
155
159
  db: RedisSubscriber.safe_db(@db)
156
160
  }
161
+ parent_id = DeadBro::SpanTracker.current_id
162
+ event[:parent_id] = parent_id if parent_id
157
163
 
158
164
  if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
159
165
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
@@ -220,7 +226,7 @@ module DeadBro
220
226
 
221
227
  def self.build_event(name, data, duration_ms, start_offset_ms = nil)
222
228
  cmd = extract_command(data)
223
- {
229
+ event = {
224
230
  event: name.to_s,
225
231
  command: cmd[:command],
226
232
  key: cmd[:key],
@@ -229,6 +235,9 @@ module DeadBro
229
235
  start_offset_ms: start_offset_ms,
230
236
  db: safe_db(data[:db])
231
237
  }
238
+ parent_id = DeadBro::SpanTracker.current_id
239
+ event[:parent_id] = parent_id if parent_id
240
+ event
232
241
  rescue
233
242
  nil
234
243
  end
@@ -0,0 +1,650 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ # Builds a web request's span tree: every span gets an id and the id of the
5
+ # span it ran inside (parent_id), so the dashboard can nest the Request Trace
6
+ # and show each span's total (inclusive) and self (exclusive) time.
7
+ #
8
+ # Nodes are kept for the spans other spans run inside — Rack middleware and
9
+ # the router (MiddlewareProbe), the controller action, view renders,
10
+ # DeadBro.watch blocks and the block a Rails.cache.fetch miss runs — and for
11
+ # SQL queries, and are sent as the request's `spans`. Repeated calls on the
12
+ # same path share one node: a partial rendered 50 times inside one template
13
+ # is one node with count 50, and the query it runs each time is one child
14
+ # node with count 50. Cache, Redis, HTTP, Elasticsearch and transaction
15
+ # events stay in their own lists and carry the parent_id of the span they
16
+ # ran inside; the cache event of a fetch block carries its node's span_id.
17
+ #
18
+ # Node fields: id, parent_id (nil at the top), kind (middleware, endpoint,
19
+ # action, view, watch, cache, sql), name, count, total_ms — the summed
20
+ # duration of the node's calls, nested spans included — and start_ms /
21
+ # end_ms — the first call's start and the last call's end, as offsets from
22
+ # the tracking start like every other start_offset_ms. View nodes add type
23
+ # (template, layout, partial, collection) and cache nodes type generate;
24
+ # SQL nodes carry ref, the id of their sql_queries entry, instead of a name.
25
+ #
26
+ # The middleware chain returns after the controller action has finished, so
27
+ # a request that runs inside it has its payload held until the outermost
28
+ # middleware returns (see .post), and is sent with the middleware spans. If
29
+ # that middleware never returns through its probe, the payload is sent when
30
+ # the thread's next request comes in (see .rack_enter).
31
+ #
32
+ # The tree belongs to the thread that runs the request. ActionController::Live
33
+ # runs the action on a thread of its own, with the request thread's locals
34
+ # copied over; that thread records the action in a tree of its own (see
35
+ # .live_context).
36
+ module SpanTracker
37
+ THREAD_KEY = :dead_bro_span_context
38
+
39
+ # Nodes kept per request; past it, a span's time stays in its parent's self
40
+ # time and the request's spans_dropped counts what was left out.
41
+ MAX_NODES = 500
42
+ # Middleware/router frames kept per request (a Rails stack has ~25).
43
+ MAX_RACK_FRAMES = 200
44
+ # Payloads held for one middleware chain: the request's own, and the
45
+ # exceptions app's when Rails dispatches an error to it. Past this a
46
+ # payload is sent right away.
47
+ MAX_PENDING = 10
48
+
49
+ MIDDLEWARE = "middleware"
50
+ ENDPOINT = "endpoint"
51
+ ACTION = "action"
52
+ VIEW = "view"
53
+ WATCH = "watch"
54
+ CACHE = "cache"
55
+ SQL = "sql"
56
+
57
+ # Frame pushed for a span past MAX_NODES, so leaving it still pops the
58
+ # right frame.
59
+ DROPPED_VIEW = {kind: VIEW}.freeze
60
+ DROPPED_WATCH = {kind: WATCH}.freeze
61
+ DROPPED_CACHE = {kind: CACHE}.freeze
62
+
63
+ # Rails.cache.fetch runs its block inside this event when the key misses.
64
+ CACHE_GENERATE_EVENT = "cache_generate.active_support"
65
+ # Fetch blocks whose keys differ only in their digits share a node.
66
+ CACHE_KEY_DIGITS = /\d+/
67
+
68
+ VIEW_EVENTS = {
69
+ "render_layout.action_view" => "layout",
70
+ "render_template.action_view" => "template",
71
+ "render_partial.action_view" => "partial",
72
+ "render_collection.action_view" => "collection"
73
+ }.freeze
74
+
75
+ # One request's tree, reused by the thread's next request.
76
+ class Context
77
+ attr_reader :nodes, :dropped, :thread
78
+ attr_accessor :pending
79
+
80
+ def initialize
81
+ # The thread this tree is recorded on. Another thread holding this
82
+ # context (ActionController::Live copies the request thread's locals)
83
+ # records nothing into it.
84
+ @thread = Thread.current
85
+ # Open spans inside the middleware chain, innermost last: a node Hash
86
+ # or a DROPPED_* marker. The ids are what starts now gets as
87
+ # parent_id; the starts are Float seconds (CLOCK_REALTIME, the clock
88
+ # behind every other offset in the payload).
89
+ @frames = []
90
+ @frame_ids = []
91
+ @frame_starts = []
92
+ # parent_id => {key => node}, per kind, so repeat calls on a path find
93
+ # their node.
94
+ @view_index = {}
95
+ @watch_index = {}
96
+ @cache_index = {}
97
+ @sql_index = {}
98
+ # Each fetch block's event payload => its node, for CacheSubscriber,
99
+ # whose listener for the same event may run before or after ours.
100
+ @cache_payloads = {}.compare_by_identity
101
+ # Middleware frames, as parallel arrays: the chain runs on every
102
+ # request, so recording it allocates nothing. Frame i is span i + 1
103
+ # (the other spans are numbered after MAX_RACK_FRAMES); @rack_current
104
+ # is the innermost open one and @rack_parents each one's enclosing
105
+ # frame.
106
+ @rack_probes = []
107
+ @rack_parents = []
108
+ @rack_starts = []
109
+ @rack_ends = []
110
+ reset
111
+ end
112
+
113
+ def reset
114
+ # A new Array: the last one may still be in a payload waiting to be sent.
115
+ @nodes = []
116
+ @frames.clear
117
+ @frame_ids.clear
118
+ @frame_starts.clear
119
+ @view_index.clear
120
+ @watch_index.clear
121
+ @cache_index.clear
122
+ @sql_index.clear
123
+ @cache_payloads.clear
124
+ @rack_probes.clear
125
+ @rack_parents.clear
126
+ @rack_starts.clear
127
+ @rack_ends.clear
128
+ @rack_current = nil
129
+ @next_id = MAX_RACK_FRAMES
130
+ @base = nil
131
+ @tracking = false
132
+ @recording_rack = false
133
+ @action = nil
134
+ @pending = nil
135
+ @dropped = 0
136
+ @finalized = false
137
+ end
138
+
139
+ def tracking?
140
+ @tracking
141
+ end
142
+
143
+ def idle?
144
+ @frames.empty? && @pending.nil? && !rack_open?
145
+ end
146
+
147
+ # Left over from a request whose outermost middleware never returned
148
+ # through its probe — an exception raised into the thread from outside
149
+ # the Rails stack (an outer timeout) while the probe opened or closed its
150
+ # frame: nothing is open or running, or the outermost probe is entered
151
+ # again while its own frame still is.
152
+ def abandoned?(probe)
153
+ return false if idle?
154
+
155
+ (@rack_current.nil? && @frames.empty?) || (@rack_current == 0 && @rack_probes[0].equal?(probe))
156
+ end
157
+
158
+ def current_id
159
+ @frame_ids.last || (@rack_current && @rack_current + 1)
160
+ end
161
+
162
+ # The tracking start (TRACKING_START_TIME_KEY), which every offset is
163
+ # measured from.
164
+ def start_tracking(base)
165
+ @base = base
166
+ @tracking = true
167
+ end
168
+
169
+ def stop_tracking
170
+ @tracking = false
171
+ end
172
+
173
+ # True while the outermost middleware hasn't returned yet.
174
+ def rack_open?
175
+ !@rack_ends.empty? && @rack_ends[0].nil?
176
+ end
177
+
178
+ def start_rack
179
+ @recording_rack = true
180
+ end
181
+
182
+ # A Rack app called from inside the action isn't recorded: middleware
183
+ # frames are the outermost spans.
184
+ def rack_enter(probe, now)
185
+ return nil unless @recording_rack && @frames.empty?
186
+
187
+ i = @rack_probes.size
188
+ return nil if i >= MAX_RACK_FRAMES
189
+
190
+ @rack_probes << probe
191
+ @rack_parents << @rack_current
192
+ @rack_starts << now
193
+ @rack_ends << nil
194
+ @rack_current = i
195
+ i
196
+ end
197
+
198
+ def rack_leave(i, now)
199
+ @rack_ends[i] = now
200
+ @rack_current = @rack_parents[i]
201
+ # Anything still open inside it never finished.
202
+ pop_to(0) unless @frames.empty?
203
+ end
204
+
205
+ def enter_action(now)
206
+ node = new_node(ACTION, current_id)
207
+ return nil unless node
208
+
209
+ @action = node
210
+ push(node, node[:id], now)
211
+ node
212
+ end
213
+
214
+ # Closed with the times process_action reports, which every listener of
215
+ # the event sees, rather than when this listener happens to run.
216
+ def finish_action(started, finished)
217
+ j = innermost(ACTION)
218
+ return unless j
219
+
220
+ node = @frames[j]
221
+ pop_to(j)
222
+ add_call(node, started, finished)
223
+ end
224
+
225
+ def enter_view(identifier, type, now)
226
+ key = identifier || type
227
+ node = node_for(VIEW, @view_index, key) do |created|
228
+ created[:name] = DeadBro::ViewRenderingSubscriber.safe_identifier(identifier)
229
+ created[:type] = type
230
+ end
231
+ push(node || DROPPED_VIEW, node ? node[:id] : current_id, now)
232
+ end
233
+
234
+ def leave_view(now)
235
+ j = innermost(VIEW)
236
+ leave_frame(j, now) if j
237
+ end
238
+
239
+ def enter_watch(label, now)
240
+ node = node_for(WATCH, @watch_index, label) { |created| created[:name] = label }
241
+ frame = node || DROPPED_WATCH
242
+ push(frame, node ? node[:id] : current_id, now)
243
+ frame
244
+ end
245
+
246
+ def leave_watch(frame, now)
247
+ j = @frames.size - 1
248
+ j -= 1 while j >= 0 && !@frames[j].equal?(frame)
249
+ leave_frame(j, now) if j >= 0
250
+ end
251
+
252
+ # A Rails.cache.fetch miss: the block it runs nests in this span. Keys
253
+ # that differ only in their digits share a node, named by that pattern.
254
+ def enter_cache_generate(payload, key, now)
255
+ pattern = key.gsub(CACHE_KEY_DIGITS, "?")
256
+ node = node_for(CACHE, @cache_index, pattern) do |created|
257
+ created[:name] = key
258
+ created[:type] = "generate"
259
+ end
260
+ if node
261
+ node[:name] = pattern unless node[:name] == key
262
+ @cache_payloads[payload] = node
263
+ end
264
+ push(node || DROPPED_CACHE, node ? node[:id] : current_id, now)
265
+ end
266
+
267
+ def leave_cache_generate(now)
268
+ j = innermost(CACHE)
269
+ leave_frame(j, now) if j
270
+ end
271
+
272
+ def cache_node(payload)
273
+ @cache_payloads[payload]
274
+ end
275
+
276
+ def sql(key, ref, started, finished)
277
+ node = node_for(SQL, @sql_index, key) { |created| created[:ref] = ref }
278
+ add_call(node, started, finished) if node
279
+ end
280
+
281
+ # Rounds the totals and turns the middleware frames into nodes. Once per
282
+ # request, right before the payload is sent.
283
+ def finalize(action_name)
284
+ return if @finalized || @base.nil?
285
+
286
+ @finalized = true
287
+ @action[:name] ||= action_name if @action
288
+ @nodes.each { |node| node[:total_ms] = node[:total_ms].round(3) }
289
+ @rack_probes.each_with_index do |probe, i|
290
+ start = @rack_starts[i]
291
+ finish = @rack_ends[i] || open_frame_end(start)
292
+ parent = @rack_parents[i]
293
+ @nodes << {
294
+ id: i + 1,
295
+ parent_id: parent && parent + 1,
296
+ kind: probe.span_kind,
297
+ name: probe.span_name,
298
+ count: 1,
299
+ total_ms: ((finish - start) * 1000.0).round(3),
300
+ start_ms: offset_ms(start),
301
+ end_ms: offset_ms(finish)
302
+ }
303
+ end
304
+ end
305
+
306
+ private
307
+
308
+ def push(frame, id, now)
309
+ @frames << frame
310
+ @frame_ids << id
311
+ @frame_starts << now
312
+ end
313
+
314
+ # Pops frame j and every frame still open inside it.
315
+ def pop_to(j)
316
+ (@frames.size - j).times do
317
+ @frames.pop
318
+ @frame_ids.pop
319
+ @frame_starts.pop
320
+ end
321
+ end
322
+
323
+ # The innermost open frame of a kind (a node or a DROPPED_* marker).
324
+ # A loop rather than rindex with a block, which allocates.
325
+ def innermost(kind)
326
+ j = @frames.size - 1
327
+ while j >= 0
328
+ frame = @frames[j]
329
+ return j if frame.is_a?(Hash) && frame[:kind] == kind
330
+
331
+ j -= 1
332
+ end
333
+ nil
334
+ end
335
+
336
+ def leave_frame(j, now)
337
+ frame = @frames[j]
338
+ start = @frame_starts[j]
339
+ pop_to(j)
340
+ add_call(frame, start, now) unless frame.frozen?
341
+ end
342
+
343
+ # The node for key under the innermost open span, created on first use;
344
+ # nil past MAX_NODES.
345
+ def node_for(kind, index, key)
346
+ parent_id = current_id
347
+ by_key = (index[parent_id] ||= {})
348
+ node = by_key[key]
349
+ return node if node
350
+
351
+ node = new_node(kind, parent_id)
352
+ return nil unless node
353
+
354
+ yield node
355
+ by_key[key] = node
356
+ end
357
+
358
+ def new_node(kind, parent_id)
359
+ if @nodes.size >= MAX_NODES
360
+ @dropped += 1
361
+ return nil
362
+ end
363
+
364
+ node = {id: (@next_id += 1), parent_id: parent_id, kind: kind, count: 0, total_ms: 0.0, start_ms: nil, end_ms: nil}
365
+ @nodes << node
366
+ node
367
+ end
368
+
369
+ def add_call(node, started, finished)
370
+ node[:count] += 1
371
+ node[:total_ms] += (finished - started) * 1000.0
372
+ node[:start_ms] ||= offset_ms(started)
373
+ node[:end_ms] = offset_ms(finished)
374
+ end
375
+
376
+ def offset_ms(time)
377
+ ((time - @base) * 1000.0).round(3)
378
+ end
379
+
380
+ # A frame sent while still open (its middleware never returned through
381
+ # the probe) ends where the last frame that did close ended.
382
+ def open_frame_end(start)
383
+ finish = start
384
+ @rack_ends.each { |time| finish = time if time && time > finish }
385
+ finish
386
+ end
387
+ end
388
+
389
+ # Pushes and pops the view spans; start/finish listeners, so a view's
390
+ # children see it as their parent while it renders.
391
+ class ViewListener
392
+ def initialize(type)
393
+ @type = type
394
+ end
395
+
396
+ def start(_name, _id, payload)
397
+ SpanTracker.enter_view(payload[:identifier], @type)
398
+ end
399
+
400
+ def finish(_name, _id, _payload)
401
+ SpanTracker.leave_view
402
+ end
403
+ end
404
+
405
+ # start_processing.action_controller fires right before
406
+ # process_action.action_controller starts: the action span opens here and
407
+ # Subscriber closes it.
408
+ class ActionListener
409
+ def start(_name, _id, _payload)
410
+ SpanTracker.enter_action
411
+ end
412
+
413
+ def finish(_name, _id, _payload)
414
+ end
415
+ end
416
+
417
+ # cache_generate wraps the block of a Rails.cache.fetch miss, so what the
418
+ # block runs nests in its span instead of sitting next to it.
419
+ class CacheGenerateListener
420
+ def start(_name, _id, payload)
421
+ SpanTracker.enter_cache_generate(payload)
422
+ end
423
+
424
+ def finish(_name, _id, _payload)
425
+ SpanTracker.leave_cache_generate
426
+ end
427
+ end
428
+
429
+ module_function
430
+
431
+ def subscribe!
432
+ return unless defined?(ActiveSupport::Notifications)
433
+
434
+ VIEW_EVENTS.each do |event_name, type|
435
+ ActiveSupport::Notifications.subscribe(event_name, ViewListener.new(type))
436
+ end
437
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller", ActionListener.new)
438
+ ActiveSupport::Notifications.subscribe(CACHE_GENERATE_EVENT, CacheGenerateListener.new)
439
+ rescue
440
+ # Never raise from instrumentation install
441
+ end
442
+
443
+ def now
444
+ Process.clock_gettime(Process::CLOCK_REALTIME)
445
+ end
446
+
447
+ # This thread's context, created on first use — and in place of one
448
+ # copied from another thread.
449
+ def context
450
+ own_context || (Thread.current[THREAD_KEY] = Context.new)
451
+ end
452
+
453
+ def own_context
454
+ ctx = Thread.current[THREAD_KEY]
455
+ ctx if ctx && ctx.thread.equal?(Thread.current)
456
+ end
457
+
458
+ # The context of a request being tracked on this thread, else nil.
459
+ def tracking_context
460
+ ctx = own_context
461
+ ctx if ctx&.tracking?
462
+ end
463
+
464
+ # ActionController::Live runs the action on a thread of its own, with the
465
+ # request thread's locals copied over — that thread's context and the
466
+ # tracking start among them. The request thread leaves the middleware
467
+ # chain once the response is committed, while the action may still run,
468
+ # so the action thread records the action in a context of its own,
469
+ # measured from the same tracking start. Nil anywhere else.
470
+ def live_context
471
+ copied = Thread.current[THREAD_KEY]
472
+ return nil if copied.nil? || copied.thread.equal?(Thread.current)
473
+
474
+ tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
475
+ return nil unless tracking_start
476
+
477
+ ctx = Thread.current[THREAD_KEY] = Context.new
478
+ ctx.start_tracking(tracking_start.to_f)
479
+ ctx
480
+ end
481
+
482
+ # The id of the span running now — the parent_id of an event recorded now.
483
+ def current_id
484
+ tracking_context&.current_id
485
+ end
486
+
487
+ # Called by SqlTrackingMiddleware once tracking is set up. Without
488
+ # MiddlewareProbe frames around it (no Rails middleware stack) the request
489
+ # starts here.
490
+ def start_request(tracking_start)
491
+ ctx = context
492
+ ctx.reset if ctx.idle?
493
+ ctx.start_tracking(tracking_start.to_f)
494
+ rescue *DeadBro::CONTAINED_ERRORS
495
+ end
496
+
497
+ def stop_request
498
+ own_context&.stop_tracking
499
+ rescue *DeadBro::CONTAINED_ERRORS
500
+ end
501
+
502
+ # Returns a frame token for rack_leave, or nil when nothing is recorded.
503
+ def rack_enter(probe)
504
+ config = DeadBro.configuration
505
+ return nil unless config.enabled
506
+
507
+ ctx = own_context
508
+ if ctx&.abandoned?(probe)
509
+ # Send what the abandoned request held, and start over — also while
510
+ # the dashboard has middleware spans off, so it doesn't linger.
511
+ begin
512
+ flush(ctx)
513
+ ensure
514
+ ctx.reset
515
+ end
516
+ end
517
+ return nil unless config.middleware_spans_active?
518
+
519
+ ctx ||= context
520
+ if ctx.idle?
521
+ ctx.reset
522
+ ctx.start_rack
523
+ end
524
+ ctx.rack_enter(probe, now)
525
+ rescue *DeadBro::CONTAINED_ERRORS
526
+ nil
527
+ end
528
+
529
+ # Leaving the outermost frame (0) sends the payloads held for it.
530
+ def rack_leave(frame)
531
+ ctx = own_context
532
+ return unless ctx
533
+
534
+ ctx.rack_leave(frame, now)
535
+ return unless frame == 0
536
+
537
+ begin
538
+ flush(ctx)
539
+ ensure
540
+ ctx.reset
541
+ end
542
+ rescue *DeadBro::CONTAINED_ERRORS
543
+ end
544
+
545
+ def enter_action
546
+ (tracking_context || live_context)&.enter_action(now)
547
+ rescue *DeadBro::CONTAINED_ERRORS
548
+ end
549
+
550
+ def finish_action(started, finished)
551
+ tracking_context&.finish_action(started.to_f, finished.to_f)
552
+ rescue *DeadBro::CONTAINED_ERRORS
553
+ end
554
+
555
+ def enter_view(identifier, type)
556
+ tracking_context&.enter_view(identifier, type, now)
557
+ rescue *DeadBro::CONTAINED_ERRORS
558
+ end
559
+
560
+ def leave_view
561
+ tracking_context&.leave_view(now)
562
+ rescue *DeadBro::CONTAINED_ERRORS
563
+ end
564
+
565
+ # Returns the frame to pass to leave_watch, nil when not tracking.
566
+ def enter_watch(label)
567
+ tracking_context&.enter_watch(label, now)
568
+ rescue *DeadBro::CONTAINED_ERRORS
569
+ nil
570
+ end
571
+
572
+ def leave_watch(frame)
573
+ return unless frame
574
+
575
+ tracking_context&.leave_watch(frame, now)
576
+ rescue *DeadBro::CONTAINED_ERRORS
577
+ end
578
+
579
+ def enter_cache_generate(payload)
580
+ ctx = tracking_context
581
+ return unless ctx
582
+
583
+ key = defined?(DeadBro::CacheSubscriber) ? DeadBro::CacheSubscriber.safe_key(payload[:key]) : payload[:key]
584
+ ctx.enter_cache_generate(payload, key.to_s, now)
585
+ rescue *DeadBro::CONTAINED_ERRORS
586
+ end
587
+
588
+ def leave_cache_generate
589
+ tracking_context&.leave_cache_generate(now)
590
+ rescue *DeadBro::CONTAINED_ERRORS
591
+ end
592
+
593
+ # The node of a fetch block's cache_generate event, by the event's
594
+ # payload; nil for every other event.
595
+ def cache_node(payload)
596
+ tracking_context&.cache_node(payload)
597
+ rescue *DeadBro::CONTAINED_ERRORS
598
+ nil
599
+ end
600
+
601
+ # A query of the request's own SQL tracking (sql_depth 1: an inline job's
602
+ # queries go to the job's payload, whose sql_queries ids are its own).
603
+ def sql(key, ref, started, finished, sql_depth)
604
+ return unless sql_depth == 1
605
+
606
+ tracking_context&.sql(key, ref, started.to_f, finished.to_f)
607
+ rescue *DeadBro::CONTAINED_ERRORS
608
+ end
609
+
610
+ # The request's nodes for the payload (finished by .post), or nil when the
611
+ # request isn't being tracked — e.g. the exceptions app Rails dispatches to
612
+ # after DeadBro's middleware has returned, whose context still holds the
613
+ # failed request's tree.
614
+ def payload_spans
615
+ ctx = tracking_context
616
+ return nil unless ctx && !ctx.nodes.empty?
617
+
618
+ ctx.nodes
619
+ end
620
+
621
+ def payload_spans_dropped
622
+ ctx = tracking_context
623
+ ctx.dropped if ctx && ctx.dropped > 0
624
+ end
625
+
626
+ # Sends a request's payload. While the middleware chain is still running
627
+ # the payload is held and sent when its outermost middleware returns, with
628
+ # the middleware spans closed.
629
+ def post(client, event_name, payload, action_name)
630
+ ctx = own_context
631
+ if ctx&.rack_open? && (ctx.pending.nil? || ctx.pending.size < MAX_PENDING)
632
+ (ctx.pending ||= []) << [client, event_name, payload, action_name]
633
+ else
634
+ ctx&.finalize(action_name)
635
+ client.post_metric(event_name: event_name, payload: payload, force: true)
636
+ end
637
+ end
638
+
639
+ def flush(ctx)
640
+ pending = ctx.pending
641
+ return unless pending
642
+
643
+ ctx.pending = nil
644
+ pending.each do |client, event_name, payload, action_name|
645
+ ctx.finalize(action_name)
646
+ client.post_metric(event_name: event_name, payload: payload, force: true)
647
+ end
648
+ end
649
+ end
650
+ end
@@ -156,11 +156,14 @@ module DeadBro
156
156
  tracking_start = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
157
157
  start_offset_ms = tracking_start ? ((started - tracking_start) * 1000.0).round(2) : nil
158
158
 
159
- current << {
159
+ event = {
160
160
  event: name,
161
161
  duration_ms: ((finished - started) * 1000.0).round(2),
162
162
  start_offset_ms: start_offset_ms
163
163
  }
164
+ parent_id = DeadBro::SpanTracker.current_id
165
+ event[:parent_id] = parent_id if parent_id
166
+ current << event
164
167
  rescue
165
168
  nil
166
169
  end
@@ -251,7 +254,9 @@ module DeadBro
251
254
  agg[:n_plus_one] = true if call_count >= N_PLUS_ONE_THRESHOLD
252
255
  agg[:backtrace] = captured_trace if agg[:backtrace].empty? && !captured_trace.empty?
253
256
  else
254
- aggregates_h[normalized_key] = {
257
+ agg = aggregates_h[normalized_key] = {
258
+ # What the request's span tree refers to this query by (ref).
259
+ id: aggregates_h.size + 1,
255
260
  sql: sanitized_sql,
256
261
  name: data[:name],
257
262
  count: 1,
@@ -266,6 +271,7 @@ module DeadBro
266
271
  explain_plan: nil
267
272
  }
268
273
  end
274
+ DeadBro::SpanTracker.sql(normalized_key, agg[:id], started, finished, agg_stack.length)
269
275
  end
270
276
 
271
277
  if should_continue_tracking?(current, MAX_TRACKED_QUERIES)
@@ -38,6 +38,9 @@ module DeadBro
38
38
  DeadBro::SqlSubscriber.start_request_tracking
39
39
  end
40
40
 
41
+ # Start the span tree, measured from the same rack entry
42
+ DeadBro::SpanTracker.start_request(rack_entry) if defined?(DeadBro::SpanTracker)
43
+
41
44
  # Start cache tracking for this request
42
45
  if defined?(DeadBro::CacheSubscriber)
43
46
  DeadBro::CacheSubscriber.start_request_tracking
@@ -158,6 +161,9 @@ module DeadBro
158
161
  ensure
159
162
  Thread.current[:dead_bro_alloc_active] = nil
160
163
  Thread.current[DeadBro::TRACKING_START_TIME_KEY] = nil
164
+ # Keeps the spans (and a payload held for the middleware chain) until
165
+ # the outermost middleware returns.
166
+ DeadBro::SpanTracker.stop_request if defined?(DeadBro::SpanTracker)
161
167
  end
162
168
 
163
169
  def parse_queue_start(env, rack_entry)
@@ -13,6 +13,10 @@ module DeadBro
13
13
  # drop with an excluded request) — ErrorMiddleware must not send it again.
14
14
  mark_reported(data)
15
15
 
16
+ # Closes the action span opened at start_processing, whether or not
17
+ # this request is sent.
18
+ DeadBro::SpanTracker.finish_action(started, finished)
19
+
16
20
  # When disabled remotely, fire a heartbeat at most once per minute so the gem
17
21
  # can detect when tracking has been re-enabled, then skip all tracking.
18
22
  unless DeadBro.configuration.enabled
@@ -191,6 +195,14 @@ module DeadBro
191
195
  watch_events: watch_events,
192
196
  logs: DeadBro.logger.logs
193
197
  }
198
+ # The span tree (see SpanTracker); the middleware spans are added when
199
+ # the payload is sent.
200
+ spans = DeadBro::SpanTracker.payload_spans
201
+ if spans
202
+ detail_fields[:spans] = spans
203
+ spans_dropped = DeadBro::SpanTracker.payload_spans_dropped
204
+ detail_fields[:spans_dropped] = spans_dropped if spans_dropped
205
+ end
194
206
 
195
207
  # Report exceptions attached to this action (e.g. controller/view errors)
196
208
  if data[:exception] || data[:exception_object]
@@ -223,7 +235,7 @@ module DeadBro
223
235
  )
224
236
 
225
237
  event_name = (exception_class || exception_obj&.class&.name || "exception").to_s
226
- client.post_metric(event_name: event_name, payload: error_payload, force: true)
238
+ DeadBro::SpanTracker.post(client, event_name, error_payload, request_type_key)
227
239
  rescue
228
240
  ensure
229
241
  next
@@ -247,10 +259,12 @@ module DeadBro
247
259
  user_agent: safe_user_agent(data),
248
260
  user_id: extract_user_id(data)
249
261
  )
250
- # force: true — the sampling decision (global or per-request-type) was
251
- # already made above; client#post_metric must not re-roll it with the
252
- # global-only rate, which would silently override a per-type sample rate.
253
- client.post_metric(event_name: name, payload: payload, force: true)
262
+ # Sent with force: true — the sampling decision (global or
263
+ # per-request-type) was already made above; client#post_metric must not
264
+ # re-roll it with the global-only rate, which would silently override a
265
+ # per-type sample rate. Held until the middleware chain returns when the
266
+ # request runs inside one (see SpanTracker.post).
267
+ DeadBro::SpanTracker.post(client, name, payload, request_type_key)
254
268
  rescue *DeadBro::CONTAINED_ERRORS
255
269
  # Anything raised here would fail the host's request (see
256
270
  # DeadBro::CONTAINED_ERRORS) — drop this request's metrics instead.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.37"
4
+ VERSION = "0.2.38"
5
5
  end
@@ -91,6 +91,8 @@ module DeadBro
91
91
  frame[:slot] = events.length
92
92
  events << nil
93
93
  active_stack << frame
94
+ # The block's span in the request's span tree (nil in a job).
95
+ span_frame = DeadBro::SpanTracker.enter_watch(sanitized_label)
94
96
 
95
97
  begin
96
98
  yield
@@ -104,6 +106,8 @@ module DeadBro
104
106
  raise
105
107
  ensure
106
108
  active_stack.pop if active_stack.last.equal?(frame)
109
+ DeadBro::SpanTracker.leave_watch(span_frame)
110
+ frame[:span_id] = span_frame[:id] if span_frame.is_a?(Hash)
107
111
  append_completed_span(events, frame)
108
112
  end
109
113
  end
@@ -178,6 +182,7 @@ module DeadBro
178
182
  error: frame[:error] == true
179
183
  }
180
184
  span[:exception_class] = frame[:exception_class] if frame[:exception_class]
185
+ span[:span_id] = frame[:span_id] if frame[:span_id]
181
186
  tags = frame[:tags]
182
187
  span[:tags] = tags if tags.is_a?(Hash) && tags.any?
183
188
 
data/lib/dead_bro.rb CHANGED
@@ -30,6 +30,8 @@ module DeadBro
30
30
  autoload :MemoryDetails, "dead_bro/memory_details"
31
31
  autoload :Logger, "dead_bro/logger"
32
32
  autoload :WatchTracker, "dead_bro/watch_tracker"
33
+ autoload :SpanTracker, "dead_bro/span_tracker"
34
+ autoload :MiddlewareProbe, "dead_bro/middleware_probe"
33
35
  autoload :Sanitizer, "dead_bro/sanitizer"
34
36
  begin
35
37
  require "dead_bro/railtie"
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.37
4
+ version: 0.2.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -51,10 +51,12 @@ files:
51
51
  - lib/dead_bro/memory_leak_detector.rb
52
52
  - lib/dead_bro/memory_phase_tracker.rb
53
53
  - lib/dead_bro/memory_tracking_subscriber.rb
54
+ - lib/dead_bro/middleware_probe.rb
54
55
  - lib/dead_bro/monitor.rb
55
56
  - lib/dead_bro/railtie.rb
56
57
  - lib/dead_bro/redis_subscriber.rb
57
58
  - lib/dead_bro/sanitizer.rb
59
+ - lib/dead_bro/span_tracker.rb
58
60
  - lib/dead_bro/sql_subscriber.rb
59
61
  - lib/dead_bro/sql_tracking_middleware.rb
60
62
  - lib/dead_bro/subscriber.rb