bitfab 0.51.2 → 0.51.3

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,774 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Bitfab
8
+ # Captures Ruby method calls beneath one traced root via TracePoint.
9
+ module Subtree
10
+ DEFAULT_MAX_DEPTH = 30
11
+ DEFAULT_MAX_SPANS = 500
12
+ SESSION_KEY = :bitfab_subtree_session
13
+ DISPATCHER_KEY = :bitfab_subtree_dispatcher
14
+ FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
15
+ REALPATH_CACHE = {}
16
+ REALPATH_MUTEX = Mutex.new
17
+ SDK_PREFIX = "#{File.realpath(File.expand_path("..", __dir__))}#{File::SEPARATOR}".freeze
18
+
19
+ CaptureConfig = Struct.new(:root, :root_path, :exclude, keyword_init: true)
20
+ FiberContext = Struct.new(:session, :fiber, :thread, :span_stack, :inherited_span, :parent, keyword_init: true)
21
+
22
+ Boundary = Struct.new(
23
+ :receiver,
24
+ :method_name,
25
+ :span_id,
26
+ :counts_toward_depth,
27
+ :skip_next_call,
28
+ keyword_init: true
29
+ )
30
+
31
+ MethodMetadata = Struct.new(
32
+ :identity,
33
+ :lineno,
34
+ :name,
35
+ :excluded,
36
+ :passthrough,
37
+ keyword_init: true
38
+ )
39
+
40
+ OpenSpan = Struct.new(
41
+ :identity,
42
+ :span_id,
43
+ :parent_span_id,
44
+ :name,
45
+ :args,
46
+ :kwargs,
47
+ :started_at,
48
+ :error,
49
+ keyword_init: true
50
+ )
51
+
52
+ module_function
53
+
54
+ def prepare(root_source:, exclude:)
55
+ path = root_source[0]
56
+ root = first_party_root(path)
57
+ CaptureConfig.new(
58
+ root: root && with_separator(realpath(root)),
59
+ root_path: path,
60
+ exclude: exclude.to_h { |name| [name.to_s, true] }.freeze
61
+ ).freeze
62
+ rescue
63
+ CaptureConfig.new(root: nil, root_path: nil, exclude: {}.freeze).freeze
64
+ end
65
+
66
+ def capture(client:, trace_function_key:, config:, max_depth:, max_spans:, include_wrappers:,
67
+ root_receiver:, root_method_name:)
68
+ return yield unless config.root
69
+
70
+ span = SpanContext.current || ReplayContext.current_span
71
+ return yield unless span
72
+
73
+ session = begin
74
+ replay = ReplayContext.current
75
+ Session.new(
76
+ client:,
77
+ trace_function_key:,
78
+ trace_id: span[:trace_id],
79
+ root_span_id: span[:span_id],
80
+ test_run_id: replay&.dig(:test_run_id),
81
+ input_source_span_id: replay&.dig(:input_source_span_id),
82
+ config:,
83
+ max_depth:,
84
+ max_spans:,
85
+ include_wrappers:,
86
+ root_receiver:,
87
+ root_method_name:
88
+ )
89
+ rescue Exception # rubocop:disable Lint/RescueException
90
+ return yield
91
+ end
92
+ result = session.run(defer_enumerator: true) { yield }
93
+ result.is_a?(Enumerator) ? session.wrap_enumerator(result) : result
94
+ end
95
+
96
+ def current_session
97
+ Thread.current[SESSION_KEY] || fiber_context&.session
98
+ end
99
+
100
+ def fiber_context
101
+ context = Fiber[FIBER_CONTEXT_KEY]
102
+ context = context.parent while context && !context.session&.active?
103
+ context if context && context.thread.equal?(Thread.current)
104
+ end
105
+
106
+ def with_borrowed_span(trace_id:, span_id:, receiver:, method_name:)
107
+ sessions = active_sessions.select { |session| session.trace_id == trace_id }
108
+ return yield if sessions.empty?
109
+
110
+ with_boundaries(
111
+ sessions,
112
+ receiver:,
113
+ method_name:,
114
+ span_id:,
115
+ counts_toward_depth: true
116
+ ) { yield }
117
+ end
118
+
119
+ def with_suppressed_node(receiver:, method_name:, sessions: active_sessions)
120
+ return yield if sessions.empty?
121
+
122
+ with_boundaries(
123
+ sessions,
124
+ receiver:,
125
+ method_name:,
126
+ span_id: nil,
127
+ counts_toward_depth: false
128
+ ) { yield }
129
+ end
130
+
131
+ def active_sessions
132
+ sessions = []
133
+ session = current_session
134
+ while session
135
+ sessions << session
136
+ session = session.parent_session
137
+ end
138
+ sessions
139
+ end
140
+
141
+ def current_parent_span_id(trace_id)
142
+ session = active_sessions.find { |candidate| candidate.trace_id == trace_id }
143
+ session&.current_parent_span_id
144
+ end
145
+
146
+ def with_boundaries(sessions, receiver:, method_name:, span_id:, counts_toward_depth:)
147
+ boundaries = sessions.map do |session|
148
+ [session, session.push_boundary(
149
+ receiver:,
150
+ method_name:,
151
+ span_id:,
152
+ counts_toward_depth:
153
+ )]
154
+ end
155
+ yield
156
+ ensure
157
+ boundaries&.reverse_each { |session, boundary| session.pop_boundary(boundary) }
158
+ end
159
+ private_class_method :with_boundaries
160
+
161
+ def active_trace?(trace_function_key)
162
+ session = current_session
163
+ while session
164
+ return true if session.trace_function_key == trace_function_key
165
+
166
+ session = session.parent_session
167
+ end
168
+ false
169
+ end
170
+
171
+ def dispatcher
172
+ Thread.current.thread_variable_get(DISPATCHER_KEY) || begin
173
+ value = Dispatcher.new
174
+ Thread.current.thread_variable_set(DISPATCHER_KEY, value)
175
+ value
176
+ end
177
+ end
178
+
179
+ def first_party_root(path)
180
+ return nil unless path && !path.start_with?("<")
181
+
182
+ resolved = realpath(path)
183
+ return nil unless resolved
184
+
185
+ if defined?(Gem)
186
+ gem_root = Gem.loaded_specs.values.map(&:full_gem_path).filter_map { |entry| realpath(entry) }
187
+ .select { |entry| resolved.start_with?(with_separator(entry)) }.max_by(&:length)
188
+ return gem_root if gem_root
189
+
190
+ Gem.path.each do |home|
191
+ prefix = with_separator(realpath(File.join(home, "gems")))
192
+ next unless prefix && resolved.start_with?(prefix)
193
+
194
+ return File.join(prefix, resolved.delete_prefix(prefix).split(File::SEPARATOR).first)
195
+ end
196
+ end
197
+
198
+ directory = File.dirname(resolved)
199
+ candidate = directory
200
+ loop do
201
+ if File.file?(File.join(candidate, "Gemfile")) || !Dir.glob(File.join(candidate, "*.gemspec")).empty?
202
+ return candidate
203
+ end
204
+
205
+ parent = File.dirname(candidate)
206
+ break if parent == candidate
207
+
208
+ candidate = parent
209
+ end
210
+ directory
211
+ rescue SystemCallError
212
+ nil
213
+ end
214
+
215
+ def realpath(path)
216
+ REALPATH_CACHE.fetch(path) do
217
+ REALPATH_MUTEX.synchronize do
218
+ REALPATH_CACHE.fetch(path) { REALPATH_CACHE[path] = File.realpath(path) }
219
+ end
220
+ end
221
+ rescue SystemCallError, TypeError
222
+ nil
223
+ end
224
+
225
+ def with_separator(path)
226
+ return nil unless path
227
+
228
+ path.end_with?(File::SEPARATOR) ? path : "#{path}#{File::SEPARATOR}"
229
+ end
230
+
231
+ class Dispatcher
232
+ def initialize
233
+ @sessions = []
234
+ @frames = []
235
+ @current_sessions = []
236
+ @trace = TracePoint.new(:call, :return, :raise, :rescue, :fiber_switch) do |event|
237
+ if event.event == :fiber_switch
238
+ switch_fiber
239
+ else
240
+ @current_sessions.each { |session| session.handle(event) }
241
+ end
242
+ end
243
+ end
244
+
245
+ def run(session)
246
+ previous_frame = Fiber[FIBER_CONTEXT_KEY]
247
+ # Fiber storage inherits the frame by reference; stacks remain local.
248
+ # Expired frames retain only their enclosing context, so children can
249
+ # keep participating in an outer capture without reviving an inner one.
250
+ frame = FiberContext.new(session:, fiber: Fiber.current, thread: Thread.current,
251
+ span_stack: SpanContext.stack, inherited_span: SpanContext.current, parent: Subtree.fiber_context)
252
+ Fiber[FIBER_CONTEXT_KEY] = frame
253
+ @frames.concat(session.deferred_frames)
254
+ session.deferred_frames.clear
255
+ @frames << frame
256
+ @sessions << session
257
+ @current_sessions = Subtree.active_sessions.reverse
258
+ @trace.enable(target_thread: Thread.current) unless @trace.enabled?
259
+ yield
260
+ ensure
261
+ @sessions.delete(session)
262
+ @sessions.each { |entry| entry.remove_parent(session) }
263
+ @frames.delete_if do |entry|
264
+ next false unless entry.session.equal?(session)
265
+
266
+ if session.deferred?
267
+ session.deferred_frames << entry
268
+ else
269
+ entry.session = entry.span_stack = entry.inherited_span = nil
270
+ end
271
+ true
272
+ end
273
+ Fiber[FIBER_CONTEXT_KEY] = previous_frame
274
+ @current_sessions = @current_sessions.reject { |entry| entry.equal?(session) }
275
+ @trace.disable if @sessions.empty?
276
+ end
277
+
278
+ private
279
+
280
+ def switch_fiber
281
+ context = Subtree.fiber_context
282
+ if context && !context.fiber.equal?(Fiber.current)
283
+ parent_fiber_id = context.fiber.object_id
284
+ inherited_span = context.span_stack.reverse_each.find { |span| span[:trace_id] == context.session.trace_id } ||
285
+ context.inherited_span
286
+ context = FiberContext.new(session: context.session, fiber: Fiber.current,
287
+ thread: Thread.current, span_stack: SpanContext.stack, inherited_span:, parent: context.parent)
288
+ Fiber[FIBER_CONTEXT_KEY] = context
289
+ @frames << context
290
+ Subtree.active_sessions.each { |session| session.inherit_parent(parent_fiber_id) }
291
+ end
292
+ @current_sessions = Subtree.active_sessions.reverse
293
+ end
294
+ end
295
+
296
+ class Session
297
+ attr_reader :client, :parent_session, :trace_function_key, :trace_id, :deferred_frames
298
+
299
+ def active? = @active
300
+ def deferred? = @deferred
301
+
302
+ def remove_parent(session)
303
+ @parent_session = session.parent_session if @parent_session.equal?(session)
304
+ end
305
+
306
+ def initialize(client:, trace_function_key:, trace_id:, root_span_id:, test_run_id:,
307
+ input_source_span_id:, config:, max_depth:, max_spans:, include_wrappers:,
308
+ root_receiver:, root_method_name:)
309
+ @client = client
310
+ @trace_function_key = trace_function_key
311
+ @trace_id = trace_id
312
+ @root_span_id = root_span_id
313
+ @test_run_id = test_run_id
314
+ @input_source_span_id = input_source_span_id
315
+ @root = config.root
316
+ @root_path = config.root_path
317
+ @root_receiver = root_receiver
318
+ @root_method_name = root_method_name.to_sym
319
+ @skip_root_call = true
320
+ @max_depth = max_depth
321
+ @max_spans = max_spans
322
+ @exclude = config.exclude
323
+ @include_wrappers = include_wrappers
324
+ @stacks = {}
325
+ @fiber_parents = {}
326
+ @suppressed_depths = {}
327
+ @owned_paths = {}
328
+ @method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
329
+ @spans_used = 0
330
+ @truncated = false
331
+ @exhausted = false
332
+ @drained = false
333
+ @deferred_frames = []
334
+ end
335
+
336
+ def run(defer_enumerator: false)
337
+ previous = Thread.current[SESSION_KEY]
338
+ @parent_session = Subtree.current_session
339
+ @active = true
340
+ @deferred = false
341
+ Thread.current[SESSION_KEY] = self
342
+ entered = false
343
+ completed = false
344
+ result = Subtree.dispatcher.run(self) do
345
+ entered = true
346
+ value = yield
347
+ @deferred = defer_enumerator && value.is_a?(Enumerator)
348
+ completed = true
349
+ value
350
+ end
351
+ result
352
+ rescue Exception # rubocop:disable Lint/RescueException
353
+ return result if completed
354
+ raise if entered
355
+
356
+ yield
357
+ ensure
358
+ Thread.current[SESSION_KEY] = previous
359
+ @active = false
360
+ @parent_session = nil
361
+ unless @deferred
362
+ @fiber_parents.clear
363
+ finish_open_spans
364
+ warn_if_truncated
365
+ end
366
+ end
367
+
368
+ def wrap_enumerator(source)
369
+ Enumerator.new do |yielder|
370
+ run do
371
+ source.each { |value| yielder << value }
372
+ end
373
+ end
374
+ end
375
+
376
+ def handle(event)
377
+ return if @drained
378
+
379
+ if event.event == :raise
380
+ record_error(event)
381
+ return
382
+ elsif event.event == :rescue
383
+ clear_rescued_error(event)
384
+ return
385
+ end
386
+
387
+ return if event.event == :call && skip_boundary_call?(event)
388
+
389
+ resolved_path = owned_path(event.path)
390
+ return unless resolved_path
391
+
392
+ case event.event
393
+ when :call
394
+ open_span(event, resolved_path)
395
+ when :return
396
+ close(event, resolved_path)
397
+ end
398
+ rescue Exception # rubocop:disable Lint/RescueException
399
+ nil
400
+ end
401
+
402
+ def excluded?(method_name)
403
+ @exclude[method_name.to_s] == true
404
+ end
405
+
406
+ def reserve_node
407
+ stack = current_stack(Fiber.current.object_id)
408
+ if captured_depth(stack) >= @max_depth
409
+ @truncated = true
410
+ return false
411
+ end
412
+ if @spans_used >= @max_spans
413
+ @truncated = true
414
+ @exhausted = true
415
+ return false
416
+ end
417
+
418
+ @spans_used += 1
419
+ true
420
+ end
421
+
422
+ def current_parent_span_id
423
+ stack = @stacks[Fiber.current.object_id] || []
424
+ stack.reverse_each.find { |entry| entry.span_id }&.span_id ||
425
+ @fiber_parents.dig(Fiber.current.object_id, :span_id) || @root_span_id
426
+ end
427
+
428
+ def inherit_parent(parent_fiber_id)
429
+ fiber_id = Fiber.current.object_id
430
+ return if @fiber_parents.key?(fiber_id)
431
+
432
+ stack = @stacks[parent_fiber_id] || []
433
+ parent = @fiber_parents[parent_fiber_id]
434
+ @fiber_parents[fiber_id] = {
435
+ span_id: stack.reverse_each.find { |entry| entry.span_id }&.span_id || parent&.dig(:span_id) || @root_span_id,
436
+ depth: local_depth(stack) + parent&.dig(:depth).to_i
437
+ }
438
+ end
439
+
440
+ def push_boundary(receiver:, method_name:, span_id:, counts_toward_depth:)
441
+ boundary = Boundary.new(
442
+ receiver:,
443
+ method_name: method_name.to_sym,
444
+ span_id:,
445
+ counts_toward_depth:,
446
+ skip_next_call: true
447
+ )
448
+ current_stack(Fiber.current.object_id) << boundary
449
+ boundary
450
+ end
451
+
452
+ def pop_boundary(boundary)
453
+ fiber_id = Fiber.current.object_id
454
+ stack = @stacks[fiber_id]
455
+ return unless stack
456
+
457
+ if stack.last.equal?(boundary)
458
+ stack.pop
459
+ else
460
+ stack.delete(boundary)
461
+ end
462
+ @stacks.delete(fiber_id) if stack.empty?
463
+ end
464
+
465
+ private
466
+
467
+ def finish_open_spans
468
+ @stacks.each_value do |stack|
469
+ stack.reverse_each do |entry|
470
+ next unless entry.is_a?(OpenSpan)
471
+
472
+ entry.error ||= "Subtree capture ended before the call returned"
473
+ emit(entry, result: nil)
474
+ end
475
+ end
476
+ ensure
477
+ @stacks.clear
478
+ @suppressed_depths.clear
479
+ end
480
+
481
+ def open_span(event, resolved_path)
482
+ fiber_id = Fiber.current.object_id
483
+ if @suppressed_depths[fiber_id].to_i.positive?
484
+ @suppressed_depths[fiber_id] += 1
485
+ return
486
+ end
487
+
488
+ if @exhausted
489
+ suppress(fiber_id)
490
+ return
491
+ end
492
+
493
+ metadata = metadata_for(event, resolved_path)
494
+ return if metadata.excluded
495
+ return if !@include_wrappers && metadata.passthrough
496
+
497
+ stack = current_stack(fiber_id)
498
+ if captured_depth(stack) >= @max_depth
499
+ @truncated = true
500
+ suppress(fiber_id)
501
+ return
502
+ end
503
+ if @spans_used >= @max_spans
504
+ @truncated = true
505
+ @exhausted = true
506
+ if captured_calls_drained?
507
+ @drained = true
508
+ else
509
+ suppress(fiber_id)
510
+ end
511
+ return
512
+ end
513
+
514
+ active_parent = SpanContext.current || ReplayContext.current_span
515
+ explicit_receiver = active_parent && active_parent[:explicit_span_receiver]
516
+ if explicit_receiver && active_parent[:trace_id] == @trace_id && explicit_receiver.equal?(event.self) &&
517
+ active_parent[:explicit_span_method_name] == called_method_id(event)
518
+ return
519
+ end
520
+
521
+ @spans_used += 1
522
+ span_id = SecureRandom.uuid
523
+ args, kwargs = inputs(event)
524
+ # A nested subtree root temporarily owns the ambient span context, but
525
+ # an outer session must never parent its spans across trace boundaries.
526
+ active_parent_span_id = if active_parent&.dig(:trace_id) == @trace_id
527
+ active_parent[:span_id]
528
+ end
529
+ subtree_parent_span_id = stack.reverse_each.find { |entry| entry.span_id }&.span_id
530
+ stack << OpenSpan.new(
531
+ identity: metadata.identity,
532
+ span_id:,
533
+ parent_span_id: subtree_parent_span_id || @fiber_parents.dig(fiber_id, :span_id) || active_parent_span_id || @root_span_id,
534
+ name: metadata.name,
535
+ args:,
536
+ kwargs:,
537
+ started_at: now
538
+ )
539
+ end
540
+
541
+ def close(event, resolved_path)
542
+ fiber_id = Fiber.current.object_id
543
+ suppressed_depth = @suppressed_depths[fiber_id].to_i
544
+ if suppressed_depth.positive?
545
+ if suppressed_depth == 1
546
+ @suppressed_depths.delete(fiber_id)
547
+ @drained = true if @exhausted && captured_calls_drained?
548
+ else
549
+ @suppressed_depths[fiber_id] = suppressed_depth - 1
550
+ end
551
+ return
552
+ end
553
+
554
+ stack = @stacks[fiber_id]
555
+ return unless stack
556
+
557
+ entry = stack.last
558
+ return unless entry.is_a?(OpenSpan) && matching_identity?(entry.identity, event, resolved_path)
559
+
560
+ stack.pop
561
+ emit(entry, result: event.return_value)
562
+ parent = stack.last
563
+ parent.error ||= entry.error if entry.error && parent.is_a?(OpenSpan)
564
+ @stacks.delete(fiber_id) if stack.empty?
565
+ @drained = true if @exhausted && captured_calls_drained?
566
+ end
567
+
568
+ def record_error(event)
569
+ entry = @stacks[Fiber.current.object_id]&.last
570
+ return unless entry.is_a?(OpenSpan)
571
+
572
+ exception = event.raised_exception
573
+ entry.error = "#{exception.class}: #{exception.message}"
574
+ end
575
+
576
+ def clear_rescued_error(event)
577
+ entry = @stacks[Fiber.current.object_id]&.last
578
+ return unless entry.is_a?(OpenSpan)
579
+
580
+ entry.error = nil
581
+ end
582
+
583
+ def emit(entry, result:)
584
+ # Recording a lazy return value must not execute the caller's iteration.
585
+ result = Serialize.unserializable_stub(result, "lazy_enumerator") if result.is_a?(Enumerator)
586
+ @client.send(
587
+ :send_span,
588
+ trace_function_key: @trace_function_key,
589
+ trace_id: @trace_id,
590
+ span_id: entry.span_id,
591
+ parent_span_id: entry.parent_span_id,
592
+ span_name: entry.name,
593
+ span_type: "function",
594
+ function_name: entry.name,
595
+ contexts: nil,
596
+ prompt: nil,
597
+ args: entry.args,
598
+ kwargs: entry.kwargs,
599
+ result:,
600
+ error: entry.error,
601
+ started_at: entry.started_at,
602
+ ended_at: now,
603
+ test_run_id: @test_run_id,
604
+ input_source_span_id: @input_source_span_id
605
+ )
606
+ rescue Exception # rubocop:disable Lint/RescueException
607
+ nil
608
+ end
609
+
610
+ def inputs(event)
611
+ args = []
612
+ kwargs = {}
613
+ event.parameters.each do |kind, name|
614
+ next unless name
615
+
616
+ value = event.binding.local_variable_get(name)
617
+ case kind
618
+ when :req, :opt
619
+ args << value
620
+ when :rest
621
+ args.concat(value)
622
+ when :keyreq, :key
623
+ kwargs[name] = value
624
+ when :keyrest
625
+ kwargs.merge!(value)
626
+ end
627
+ end
628
+ [args, kwargs]
629
+ rescue NameError, TypeError
630
+ [[], {}]
631
+ end
632
+
633
+ def span_name(event, method_id)
634
+ if @root_receiver.equal?(event.self) && @root_method_name == method_id
635
+ return method_id.to_s
636
+ end
637
+
638
+ owner = event.defined_class
639
+ owner_name = if owner.respond_to?(:singleton_class?) && owner.singleton_class?
640
+ event.self.respond_to?(:name) ? event.self.name : nil
641
+ elsif owner.respond_to?(:name)
642
+ owner.name
643
+ end
644
+ return method_id.to_s if !owner_name || owner_name.empty? || owner_name == "Object"
645
+
646
+ "#{owner_name}.#{method_id}"
647
+ end
648
+
649
+ def skip_boundary_call?(event)
650
+ boundary = @stacks[Fiber.current.object_id]&.last
651
+ if boundary.is_a?(Boundary) && boundary.skip_next_call &&
652
+ boundary.receiver.equal?(event.self) && boundary.method_name == called_method_id(event)
653
+ boundary.skip_next_call = false
654
+ return true
655
+ end
656
+
657
+ return false unless @skip_root_call
658
+ return false unless @root_receiver.equal?(event.self) && @root_method_name == called_method_id(event)
659
+
660
+ @skip_root_call = false
661
+ if event.path != @root_path
662
+ root = Subtree.first_party_root(event.path)
663
+ resolved_root = root && Subtree.with_separator(Subtree.realpath(root))
664
+ if resolved_root
665
+ @root = resolved_root
666
+ @root_path = event.path
667
+ @owned_paths.clear
668
+ end
669
+ end
670
+ true
671
+ end
672
+
673
+ def passthrough_wrapper?(parameters)
674
+ kinds = parameters.map(&:first)
675
+ kinds.any? { |kind| kind == :rest || kind == :keyrest } &&
676
+ kinds.all? { |kind| %i[rest keyrest block].include?(kind) }
677
+ end
678
+
679
+ def metadata_for(event, resolved_path)
680
+ method_id = called_method_id(event)
681
+ definitions = @method_metadata[event.defined_class]
682
+ cached = definitions[method_id]
683
+ return cached if cached && cached.identity[0] == resolved_path && cached.lineno == event.lineno
684
+
685
+ definitions[method_id] = MethodMetadata.new(
686
+ identity: [resolved_path, method_id, event.defined_class].freeze,
687
+ lineno: event.lineno,
688
+ name: span_name(event, method_id),
689
+ excluded: @exclude[method_id.to_s],
690
+ passthrough: passthrough_wrapper?(event.parameters)
691
+ ).freeze
692
+ end
693
+
694
+ def matching_identity?(identity, event, resolved_path)
695
+ identity[0] == resolved_path && identity[1] == called_method_id(event) &&
696
+ identity[2] == event.defined_class
697
+ end
698
+
699
+ def called_method_id(event)
700
+ event.callee_id || event.method_id
701
+ end
702
+
703
+ def owned_path(path)
704
+ @owned_paths.fetch(path) do
705
+ resolved = Subtree.realpath(path)
706
+ # The selected application may itself be installed under Gem.path.
707
+ # Its own boundary wins over ancestor exclusions, never the SDK's.
708
+ @owned_paths[path] = if resolved&.start_with?(@root) &&
709
+ !resolved.start_with?(SDK_PREFIX) &&
710
+ !Subtree.library_prefixes.any? { |prefix| resolved.start_with?(prefix) && !@root.start_with?(prefix) }
711
+ resolved
712
+ else
713
+ false
714
+ end
715
+ end
716
+ end
717
+
718
+ def current_stack(fiber_id)
719
+ @stacks[fiber_id] ||= []
720
+ end
721
+
722
+ def captured_depth(stack)
723
+ local_depth(stack) + @fiber_parents.dig(Fiber.current.object_id, :depth).to_i
724
+ end
725
+
726
+ def local_depth(stack)
727
+ stack.count do |entry|
728
+ !entry.is_a?(Boundary) || entry.counts_toward_depth
729
+ end
730
+ end
731
+
732
+ def captured_calls_drained?
733
+ @suppressed_depths.empty? && @stacks.values.all? do |stack|
734
+ stack.none? { |entry| entry.is_a?(OpenSpan) }
735
+ end
736
+ end
737
+
738
+ def suppress(fiber_id)
739
+ @suppressed_depths[fiber_id] = 1
740
+ end
741
+
742
+ def warn_if_truncated
743
+ return unless @truncated
744
+
745
+ Bitfab.warn_once(
746
+ "subtree-truncated:#{@trace_function_key}",
747
+ "'#{@trace_function_key}' hit a subtree capture limit (max_depth=#{@max_depth}, " \
748
+ "max_spans=#{@max_spans}) and its trace is incomplete. Raise the limits, or narrow " \
749
+ "what is traced with exclude: [...]."
750
+ )
751
+ end
752
+
753
+ def now
754
+ Bitfab.now_iso_timestamp
755
+ end
756
+ end
757
+
758
+ def library_prefixes
759
+ @library_prefixes ||= begin
760
+ paths = [
761
+ RbConfig::CONFIG["rubylibdir"],
762
+ RbConfig::CONFIG["archdir"],
763
+ RbConfig::CONFIG["vendorlibdir"],
764
+ RbConfig::CONFIG["sitelibdir"],
765
+ File.expand_path("..", __dir__)
766
+ ]
767
+ paths.concat(Gem.path) if defined?(Gem)
768
+ paths.compact.filter_map do |path|
769
+ with_separator(realpath(path))
770
+ end.freeze
771
+ end
772
+ end
773
+ end
774
+ end