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.
- checksums.yaml +4 -4
- data/README.md +4 -4
- data/exe/bitfab-seed +13 -0
- data/lib/bitfab/assertion_categories.rb +42 -0
- data/lib/bitfab/assertions.rb +35 -0
- data/lib/bitfab/baml.rb +208 -0
- data/lib/bitfab/client.rb +157 -50
- data/lib/bitfab/db_snapshot.rb +18 -5
- data/lib/bitfab/detached_trace.rb +46 -0
- data/lib/bitfab/graders.rb +23 -0
- data/lib/bitfab/http_client.rb +124 -35
- data/lib/bitfab/labels.rb +80 -0
- data/lib/bitfab/otel.rb +1 -1
- data/lib/bitfab/replay.rb +92 -23
- data/lib/bitfab/replay_cli.rb +7 -1
- data/lib/bitfab/replay_concurrency.rb +44 -0
- data/lib/bitfab/replay_memory.rb +111 -0
- data/lib/bitfab/replay_processes.rb +218 -0
- data/lib/bitfab/replay_registry.rb +100 -15
- data/lib/bitfab/seed.rb +128 -0
- data/lib/bitfab/seed_cli.rb +67 -0
- data/lib/bitfab/seed_context.rb +21 -0
- data/lib/bitfab/simulation_plan.rb +307 -0
- data/lib/bitfab/span_context.rb +9 -4
- data/lib/bitfab/span_origin.rb +13 -0
- data/lib/bitfab/subtree.rb +214 -26
- data/lib/bitfab/thread_propagation.rb +111 -0
- data/lib/bitfab/traceable.rb +85 -155
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +9 -2
- metadata +32 -1
data/lib/bitfab/seed.rb
ADDED
|
@@ -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,307 @@
|
|
|
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
|
+
result = plan.stop if stop
|
|
29
|
+
released = result && released
|
|
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
|
+
@first_read_finished = false
|
|
57
|
+
self.class.register(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def disabled?
|
|
61
|
+
!@enabled || !ENV.fetch("BITFAB_DISABLE_SIM_PLAN", "").strip.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def refresh
|
|
65
|
+
reset_after_fork
|
|
66
|
+
return if disabled? || @stopped
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize { start_reader(0) if @thread.nil? && now >= @refresh_after }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def send_span(payload, &submit)
|
|
72
|
+
refresh
|
|
73
|
+
data = payload.dig("rawSpan", "span_data")
|
|
74
|
+
key = payload["rootTraceFunctionKey"] || payload["traceFunctionKey"]
|
|
75
|
+
if disabled? || key.nil? || !data.is_a?(Hash) || framework?(payload)
|
|
76
|
+
submit.call(payload)
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
entry = [payload, key, submit]
|
|
80
|
+
ready = @mutex.synchronize { reading? ? hold(entry) : entry }
|
|
81
|
+
deliver(*ready) if ready
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def send_trace(payload, &submit)
|
|
85
|
+
refresh
|
|
86
|
+
entry = [payload, nil, submit]
|
|
87
|
+
ready = @mutex.synchronize do
|
|
88
|
+
id = trace_id(payload)
|
|
89
|
+
if !disabled? && !@stopped && id && (@draining.include?(id) || @held.any? { |held| held[1] && trace_id(held[0]) == id })
|
|
90
|
+
hold(entry)
|
|
91
|
+
else
|
|
92
|
+
entry
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
deliver(*ready) if ready
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def release(timeout)
|
|
99
|
+
refresh
|
|
100
|
+
deadline = now + [timeout, 0].max
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
while @thread && (!@held.empty? || !@draining.empty?)
|
|
103
|
+
remaining = deadline - now
|
|
104
|
+
break unless remaining.positive?
|
|
105
|
+
@condition.wait(@mutex, remaining)
|
|
106
|
+
end
|
|
107
|
+
if @content_off.nil? && !@held.empty?
|
|
108
|
+
Bitfab.warn_once("sim-plan-never-loaded", "#{@held.length} records remain held because the simulation plan has not loaded")
|
|
109
|
+
end
|
|
110
|
+
@held.empty? && @draining.empty?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def stop
|
|
115
|
+
reset_after_fork
|
|
116
|
+
queued = @mutex.synchronize do
|
|
117
|
+
@stopped = true
|
|
118
|
+
@condition.broadcast
|
|
119
|
+
entries, @held = @held, []
|
|
120
|
+
entries
|
|
121
|
+
end
|
|
122
|
+
queued.each { |entry| deliver_safely(*entry) }
|
|
123
|
+
@mutex.synchronize { @held.empty? && @draining.empty? }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def content_off?(key, name)
|
|
127
|
+
reset_after_fork
|
|
128
|
+
return false if disabled? || @stopped
|
|
129
|
+
|
|
130
|
+
@mutex.synchronize { @content_off&.fetch(key, nil)&.include?(name) == true }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def content_off_names?(key)
|
|
134
|
+
reset_after_fork
|
|
135
|
+
return false if disabled? || @stopped
|
|
136
|
+
|
|
137
|
+
@mutex.synchronize { @content_off&.fetch(key, nil)&.any? == true }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unavailable?
|
|
141
|
+
reset_after_fork
|
|
142
|
+
return false if disabled?
|
|
143
|
+
|
|
144
|
+
@mutex.synchronize { @content_off.nil? && !reading? }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def wait_for_first_read(timeout = READ_TIMEOUT)
|
|
148
|
+
reset_after_fork
|
|
149
|
+
return if disabled?
|
|
150
|
+
|
|
151
|
+
deadline = now + [timeout, 0].max
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
while reading? && @thread
|
|
154
|
+
remaining = deadline - now
|
|
155
|
+
break unless remaining.positive?
|
|
156
|
+
@condition.wait(@mutex, remaining)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def apply(payload, key)
|
|
162
|
+
return payload if framework?(payload)
|
|
163
|
+
|
|
164
|
+
names = @mutex.synchronize { @content_off&.fetch(key, nil) }
|
|
165
|
+
data = payload.dig("rawSpan", "span_data")
|
|
166
|
+
return payload unless data.is_a?(Hash) && names&.include?(data["name"])
|
|
167
|
+
|
|
168
|
+
without_content(payload, data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def now
|
|
174
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reading?
|
|
178
|
+
@content_off.nil? && !@first_read_finished && !@stopped
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def trace_id(payload)
|
|
182
|
+
payload["traceId"] || payload["sourceTraceId"] || payload.dig("externalTrace", "id") || payload["id"]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def framework?(payload)
|
|
186
|
+
FRAMEWORKS.include?(payload.dig("rawSpan", "span_origin", "instrumentation", "name"))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def root?(payload)
|
|
190
|
+
payload.dig("rawSpan", "parent_id").nil?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def hold(entry)
|
|
194
|
+
@held << entry
|
|
195
|
+
start_reader([@refresh_after - now, 0].max) if @thread.nil? && !@stopped && !disabled?
|
|
196
|
+
@held.shift if @held.length > MAX_HELD
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def deliver(payload, key, submit)
|
|
200
|
+
submit.call(prepare(payload, key))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def deliver_safely(payload, key, submit)
|
|
204
|
+
deliver(payload, key, submit)
|
|
205
|
+
rescue => error
|
|
206
|
+
Bitfab.warn_once("sim-plan-held-span-dropped", "Held record could not be sent: #{error}")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def prepare(payload, key)
|
|
210
|
+
return payload if key.nil? || disabled?
|
|
211
|
+
return apply(payload, key) if @mutex.synchronize { @content_off }
|
|
212
|
+
|
|
213
|
+
without_unread_content(payload)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def without_unread_content(payload)
|
|
217
|
+
data = payload.dig("rawSpan", "span_data")
|
|
218
|
+
return payload if !data.is_a?(Hash) || root?(payload) || framework?(payload)
|
|
219
|
+
|
|
220
|
+
Bitfab.warn_once("sim-plan-unread-content",
|
|
221
|
+
"Sending spans without inputs and outputs because the simulation plan could not be read")
|
|
222
|
+
without_content(payload, data)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def without_content(payload, data)
|
|
226
|
+
kept = data.except(*CONTENT_KEYS)
|
|
227
|
+
kept["content_off_by_simulation_plan"] = true
|
|
228
|
+
payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => kept))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def start_reader(delay)
|
|
232
|
+
@thread = Thread.new { read_until_loaded(delay) }
|
|
233
|
+
@thread.name = "bitfab-sim-plan"
|
|
234
|
+
rescue ThreadError
|
|
235
|
+
@first_read_finished = true
|
|
236
|
+
@refresh_after = now + RETRY_SECONDS
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def pause(delay)
|
|
240
|
+
@mutex.synchronize do
|
|
241
|
+
deadline = now + delay
|
|
242
|
+
while !@stopped && (remaining = deadline - now).positive?
|
|
243
|
+
@condition.wait(@mutex, remaining)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def read_once
|
|
249
|
+
body = @reader.get_simulation_plan
|
|
250
|
+
unless body.is_a?(Hash) && body["nodes"].is_a?(Array)
|
|
251
|
+
Bitfab.warn_once("sim-plan-unreadable", "Simulation plan response was not understood; sending spans without inputs and outputs until it loads")
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
names = {}
|
|
255
|
+
body["nodes"].each do |node|
|
|
256
|
+
next unless node.is_a?(Hash) && node["traceFunctionKey"].is_a?(String) && node["name"].is_a?(String) && node["captureContent"] == false
|
|
257
|
+
(names[node["traceFunctionKey"]] ||= Set.new).add(node["name"])
|
|
258
|
+
end
|
|
259
|
+
names
|
|
260
|
+
rescue => error
|
|
261
|
+
return {} if error.respond_to?(:response) && error.response&.code.to_i == 404
|
|
262
|
+
|
|
263
|
+
Bitfab.warn_once("sim-plan-unavailable", "Could not read simulation plan: #{error}; sending spans without inputs and outputs until it loads")
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def read_until_loaded(delay)
|
|
268
|
+
pause(delay) if delay.positive?
|
|
269
|
+
parsed = (disabled? || @stopped) ? nil : read_once
|
|
270
|
+
entries = @mutex.synchronize do
|
|
271
|
+
@first_read_finished = true
|
|
272
|
+
@content_off = parsed if parsed
|
|
273
|
+
@refresh_after = now + (parsed ? REFRESH_SECONDS : RETRY_SECONDS)
|
|
274
|
+
queued, @held = @held, []
|
|
275
|
+
@draining = queued.filter_map { |payload, key, _| trace_id(payload) if key }.to_set
|
|
276
|
+
@condition.broadcast
|
|
277
|
+
queued
|
|
278
|
+
end
|
|
279
|
+
until entries.empty?
|
|
280
|
+
entries.each { |entry| deliver_safely(*entry) }
|
|
281
|
+
entries = @mutex.synchronize do
|
|
282
|
+
queued, @held = @held, []
|
|
283
|
+
@draining.clear if queued.empty?
|
|
284
|
+
queued
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
ensure
|
|
288
|
+
@mutex.synchronize do
|
|
289
|
+
@draining.clear
|
|
290
|
+
@thread = nil
|
|
291
|
+
@first_read_finished = true
|
|
292
|
+
@condition.broadcast
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def reset_after_fork
|
|
297
|
+
return if @pid == Process.pid
|
|
298
|
+
|
|
299
|
+
@pid = Process.pid
|
|
300
|
+
@mutex = Mutex.new
|
|
301
|
+
@condition = ConditionVariable.new
|
|
302
|
+
@thread = nil
|
|
303
|
+
@held = []
|
|
304
|
+
@draining = Set.new
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
data/lib/bitfab/span_context.rb
CHANGED
|
@@ -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
|