bitfab 0.51.9 → 0.57.0

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.
@@ -6,9 +6,12 @@ require "time"
6
6
 
7
7
  module Bitfab
8
8
  # Captures Ruby method calls beneath one traced root via TracePoint.
9
+ class MixedTracingError < StandardError; end
10
+
9
11
  module Subtree
10
12
  DEFAULT_MAX_DEPTH = 30
11
- DEFAULT_MAX_SPANS = 500
13
+ DEFAULT_MAX_SPANS = 2048
14
+ DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 512
12
15
  SESSION_KEY = :bitfab_subtree_session
13
16
  DISPATCHER_KEY = :bitfab_subtree_dispatcher
14
17
  FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
@@ -46,11 +49,119 @@ module Bitfab
46
49
  :kwargs,
47
50
  :started_at,
48
51
  :error,
52
+ :span_type,
53
+ :test_run_id,
54
+ :span_data,
49
55
  keyword_init: true
50
56
  )
51
57
 
52
58
  module_function
53
59
 
60
+ def assert_surface!(surface)
61
+ enclosing = current_session ? :trace : SpanContext.current&.dig(:surface)
62
+ return unless enclosing && enclosing != surface
63
+
64
+ raise MixedTracingError,
65
+ "opt-in and opt-out tracing cannot be mixed: #{surface} entered inside #{enclosing}. " \
66
+ "Use bitfab_node inside bitfab_trace, or use bitfab_span throughout the workflow."
67
+ rescue MixedTracingError
68
+ raise
69
+ rescue
70
+ nil
71
+ end
72
+
73
+ def nested_root_suppressed?
74
+ current_session && (ReplayContext.current || (defined?(SeedContext) && SeedContext.current))
75
+ end
76
+
77
+ def qualified_name(receiver, method_name)
78
+ owner = receiver.is_a?(Module) ? receiver.name : receiver.class.name
79
+ (owner && !owner.empty? && owner != "Object") ? "#{owner}.#{method_name}" : method_name.to_s
80
+ end
81
+
82
+ class ConfiguredCall
83
+ attr_reader :span_data, :owner
84
+
85
+ def initialize(receiver:, method_name:, name:, type:, args:, kwargs:, test_run_id: nil,
86
+ nested_trace_key: nil)
87
+ @receiver = receiver
88
+ @method_name = method_name
89
+ @nested_trace_key = nested_trace_key
90
+ @span_data = {}
91
+ @completed = false
92
+ @records = Subtree.active_sessions.map do |session|
93
+ omitted = session.excluded?(method_name)
94
+ entry = unless omitted
95
+ session.configured_entry(name:, type:, args:, kwargs:, test_run_id:)
96
+ end
97
+ [session, entry, !omitted]
98
+ end
99
+ @owner = @records.find { |_, entry, _| entry }&.first unless nested_trace_key
100
+ end
101
+
102
+ def recorded_elsewhere?
103
+ @records.any? { |session, entry, _| entry && !session.equal?(@owner) }
104
+ end
105
+
106
+ def start(trace_id:, span_id:)
107
+ @records.each do |session, entry, _|
108
+ next unless entry
109
+
110
+ if session.equal?(@owner)
111
+ entry.span_id = span_id
112
+ session.discard_configured(entry)
113
+ elsif @nested_trace_key
114
+ entry.span_data = {
115
+ "nested_trace_id" => trace_id,
116
+ "nested_trace_function_key" => @nested_trace_key,
117
+ "nested_root_span_id" => span_id
118
+ }
119
+ end
120
+ end
121
+ if @nested_trace_key && (enclosing = @records.find { |_, entry, _| entry })
122
+ session, entry = enclosing
123
+ @span_data.merge!(
124
+ "enclosing_trace_id" => session.trace_id,
125
+ "enclosing_trace_function_key" => session.trace_function_key,
126
+ "enclosing_span_id" => entry.span_id
127
+ )
128
+ end
129
+ end
130
+
131
+ def run
132
+ boundaries = @records.filter_map do |session, entry, counts_toward_depth|
133
+ next if session.equal?(@owner)
134
+
135
+ [session, session.push_boundary(
136
+ receiver: @receiver, method_name: @method_name,
137
+ span_id: entry&.span_id, counts_toward_depth:
138
+ )]
139
+ end
140
+ yield
141
+ ensure
142
+ boundaries&.reverse_each { |session, boundary| session.pop_boundary(boundary) }
143
+ end
144
+
145
+ def with_owner_parent(&body)
146
+ return yield unless @owner
147
+
148
+ SpanContext.with_span(trace_id: @owner.trace_id,
149
+ span_id: @owner.current_parent_span_id, surface: :trace, &body)
150
+ end
151
+
152
+ def complete(result:, error:, ended_at:)
153
+ return if @completed
154
+
155
+ @completed = true
156
+ @records.each do |session, entry, _|
157
+ next if !entry || session.equal?(@owner)
158
+
159
+ entry.error = error
160
+ session.emit_configured(entry, result:)
161
+ end
162
+ end
163
+ end
164
+
54
165
  def prepare(root_source:, exclude:)
55
166
  path = root_source[0]
56
167
  root = first_party_root(path)
@@ -64,7 +175,7 @@ module Bitfab
64
175
  end
65
176
 
66
177
  def capture(client:, trace_function_key:, config:, max_depth:, max_spans:, include_wrappers:,
67
- root_receiver:, root_method_name:)
178
+ root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
68
179
  return yield unless config.root
69
180
 
70
181
  span = SpanContext.current || ReplayContext.current_span
@@ -82,7 +193,9 @@ module Bitfab
82
193
  config:,
83
194
  max_depth:,
84
195
  max_spans:,
196
+ max_captured_subtree_spans:,
85
197
  include_wrappers:,
198
+ mock_on_replay_default:,
86
199
  root_receiver:,
87
200
  root_method_name:
88
201
  )
@@ -294,7 +407,7 @@ module Bitfab
294
407
  end
295
408
 
296
409
  class Session
297
- attr_reader :client, :parent_session, :trace_function_key, :trace_id, :deferred_frames
410
+ attr_reader :client, :parent_session, :trace_function_key, :trace_id, :deferred_frames, :mock_on_replay_default
298
411
 
299
412
  def active? = @active
300
413
  def deferred? = @deferred
@@ -305,7 +418,8 @@ module Bitfab
305
418
 
306
419
  def initialize(client:, trace_function_key:, trace_id:, root_span_id:, test_run_id:,
307
420
  input_source_span_id:, config:, max_depth:, max_spans:, include_wrappers:,
308
- root_receiver:, root_method_name:)
421
+ root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
422
+ @mock_on_replay_default = mock_on_replay_default
309
423
  @client = client
310
424
  @trace_function_key = trace_function_key
311
425
  @trace_id = trace_id
@@ -319,14 +433,17 @@ module Bitfab
319
433
  @skip_root_call = true
320
434
  @max_depth = max_depth
321
435
  @max_spans = max_spans
436
+ @max_captured_subtree_spans = [max_captured_subtree_spans, max_spans].min
322
437
  @exclude = config.exclude
323
438
  @include_wrappers = include_wrappers
324
439
  @stacks = {}
440
+ @configured_entries = {}
325
441
  @fiber_parents = {}
326
442
  @suppressed_depths = {}
327
443
  @owned_paths = {}
328
444
  @method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
329
- @spans_used = 0
445
+ @spans_used = 1
446
+ @captured_subtree_count = 0
330
447
  @truncated = false
331
448
  @exhausted = false
332
449
  @drained = false
@@ -409,14 +526,34 @@ module Bitfab
409
526
  @truncated = true
410
527
  return false
411
528
  end
412
- if @spans_used >= @max_spans
413
- @truncated = true
414
- @exhausted = true
415
- return false
416
- end
529
+ return true if take_slot(discovered: false)
417
530
 
418
- @spans_used += 1
419
- true
531
+ @truncated = true
532
+ @exhausted = true
533
+ false
534
+ end
535
+
536
+ def configured_entry(name:, type:, args:, kwargs:, test_run_id:)
537
+ return unless reserve_node
538
+
539
+ entry = OpenSpan.new(
540
+ span_id: SecureRandom.uuid,
541
+ parent_span_id: current_parent_span_id,
542
+ name:, span_type: type, args:, kwargs:, test_run_id:, started_at: now,
543
+ span_data: {}
544
+ )
545
+ @configured_entries[entry.object_id] = entry
546
+ entry
547
+ end
548
+
549
+ def discard_configured(entry)
550
+ @configured_entries.delete(entry.object_id)
551
+ end
552
+
553
+ def emit_configured(entry, result:)
554
+ return unless discard_configured(entry)
555
+
556
+ emit(entry, result:)
420
557
  end
421
558
 
422
559
  def current_parent_span_id
@@ -465,6 +602,11 @@ module Bitfab
465
602
  private
466
603
 
467
604
  def finish_open_spans
605
+ @configured_entries.each_value do |entry|
606
+ entry.error ||= "Subtree capture ended before the call returned"
607
+ emit(entry, result: nil)
608
+ end
609
+ @configured_entries.clear
468
610
  @stacks.each_value do |stack|
469
611
  stack.reverse_each do |entry|
470
612
  next unless entry.is_a?(OpenSpan)
@@ -500,14 +642,10 @@ module Bitfab
500
642
  suppress(fiber_id)
501
643
  return
502
644
  end
503
- if @spans_used >= @max_spans
645
+ content_off = content_off?(metadata.name)
646
+ if (limit = limit_reached(discovered: true, content_off:))
504
647
  @truncated = true
505
- @exhausted = true
506
- if captured_calls_drained?
507
- @drained = true
508
- else
509
- suppress(fiber_id)
510
- end
648
+ stop_discovery(fiber_id) unless limit == :max_captured_subtree_spans && content_off_names?
511
649
  return
512
650
  end
513
651
 
@@ -518,9 +656,10 @@ module Bitfab
518
656
  return
519
657
  end
520
658
 
521
- @spans_used += 1
659
+ count_span(discovered: true, content_off:)
522
660
  span_id = SecureRandom.uuid
523
- args, kwargs = inputs(event)
661
+ without_content = content_off || plan_unavailable?
662
+ args, kwargs = without_content ? [[], {}] : inputs(event)
524
663
  # A nested subtree root temporarily owns the ambient span context, but
525
664
  # an outer session must never parent its spans across trace boundaries.
526
665
  active_parent_span_id = if active_parent&.dig(:trace_id) == @trace_id
@@ -534,7 +673,8 @@ module Bitfab
534
673
  name: metadata.name,
535
674
  args:,
536
675
  kwargs:,
537
- started_at: now
676
+ started_at: now,
677
+ span_data: without_content ? {"content_off_by_simulation_plan" => true} : nil
538
678
  )
539
679
  end
540
680
 
@@ -590,7 +730,9 @@ module Bitfab
590
730
  span_id: entry.span_id,
591
731
  parent_span_id: entry.parent_span_id,
592
732
  span_name: entry.name,
593
- span_type: "function",
733
+ span_type: entry.span_type || "function",
734
+ instrumentation: "trace",
735
+ span_data: entry.span_data || {},
594
736
  function_name: entry.name,
595
737
  contexts: nil,
596
738
  prompt: nil,
@@ -600,7 +742,7 @@ module Bitfab
600
742
  error: entry.error,
601
743
  started_at: entry.started_at,
602
744
  ended_at: now,
603
- test_run_id: @test_run_id,
745
+ test_run_id: entry.test_run_id || @test_run_id,
604
746
  input_source_span_id: @input_source_span_id
605
747
  )
606
748
  rescue Exception # rubocop:disable Lint/RescueException
@@ -739,14 +881,60 @@ module Bitfab
739
881
  @suppressed_depths[fiber_id] = 1
740
882
  end
741
883
 
884
+ def limit_reached(discovered:, content_off: false)
885
+ return :max_spans if @spans_used >= @max_spans
886
+
887
+ :max_captured_subtree_spans if discovered && !content_off && @captured_subtree_count >= @max_captured_subtree_spans
888
+ end
889
+
890
+ def take_slot(discovered:)
891
+ return false if limit_reached(discovered:)
892
+
893
+ count_span(discovered:, content_off: false)
894
+ true
895
+ end
896
+
897
+ def count_span(discovered:, content_off:)
898
+ @spans_used += 1
899
+ @captured_subtree_count += 1 if discovered && !content_off
900
+ end
901
+
902
+ def stop_discovery(fiber_id)
903
+ @exhausted = true
904
+ if captured_calls_drained?
905
+ @drained = true
906
+ else
907
+ suppress(fiber_id)
908
+ end
909
+ end
910
+
911
+ def content_off?(name)
912
+ @client.send(:content_off_by_simulation_plan?, trace_id: @trace_id, trace_function_key: @trace_function_key,
913
+ span_name: name, instrumentation: "trace")
914
+ rescue
915
+ false
916
+ end
917
+
918
+ def content_off_names?
919
+ @client.send(:simulation_plan_content_off_names?, trace_id: @trace_id, trace_function_key: @trace_function_key)
920
+ rescue
921
+ false
922
+ end
923
+
924
+ def plan_unavailable?
925
+ @client.send(:simulation_plan_unavailable?, instrumentation: "trace")
926
+ rescue
927
+ false
928
+ end
929
+
742
930
  def warn_if_truncated
743
931
  return unless @truncated
744
932
 
745
933
  Bitfab.warn_once(
746
934
  "subtree-truncated:#{@trace_function_key}",
747
935
  "'#{@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: [...]."
936
+ "max_captured_subtree_spans=#{@max_captured_subtree_spans}, max_spans=#{@max_spans}) and its trace is incomplete. " \
937
+ "Raise the limits, add bitfab_node to the calls that matter, or narrow what is traced with exclude: [...]."
750
938
  )
751
939
  end
752
940
 
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ module ThreadPropagation
5
+ ORIGIN_KEY = :__bitfab_submit_origin
6
+ SDK_PATH = File.expand_path(__dir__) + File::SEPARATOR
7
+ @installed = false
8
+ @mutex = Mutex.new
9
+
10
+ module_function
11
+
12
+ def install
13
+ @mutex.synchronize do
14
+ Thread.singleton_class.prepend(ThreadMethods) unless Thread.singleton_class.ancestors.include?(ThreadMethods)
15
+ @installed = true
16
+ end
17
+ begin
18
+ require "concurrent-ruby"
19
+ Concurrent::ThreadPoolExecutor.prepend(PoolMethods) unless Concurrent::ThreadPoolExecutor.ancestors.include?(PoolMethods)
20
+ rescue LoadError
21
+ nil
22
+ end
23
+ end
24
+
25
+ def installed?
26
+ @installed
27
+ end
28
+
29
+ def uninstall
30
+ @installed = false
31
+ end
32
+
33
+ def runtime
34
+ current = Thread.current
35
+ data = {"thread_id" => current.object_id, "thread_name" => current.name || ((current == Thread.main) ? "main" : "thread-#{current.object_id}")}
36
+ if (origin = current.thread_variable_get(ORIGIN_KEY))
37
+ data["parent_thread_id"] = origin["thread_id"]
38
+ data["submit_thread_id"] = origin["thread_id"]
39
+ data["submit_thread_name"] = origin["thread_name"]
40
+ end
41
+ data
42
+ end
43
+
44
+ def bind(block)
45
+ stack = SpanContext.stack.dup
46
+ stack << SpanContext.current if stack.empty? && SpanContext.current
47
+ replay = ReplayContext.current
48
+ seed = SeedContext.current
49
+ return block if stack.empty? && replay.nil? && seed.nil?
50
+
51
+ origin = runtime.slice("thread_id", "thread_name")
52
+ lambda do |*args, **kwargs|
53
+ current = Thread.current
54
+ previous_stack = current[SpanContext::STACK_KEY]
55
+ previous_bridge = current.thread_variable_get(SpanContext::FIBER_BRIDGE_STACK_KEY)
56
+ previous_replay = current.thread_variable_get(REPLAY_CONTEXT_KEY)
57
+ previous_seed = current.thread_variable_get(SeedContext::KEY)
58
+ previous_origin = current.thread_variable_get(ORIGIN_KEY)
59
+ local_replay = replay&.merge(span_stack: replay.fetch(:span_stack, []).dup)
60
+ current[SpanContext::STACK_KEY] = stack.dup
61
+ current.thread_variable_set(SpanContext::FIBER_BRIDGE_STACK_KEY, [])
62
+ current.thread_variable_set(REPLAY_CONTEXT_KEY, local_replay)
63
+ current.thread_variable_set(SeedContext::KEY, seed)
64
+ current.thread_variable_set(ORIGIN_KEY, origin)
65
+ begin
66
+ block.call(*args, **kwargs)
67
+ ensure
68
+ replay[:db_snapshot_accessed] = true if replay && local_replay[:db_snapshot_accessed]
69
+ current[SpanContext::STACK_KEY] = previous_stack
70
+ current.thread_variable_set(SpanContext::FIBER_BRIDGE_STACK_KEY, previous_bridge)
71
+ current.thread_variable_set(REPLAY_CONTEXT_KEY, previous_replay)
72
+ current.thread_variable_set(SeedContext::KEY, previous_seed)
73
+ current.thread_variable_set(ORIGIN_KEY, previous_origin)
74
+ end
75
+ end
76
+ end
77
+
78
+ def internal?(block)
79
+ path = block.source_location&.first
80
+ path && (path.start_with?(SDK_PATH) || path.include?("/opentelemetry") || path.include?("/concurrent-ruby"))
81
+ end
82
+
83
+ module ThreadMethods
84
+ %i[new start fork].each do |method_name|
85
+ define_method(method_name) do |*args, &block|
86
+ return super(*args, &block) if !ThreadPropagation.installed? || block.nil? || ThreadPropagation.internal?(block)
87
+
88
+ bound = begin
89
+ ThreadPropagation.bind(block)
90
+ rescue
91
+ block
92
+ end
93
+ super(*args, &bound)
94
+ end
95
+ end
96
+ end
97
+
98
+ module PoolMethods
99
+ def post(*args, &block)
100
+ return super unless ThreadPropagation.installed? && block
101
+
102
+ bound = begin
103
+ ThreadPropagation.bind(block)
104
+ rescue
105
+ block
106
+ end
107
+ super(*args, &bound)
108
+ end
109
+ end
110
+ end
111
+ end