dead_bro 0.2.36 → 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.
@@ -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)