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.
@@ -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
@@ -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