bitfab 0.51.8 → 0.51.10

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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "seed_context"
5
+
6
+ module Bitfab
7
+ # Supplies the root span for an explicit-key callable used by seed or replay.
8
+ class ManagedCallable
9
+ def initialize(client, key, callable)
10
+ raise ArgumentError, "Expected a callable function." unless callable.respond_to?(:call)
11
+
12
+ @client = client
13
+ @key = key
14
+ @callable = callable
15
+ end
16
+
17
+ def call(*args, **kwargs)
18
+ @client.execute_span(trace_function_key: @key, span_name: @key, span_type: "function",
19
+ function_name: @key, args:, kwargs:, managed_root: true) do
20
+ value = @callable.call(*args, **kwargs)
21
+ value.is_a?(Enumerator) ? value.to_a : value
22
+ end
23
+ end
24
+ end
25
+
26
+ class Client
27
+ SEED_INPUT_UNSET = Object.new.freeze
28
+
29
+ # Record a case without executing code, or execute a callable once as a seeded trace.
30
+ # A case supplies input: (a positional argument Array), expected:, and optional fn:
31
+ # for signature validation. A run supplies a callable and args:/kwargs:.
32
+ def seed_trace(trace_function_key, callable = nil, input: SEED_INPUT_UNSET, expected: nil, fn: nil,
33
+ args: [], kwargs: {}, metadata: nil, session_id: nil, name: nil, span_name: nil, span_type: "function")
34
+ raise ArgumentError, "Seed trace function key cannot be empty." if trace_function_key.to_s.strip.empty?
35
+ raise ArgumentError, "Seeding requires an API key." unless resolve_api_key
36
+
37
+ case_seed = !input.equal?(SEED_INPUT_UNSET)
38
+ if case_seed
39
+ raise ArgumentError, "A seed case input must be an Array of positional arguments." unless input.is_a?(Array)
40
+ raise ArgumentError, "A seed case cannot also execute a callable." if callable
41
+ if fn
42
+ validate_seed_signature(fn, input)
43
+ key = fn.is_a?(Method) && Traceable.trace_function_key_for(fn.receiver, fn.name)
44
+ raise ArgumentError, "Seed callable is traced under '#{key}', not '#{trace_function_key}'." if key && key != trace_function_key
45
+ end
46
+ args = input
47
+ kwargs = {}
48
+ callable = ->(*) { expected }
49
+ end
50
+ raise ArgumentError, "Seed requires a callable or input:." unless callable.respond_to?(:call)
51
+ declared_key = callable.is_a?(Method) && Traceable.trace_function_key_for(callable.receiver, callable.name)
52
+ if declared_key && declared_key != trace_function_key
53
+ raise ArgumentError, "Seed callable is traced under '#{declared_key}', not '#{trace_function_key}'."
54
+ end
55
+
56
+ trace_id = SecureRandom.uuid
57
+ state = TraceState.create(trace_id)
58
+ state.delete(:db_snapshot_ref)
59
+ state[:ingestion_type] = "seeded"
60
+ state[:trace_function_key] = trace_function_key
61
+ state[:metadata] = metadata.dup if metadata
62
+ state[:session_id] = session_id if session_id
63
+ state[:name] = name if name
64
+ previous_stack = Thread.current[SpanContext::STACK_KEY]
65
+ previous_bridge = Thread.current.thread_variable_get(SpanContext::FIBER_BRIDGE_STACK_KEY)
66
+ previous_replay = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
67
+ Thread.current[SpanContext::STACK_KEY] = []
68
+ Thread.current.thread_variable_set(SpanContext::FIBER_BRIDGE_STACK_KEY, [])
69
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, nil)
70
+ SeedContext.with_context(trace_id:) do
71
+ if declared_key
72
+ value = callable.call(*args, **kwargs)
73
+ value.to_a if value.is_a?(Enumerator)
74
+ else
75
+ execute_span(trace_function_key:, span_name: span_name || trace_function_key,
76
+ span_type:, function_name: trace_function_key, args:, kwargs:, managed_root: true) do
77
+ value = callable.call(*args, **kwargs)
78
+ value.is_a?(Enumerator) ? value.to_a : value
79
+ end
80
+ end
81
+ end
82
+ raise "Seed execution did not record a root trace." if TraceState.get(trace_id)
83
+
84
+ trace_id
85
+ ensure
86
+ if trace_id
87
+ flush
88
+ TraceState.delete(trace_id)
89
+ Thread.current[SpanContext::STACK_KEY] = previous_stack
90
+ Thread.current.thread_variable_set(SpanContext::FIBER_BRIDGE_STACK_KEY, previous_bridge)
91
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, previous_replay)
92
+ end
93
+ end
94
+
95
+ # Execute a stored case again, then adopt the successful run under its existing trace ID.
96
+ def reseed_trace(trace_function_key, callable, trace_id:)
97
+ endpoint = "/api/sdk/traces/#{URI.encode_www_form_component(trace_id)}/reseed"
98
+ source = @http_client.get("#{endpoint}Source")
99
+ unless source.fetch("traceFunctionKey") == trace_function_key
100
+ raise ArgumentError, "Trace #{trace_id} belongs to '#{source["traceFunctionKey"]}', not '#{trace_function_key}'."
101
+ end
102
+ args, kwargs = Serialize.deserialize_inputs({"input" => source["input"], "input_serialized" => source["inputSerialized"]})
103
+ run_trace_id = seed_trace(trace_function_key, callable, args:, kwargs:,
104
+ metadata: source["metadata"], session_id: source["sessionId"], name: source["name"])
105
+ adopted = @http_client.request(endpoint, {runTraceId: run_trace_id})
106
+ {trace_id: adopted.fetch("traceId"), previous_run_trace_id: adopted["previousRunTraceId"]}
107
+ end
108
+
109
+ private
110
+
111
+ def validate_seed_signature(callable, args)
112
+ raise ArgumentError, "Seed fn must be callable." unless callable.respond_to?(:call)
113
+
114
+ if callable.is_a?(Method)
115
+ while callable.owner.instance_variable_defined?(:@bitfab_span_method) && callable.super_method
116
+ callable = callable.super_method
117
+ end
118
+ end
119
+ parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
120
+ required = parameters.count { |kind, _| kind == :req }
121
+ maximum = parameters.count { |kind, _| [:req, :opt].include?(kind) }
122
+ variadic = parameters.any? { |kind, _| kind == :rest }
123
+ if args.length < required || (!variadic && args.length > maximum) || parameters.any? { |kind, _| kind == :keyreq }
124
+ raise ArgumentError, "Seed input does not match the callable's positional arguments."
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ module SeedCli
5
+ module_function
6
+
7
+ def parse_cases(raw, path, run)
8
+ text = raw.strip
9
+ raise ArgumentError, "Seed file '#{path}' is empty." if text.empty?
10
+
11
+ cases = text.start_with?("[") ? JSON.parse(text) : text.lines.reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) }
12
+ cases.each_with_index do |value, index|
13
+ unless value.is_a?(Hash) && value["input"].is_a?(Array)
14
+ raise ArgumentError, "Seed case #{index} must be an object with an input array."
15
+ end
16
+ if run && value.key?("expected")
17
+ raise ArgumentError, "Seed case #{index} carries expected, which --run does not record. Remove expected or drop --run."
18
+ end
19
+ end
20
+ cases
21
+ end
22
+
23
+ def seed_from_registry(registry, pipeline, cases, run: false)
24
+ entry = registry.fetch(pipeline)
25
+ callable = entry.method_name ? entry.receiver.method(entry.method_name) : entry.receiver
26
+ trace_ids = cases.map do |value|
27
+ options = {metadata: value["metadata"], session_id: value["sessionId"], name: value["name"]}
28
+ if run
29
+ raise ArgumentError, "Run-based seed cases cannot carry expected." if value.key?("expected")
30
+
31
+ entry.client.seed_trace(entry.trace_function_key, callable, args: value.fetch("input"), **options)
32
+ else
33
+ entry.client.seed_trace(entry.trace_function_key, input: value.fetch("input"), expected: value["expected"], fn: callable, **options)
34
+ end
35
+ end
36
+ {pipeline:, traceFunctionKey: entry.trace_function_key, traceIds: trace_ids}
37
+ end
38
+
39
+ def reseed_from_registry(registry, pipeline, trace_ids)
40
+ entry = registry.fetch(pipeline)
41
+ callable = entry.method_name ? entry.receiver.method(entry.method_name) : entry.receiver
42
+ reseeded = trace_ids.map do |trace_id|
43
+ value = entry.client.reseed_trace(entry.trace_function_key, callable, trace_id:)
44
+ {traceId: value[:trace_id], previousRunTraceId: value[:previous_run_trace_id]}
45
+ end
46
+ {pipeline:, traceFunctionKey: entry.trace_function_key, reseeded:}
47
+ end
48
+
49
+ def run(registry, argv: ARGV, stdout: $stdout, stderr: $stderr)
50
+ args = ReplayCli.parse(registry, argv.dup)
51
+ if (!!args[:seed] == !!args[:from_trace]) || (args[:from_trace] && args[:run])
52
+ raise ArgumentError, "Supply --cases PATH [--run] or --from-trace IDS."
53
+ end
54
+ pipeline = args.fetch(:pipeline)
55
+ result = if args[:from_trace]
56
+ stderr.puts "[seed] Re-seeding #{args[:from_trace].length} trace(s) through #{pipeline}..."
57
+ reseed_from_registry(registry, pipeline, args[:from_trace])
58
+ else
59
+ cases = parse_cases(File.read(args[:seed]), args[:seed], args[:run])
60
+ stderr.puts "[seed] Recording #{cases.length} case(s) through #{pipeline}..."
61
+ seed_from_registry(registry, pipeline, cases, run: !!args[:run])
62
+ end
63
+ stdout.puts JSON.generate(result)
64
+ result
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ module SeedContext
5
+ KEY = :bitfab_seed_context
6
+
7
+ module_function
8
+
9
+ def current
10
+ Thread.current.thread_variable_get(KEY)
11
+ end
12
+
13
+ def with_context(trace_id:)
14
+ previous = current
15
+ Thread.current.thread_variable_set(KEY, {trace_id:})
16
+ yield
17
+ ensure
18
+ Thread.current.thread_variable_set(KEY, previous)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ class SimulationPlan
5
+ CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized].freeze
6
+ FRAMEWORKS = %w[openai-agents langgraph claude-agent-sdk vercel-ai].freeze
7
+ READ_TIMEOUT = 5.0
8
+ REFRESH_SECONDS = 60.0
9
+ RETRY_SECONDS = 10.0
10
+ MAX_HELD = 1000
11
+ @plans = ObjectSpace::WeakMap.new
12
+ @plans_mutex = Mutex.new
13
+ @plans_pid = Process.pid
14
+
15
+ class << self
16
+ def register(plan)
17
+ reset_registry_after_fork
18
+ @plans_mutex.synchronize { @plans[plan] = true }
19
+ end
20
+
21
+ def release_all(timeout, stop: false)
22
+ reset_registry_after_fork
23
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [timeout, 0].max
24
+ plans = @plans_mutex.synchronize { @plans.keys }
25
+ released = true
26
+ plans.each do |plan|
27
+ result = plan.release([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
28
+ released = result && released
29
+ plan.stop if stop
30
+ end
31
+ released
32
+ end
33
+
34
+ private
35
+
36
+ def reset_registry_after_fork
37
+ return if @plans_pid == Process.pid
38
+
39
+ @plans_pid = Process.pid
40
+ @plans_mutex = Mutex.new
41
+ end
42
+ end
43
+
44
+ def initialize(reader, enabled: true)
45
+ @reader = reader
46
+ @enabled = enabled
47
+ @pid = Process.pid
48
+ @mutex = Mutex.new
49
+ @condition = ConditionVariable.new
50
+ @content_off = nil
51
+ @refresh_after = 0.0
52
+ @thread = nil
53
+ @held = []
54
+ @draining = Set.new
55
+ @stopped = false
56
+ self.class.register(self)
57
+ end
58
+
59
+ def disabled?
60
+ !@enabled || !ENV.fetch("BITFAB_DISABLE_SIM_PLAN", "").strip.empty?
61
+ end
62
+
63
+ def refresh
64
+ reset_after_fork
65
+ return if disabled? || @stopped
66
+
67
+ @mutex.synchronize { start_reader(0) if @thread.nil? && now >= @refresh_after }
68
+ end
69
+
70
+ def send_span(payload, &submit)
71
+ refresh
72
+ data = payload.dig("rawSpan", "span_data")
73
+ key = payload["rootTraceFunctionKey"] || payload["traceFunctionKey"]
74
+ if disabled? || @stopped || key.nil? || !data.is_a?(Hash) || framework?(payload)
75
+ submit.call(payload)
76
+ return
77
+ end
78
+ held = @mutex.synchronize do
79
+ if @content_off.nil?
80
+ hold([payload, key, submit])
81
+ true
82
+ end
83
+ end
84
+ submit.call(apply(payload, key)) unless held
85
+ end
86
+
87
+ def send_trace(payload, &submit)
88
+ refresh
89
+ held = @mutex.synchronize do
90
+ id = trace_id(payload)
91
+ if !disabled? && !@stopped && id && (@draining.include?(id) || @held.any? { |entry| entry[1] && trace_id(entry[0]) == id })
92
+ hold([payload, nil, submit])
93
+ true
94
+ end
95
+ end
96
+ submit.call(payload) unless held
97
+ end
98
+
99
+ def release(timeout)
100
+ refresh
101
+ deadline = now + [timeout, 0].max
102
+ @mutex.synchronize do
103
+ while @thread && (!@held.empty? || !@draining.empty?)
104
+ remaining = deadline - now
105
+ break unless remaining.positive?
106
+ @condition.wait(@mutex, remaining)
107
+ end
108
+ if @content_off.nil? && !@held.empty?
109
+ Bitfab.warn_once("sim-plan-never-loaded", "#{@held.length} records remain held because the simulation plan has not loaded")
110
+ end
111
+ @held.empty? && @draining.empty?
112
+ end
113
+ end
114
+
115
+ def stop
116
+ reset_after_fork
117
+ @mutex.synchronize do
118
+ @stopped = true
119
+ @held.clear
120
+ @condition.broadcast
121
+ end
122
+ end
123
+
124
+ def apply(payload, key)
125
+ return payload if framework?(payload)
126
+
127
+ names = @mutex.synchronize { @content_off&.fetch(key, nil) }
128
+ data = payload.dig("rawSpan", "span_data")
129
+ return payload unless data.is_a?(Hash) && names&.include?(data["name"])
130
+
131
+ kept = data.except(*CONTENT_KEYS)
132
+ kept["content_off_by_simulation_plan"] = true
133
+ payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => kept))
134
+ end
135
+
136
+ private
137
+
138
+ def now
139
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ end
141
+
142
+ def trace_id(payload)
143
+ payload["traceId"] || payload["sourceTraceId"] || payload.dig("externalTrace", "id") || payload["id"]
144
+ end
145
+
146
+ def framework?(payload)
147
+ FRAMEWORKS.include?(payload.dig("rawSpan", "span_origin", "instrumentation", "name"))
148
+ end
149
+
150
+ def hold(entry)
151
+ @held << entry
152
+ if @held.length > MAX_HELD
153
+ @held.shift
154
+ Bitfab.warn_once("sim-plan-held-overflow", "Simulation plan has not loaded; dropping oldest records beyond #{MAX_HELD}")
155
+ end
156
+ start_reader([@refresh_after - now, 0].max) if @thread.nil? && !@stopped && !disabled?
157
+ end
158
+
159
+ def start_reader(delay)
160
+ @thread = Thread.new { read_until_loaded(delay) }
161
+ @thread.name = "bitfab-sim-plan"
162
+ rescue ThreadError
163
+ @refresh_after = now + RETRY_SECONDS
164
+ end
165
+
166
+ def pause(delay)
167
+ @mutex.synchronize do
168
+ deadline = now + delay
169
+ while !@stopped && (remaining = deadline - now).positive?
170
+ @condition.wait(@mutex, remaining)
171
+ end
172
+ end
173
+ end
174
+
175
+ def read_once
176
+ body = @reader.get_simulation_plan
177
+ unless body.is_a?(Hash) && body["nodes"].is_a?(Array)
178
+ Bitfab.warn_once("sim-plan-unreadable", "Simulation plan response was not understood; holding content until it loads")
179
+ return
180
+ end
181
+ names = {}
182
+ body["nodes"].each do |node|
183
+ next unless node.is_a?(Hash) && node["traceFunctionKey"].is_a?(String) && node["name"].is_a?(String) && node["captureContent"] == false
184
+ (names[node["traceFunctionKey"]] ||= Set.new).add(node["name"])
185
+ end
186
+ names
187
+ rescue => error
188
+ return {} if error.respond_to?(:response) && error.response&.code.to_i == 404
189
+
190
+ Bitfab.warn_once("sim-plan-unavailable", "Could not read simulation plan: #{error}; holding content until it loads")
191
+ nil
192
+ end
193
+
194
+ def read_until_loaded(delay)
195
+ pause(delay) if delay.positive?
196
+ loop do
197
+ parsed = (disabled? || @stopped) ? nil : read_once
198
+ finished, entries = @mutex.synchronize do
199
+ if parsed
200
+ @content_off = parsed
201
+ @refresh_after = now + REFRESH_SECONDS
202
+ queued, @held = @held, []
203
+ @draining = queued.filter_map { |payload, key, _| trace_id(payload) if key }.to_set
204
+ [true, queued]
205
+ elsif @content_off || disabled? || @stopped || @held.empty?
206
+ @refresh_after = now + RETRY_SECONDS
207
+ [true, []]
208
+ else
209
+ [false, []]
210
+ end
211
+ end
212
+ if finished
213
+ until entries.empty?
214
+ entries.each do |payload, key, submit|
215
+ submit.call(key ? apply(payload, key) : payload)
216
+ rescue => error
217
+ Bitfab.warn_once("sim-plan-held-span-dropped", "Held record could not be sent: #{error}")
218
+ end
219
+ entries = @mutex.synchronize do
220
+ queued, @held = @held, []
221
+ @draining.clear if queued.empty?
222
+ queued
223
+ end
224
+ end
225
+ return
226
+ end
227
+ pause(RETRY_SECONDS)
228
+ end
229
+ ensure
230
+ @mutex.synchronize do
231
+ @draining.clear
232
+ @thread = nil
233
+ @condition.broadcast
234
+ end
235
+ end
236
+
237
+ def reset_after_fork
238
+ return if @pid == Process.pid
239
+
240
+ @pid = Process.pid
241
+ @mutex = Mutex.new
242
+ @condition = ConditionVariable.new
243
+ @thread = nil
244
+ @held = []
245
+ @draining = Set.new
246
+ end
247
+ end
248
+ end
@@ -139,8 +139,9 @@ module Bitfab
139
139
  # Enumerator.new and enum_for run their source body in another Fiber on
140
140
  # the same thread. Bridge only the active Enumerator parent across that
141
141
  # boundary; ordinary fiber-local span stacks remain isolated.
142
- def with_fiber_bridge(trace_id:, span_id:)
142
+ def with_fiber_bridge(trace_id:, span_id:, surface: nil)
143
143
  entry = {trace_id:, span_id:}
144
+ entry[:surface] = surface if surface
144
145
  fiber_bridge_stack.push(entry)
145
146
  yield
146
147
  ensure
@@ -149,8 +150,10 @@ module Bitfab
149
150
 
150
151
  # Execute a block with a new span pushed onto the stack.
151
152
  # The span is automatically popped when the block completes.
152
- def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil)
153
+ def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil, surface: nil, recording_override: nil)
153
154
  entry = {trace_id:, span_id:}
155
+ entry[:surface] = surface if surface
156
+ entry[:recording_override] = recording_override if recording_override
154
157
  if explicit_span_receiver
155
158
  entry[:explicit_span_receiver] = explicit_span_receiver
156
159
  entry[:explicit_span_method_name] = explicit_span_method_name
@@ -182,20 +185,22 @@ module Bitfab
182
185
  @states_mutex.synchronize { @states[trace_id] }
183
186
  end
184
187
 
185
- def create(trace_id, test_run_id: nil, input_source_trace_id: nil)
188
+ def create(trace_id, test_run_id: nil, input_source_trace_id: nil, replay_attempt: nil, trace_function_key: nil, db_snapshot: nil)
186
189
  @states_mutex.synchronize do
187
190
  @states[trace_id] ||= begin
188
191
  started_at = Bitfab.now_iso_timestamp
189
192
  {
190
193
  trace_id:,
194
+ trace_function_key:,
191
195
  started_at:,
192
196
  test_run_id:,
193
197
  input_source_trace_id:,
198
+ replay_attempt:,
194
199
  # Capture the wall clock now, before the wrapped function runs.
195
200
  # Stored on every trace (no IO, harmless) so any trace can later be
196
201
  # replayed against a historical branch; the provider is resolved at
197
202
  # replay time.
198
- db_snapshot_ref: DbSnapshot.build_snapshot_ref(started_at)
203
+ db_snapshot_ref: DbSnapshot.build_snapshot_ref(started_at, config: db_snapshot)
199
204
  }.compact
200
205
  end
201
206
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module Bitfab
6
+ def self.make_span_origin(instrumentation)
7
+ {
8
+ "name" => "bitfab.sdk.ruby",
9
+ "version" => VERSION,
10
+ "instrumentation" => {"name" => instrumentation}
11
+ }
12
+ end
13
+ end