bitfab 0.9.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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ require_relative "constants"
8
+ require_relative "version"
9
+
10
+ module Bitfab
11
+ class HttpClient
12
+ attr_reader :service_url
13
+
14
+ def initialize(api_key:, service_url: nil, timeout: 120)
15
+ @api_key = api_key
16
+ @service_url = (service_url || DEFAULT_SERVICE_URL).chomp("/")
17
+ @timeout = timeout
18
+ end
19
+
20
+ # Make a POST request to the Bitfab API.
21
+ # Returns parsed JSON response hash.
22
+ def request(endpoint, payload, timeout: nil, max_retries: 1, retry_delay: 0.1)
23
+ uri = URI("#{@service_url}#{endpoint}")
24
+ request_timeout = timeout || @timeout
25
+
26
+ last_error = nil
27
+
28
+ max_retries.times do |attempt|
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = request_timeout
32
+ http.read_timeout = request_timeout
33
+
34
+ req = Net::HTTP::Post.new(uri.path, headers)
35
+ req.body = JSON.generate(payload)
36
+
37
+ response = http.request(req)
38
+
39
+ unless response.is_a?(Net::HTTPSuccess)
40
+ raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
41
+ end
42
+
43
+ result = JSON.parse(response.body)
44
+
45
+ if result["error"]
46
+ msg = result["error"]
47
+ msg = "#{msg} Configure it at: #{@service_url}#{result["url"]}" if result["url"]
48
+ raise StandardError, msg
49
+ end
50
+
51
+ return result
52
+ rescue => e
53
+ last_error = e
54
+ sleep(retry_delay) if attempt < max_retries - 1
55
+ end
56
+
57
+ raise last_error
58
+ end
59
+
60
+ # Send an external span in a background thread.
61
+ # Returns the thread for callers that need to await completion.
62
+ def send_external_span(payload)
63
+ merged = payload.merge("sdkVersion" => VERSION)
64
+
65
+ Bitfab._run_in_background do
66
+ request("/api/sdk/externalSpans", merged, timeout: 30)
67
+ end
68
+ end
69
+
70
+ # Make a GET request to the Bitfab API.
71
+ # Returns parsed JSON response hash.
72
+ def get(endpoint, timeout: nil)
73
+ uri = URI("#{@service_url}#{endpoint}")
74
+ request_timeout = timeout || @timeout
75
+
76
+ http = Net::HTTP.new(uri.host, uri.port)
77
+ http.use_ssl = uri.scheme == "https"
78
+ http.open_timeout = request_timeout
79
+ http.read_timeout = request_timeout
80
+
81
+ req = Net::HTTP::Get.new(uri.path, headers)
82
+ response = http.request(req)
83
+
84
+ unless response.is_a?(Net::HTTPSuccess)
85
+ raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
86
+ end
87
+
88
+ result = JSON.parse(response.body)
89
+
90
+ if result["error"]
91
+ msg = result["error"]
92
+ msg = "#{msg} Configure it at: #{@service_url}#{result["url"]}" if result["url"]
93
+ raise StandardError, msg
94
+ end
95
+
96
+ result
97
+ end
98
+
99
+ # Start a replay session by fetching historical traces.
100
+ # Blocking call. Returns hash with testRunId, testRunUrl, and items array.
101
+ def start_replay(trace_function_key, limit, trace_ids: nil)
102
+ payload = {
103
+ "traceFunctionKey" => trace_function_key,
104
+ "limit" => limit
105
+ }
106
+ payload["traceIds"] = trace_ids if trace_ids
107
+
108
+ request("/api/sdk/replay/start", payload, timeout: 30)
109
+ end
110
+
111
+ # Fetch an external span by ID. Blocking GET request.
112
+ def get_external_span(span_id)
113
+ get("/api/sdk/externalSpans/#{span_id}", timeout: 30)
114
+ end
115
+
116
+ # Mark a replay test run as completed. Blocking call.
117
+ def complete_replay(test_run_id)
118
+ request("/api/sdk/replay/complete", {"testRunId" => test_run_id}, timeout: 30)
119
+ end
120
+
121
+ # Send an external trace (fire-and-forget in background thread).
122
+ def send_external_trace(payload)
123
+ merged = payload.merge("sdkVersion" => VERSION)
124
+
125
+ Bitfab._run_in_background do
126
+ request("/api/sdk/externalTraces", merged, timeout: 10)
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def headers
133
+ {
134
+ "Content-Type" => "application/json",
135
+ "Authorization" => "Bearer #{@api_key}"
136
+ }
137
+ end
138
+ end
139
+
140
+ # --- Background thread management ---
141
+
142
+ @pending_threads_mutex = Mutex.new
143
+ @pending_threads = []
144
+
145
+ class << self
146
+ # Run a block in a background thread with tracking.
147
+ # Returns the thread for callers that need to join on it.
148
+ def _run_in_background(&block)
149
+ thread = Thread.new do
150
+ block.call
151
+ rescue => e
152
+ begin
153
+ warn "Bitfab: Failed to send request: #{e.message}"
154
+ rescue
155
+ # Never crash the host app
156
+ end
157
+ ensure
158
+ @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
159
+ end
160
+
161
+ @pending_threads_mutex.synchronize { @pending_threads << thread }
162
+ thread
163
+ end
164
+
165
+ # Wait for all pending background threads to complete.
166
+ def flush_traces(timeout: 30)
167
+ threads = @pending_threads_mutex.synchronize { @pending_threads.dup }
168
+ threads.each { |t| t.join(timeout) }
169
+ end
170
+ end
171
+
172
+ at_exit do
173
+ Bitfab.flush_traces(timeout: 2)
174
+ end
175
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "serialize"
5
+
6
+ module Bitfab
7
+ # Thread-local replay context management.
8
+ module ReplayContext
9
+ module_function
10
+
11
+ def current
12
+ Thread.current[REPLAY_CONTEXT_KEY]
13
+ end
14
+
15
+ # Execute a block with replay context set on the current thread.
16
+ # The context is automatically cleared when the block completes.
17
+ def with_context(test_run_id:, input_source_span_id: nil)
18
+ previous = Thread.current[REPLAY_CONTEXT_KEY]
19
+ Thread.current[REPLAY_CONTEXT_KEY] = {
20
+ test_run_id:,
21
+ input_source_span_id:
22
+ }
23
+ yield
24
+ ensure
25
+ Thread.current[REPLAY_CONTEXT_KEY] = previous
26
+ end
27
+ end
28
+
29
+ # Replay historical traces through a traced method and create a test run.
30
+ module Replay
31
+ module_function
32
+
33
+ # Replay historical traces through a method and create a test run.
34
+ #
35
+ # Fetches the last N traces for the given trace function key, re-runs each
36
+ # through the provided receiver and method, and returns comparison data.
37
+ #
38
+ # @param client [Bitfab::Client] the Bitfab client instance
39
+ # @param receiver [Object, Class] an instance for instance methods, or a Class for class methods
40
+ # @param method_name [Symbol] the method to replay
41
+ # @param trace_function_key [String] the trace function key for this method
42
+ # @param limit [Integer] maximum number of traces to replay (default: 5)
43
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
44
+ # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
45
+ # @return [Hash] with :items, :test_run_id, :test_run_url
46
+ def run(client, receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10)
47
+ http_client = client.instance_variable_get(:@http_client)
48
+
49
+ replay_data = http_client.start_replay(trace_function_key, limit, trace_ids:)
50
+ test_run_id = replay_data["testRunId"]
51
+ test_run_url = replay_data["testRunUrl"]
52
+ server_items = replay_data["items"] || []
53
+
54
+ result_items = if server_items.any?
55
+ process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
56
+ else
57
+ []
58
+ end
59
+
60
+ Bitfab.flush_traces
61
+
62
+ begin
63
+ http_client.complete_replay(test_run_id)
64
+ rescue => e
65
+ warn "Bitfab: Failed to complete replay: #{e.message}"
66
+ end
67
+
68
+ {
69
+ items: result_items,
70
+ test_run_id:,
71
+ test_run_url: "#{client.service_url}#{test_run_url}"
72
+ }
73
+ end
74
+
75
+ # Process all replay items, optionally in parallel using threads.
76
+ def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
77
+ concurrency = max_concurrency || server_items.length
78
+
79
+ if concurrency <= 1
80
+ server_items.map { |item| process_single_item(http_client, item, receiver, method_name, test_run_id) }
81
+ else
82
+ results_mutex = Mutex.new
83
+ results = []
84
+ work_queue = server_items.each_with_index.to_a
85
+ work_mutex = Mutex.new
86
+
87
+ workers = [concurrency, server_items.length].min.times.map do
88
+ Thread.new do
89
+ loop do
90
+ item, idx = work_mutex.synchronize { work_queue.shift }
91
+ break unless item
92
+
93
+ result = process_single_item(http_client, item, receiver, method_name, test_run_id)
94
+ results_mutex.synchronize { results[idx] = result }
95
+ end
96
+ end
97
+ end
98
+
99
+ workers.each(&:join)
100
+ results.compact
101
+ end
102
+ end
103
+
104
+ # Fetch span data and execute a single replay item.
105
+ def process_single_item(http_client, server_item, receiver, method_name, test_run_id)
106
+ span = http_client.get_external_span(server_item["externalSpanId"])
107
+ item_data = extract_span_data(span)
108
+ execute_item(item_data, receiver, method_name, test_run_id, span["id"])
109
+ end
110
+
111
+ # Extract input/output data from an external span's rawData.
112
+ def extract_span_data(span)
113
+ raw_data = span["rawData"] || {}
114
+ span_data = raw_data["span_data"] || {}
115
+
116
+ {
117
+ "input" => span_data["input"],
118
+ "output" => span_data["output"],
119
+ "inputSerialized" => span_data["input_serialized"],
120
+ "outputSerialized" => span_data["output_serialized"]
121
+ }
122
+ end
123
+
124
+ # Execute a single replay item: deserialize inputs, call method with replay context.
125
+ def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil)
126
+ args, kwargs = Serialize.deserialize_inputs(item)
127
+
128
+ fn_result = nil
129
+ fn_error = nil
130
+
131
+ ReplayContext.with_context(test_run_id:, input_source_span_id:) do
132
+ fn_result = if kwargs.empty?
133
+ receiver.send(method_name, *args)
134
+ else
135
+ receiver.send(method_name, *args, **kwargs)
136
+ end
137
+ rescue => e
138
+ fn_error = e.message
139
+ end
140
+
141
+ {
142
+ input: args,
143
+ result: fn_result,
144
+ original_output: item["output"],
145
+ error: fn_error
146
+ }
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Bitfab
8
+ module Serialize
9
+ module_function
10
+
11
+ # Serialize a value for JSON storage (human-readable).
12
+ # Handles primitives, hashes, arrays, and objects with common conversion methods.
13
+ # Note: We intentionally avoid as_json here because it requires ActiveSupport,
14
+ # and we want to keep the SDK dependency-free (stdlib only).
15
+ def serialize_value(value)
16
+ case value
17
+ when nil, true, false, Integer, Float, String
18
+ value
19
+ when Hash
20
+ value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
21
+ when Array
22
+ value.map { |v| serialize_value(v) }
23
+ when Set
24
+ value.map { |v| serialize_value(v) }
25
+ when Time, DateTime
26
+ value.iso8601(3)
27
+ when Date
28
+ value.to_s
29
+ when Symbol
30
+ value.to_s
31
+ else
32
+ if value.respond_to?(:to_h)
33
+ serialize_value(value.to_h)
34
+ elsif value.respond_to?(:to_a)
35
+ serialize_value(value.to_a)
36
+ else
37
+ value.to_s
38
+ end
39
+ end
40
+ end
41
+
42
+ # Serialize function inputs (args + kwargs) for span data (human-readable).
43
+ def serialize_inputs(args, kwargs = {})
44
+ serialized = args.map { |arg| serialize_value(arg) }
45
+ serialized << kwargs.transform_keys(&:to_s).transform_values { |v| serialize_value(v) } unless kwargs.empty?
46
+ serialized
47
+ end
48
+
49
+ # Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction.
50
+ # Handles arbitrary Ruby objects including custom classes.
51
+ #
52
+ # @param value [Object] any Ruby value
53
+ # @return [String, nil] Base64-encoded Marshal dump, or nil if marshalling fails
54
+ def marshal_value(value)
55
+ Base64.strict_encode64(Marshal.dump(value))
56
+ rescue TypeError, ArgumentError
57
+ # Some objects (Proc, IO, etc.) can't be marshalled
58
+ nil
59
+ end
60
+
61
+ # Unmarshal a Base64-encoded string back into a Ruby object.
62
+ #
63
+ # @param encoded [String] Base64-encoded Marshal dump
64
+ # @return [Object] the reconstructed Ruby object
65
+ def unmarshal_value(encoded)
66
+ Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
67
+ end
68
+
69
+ # Deserialize replay inputs from a span's data into [args, kwargs].
70
+ #
71
+ # Prefers Marshal-serialized `inputSerialized` for type preservation,
72
+ # falls back to the raw `input` field.
73
+ #
74
+ # @param item [Hash] with "inputSerialized" and/or "input" keys
75
+ # @return [Array(Array, Hash)] positional args and keyword args
76
+ def deserialize_inputs(item)
77
+ input_serialized = item["inputSerialized"]
78
+ raw_input = item["input"]
79
+
80
+ if input_serialized.is_a?(String) && !input_serialized.empty?
81
+ begin
82
+ deserialized = unmarshal_value(input_serialized)
83
+ if deserialized.is_a?(Hash) && (deserialized.key?(:args) || deserialized.key?(:kwargs))
84
+ return [deserialized[:args] || [], deserialized[:kwargs] || {}]
85
+ end
86
+ return deserialized.nil? ? [[], {}] : [[deserialized], {}]
87
+ rescue
88
+ # Fall through to raw_input
89
+ end
90
+ end
91
+
92
+ if raw_input.is_a?(Array)
93
+ [raw_input, {}]
94
+ elsif raw_input.is_a?(Hash)
95
+ [[], raw_input.transform_keys(&:to_sym)]
96
+ elsif raw_input.nil?
97
+ [[], {}]
98
+ else
99
+ [[raw_input], {}]
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Handle to the current active span, allowing context to be added.
5
+ class CurrentSpan
6
+ def initialize(span_state)
7
+ @span_state = span_state
8
+ end
9
+
10
+ # The trace ID for the current span.
11
+ def trace_id
12
+ @span_state[:trace_id]
13
+ end
14
+
15
+ # Add a context entry to this span.
16
+ # The entire hash is pushed as a single entry in the contexts array.
17
+ # Context entries are accumulated - multiple calls add to the list.
18
+ #
19
+ # @param context [Hash] key-value pairs to add as a single context entry
20
+ def add_context(context)
21
+ return unless context.is_a?(Hash)
22
+
23
+ @span_state[:contexts] ||= []
24
+ @span_state[:contexts] << context
25
+ rescue
26
+ # Silently ignore - never crash the host app
27
+ end
28
+
29
+ # Set the prompt for this span.
30
+ # The prompt is stored in span_data.prompt. Calling multiple times
31
+ # overwrites the previous value.
32
+ #
33
+ # @param prompt [String] the prompt string to store
34
+ def set_prompt(prompt)
35
+ return unless prompt.is_a?(String)
36
+
37
+ @span_state[:prompt] = prompt
38
+ rescue
39
+ # Silently ignore - never crash the host app
40
+ end
41
+ end
42
+
43
+ # Handle to the current active trace, allowing trace-level context to be set.
44
+ class CurrentTrace
45
+ def initialize(trace_id)
46
+ @trace_id = trace_id
47
+ end
48
+
49
+ # Set the session ID for this trace.
50
+ # Session ID is used to group traces from the same user session.
51
+ # This is stored as a database column.
52
+ #
53
+ # @param session_id [String] the session ID to set
54
+ def set_session_id(session_id)
55
+ trace_state = get_or_create_trace_state
56
+ trace_state[:session_id] = session_id
57
+ rescue
58
+ # Silently ignore - never crash the host app
59
+ end
60
+
61
+ # Set metadata for this trace.
62
+ # Metadata is stored in the raw trace data. Subsequent calls merge with
63
+ # existing metadata, with later values taking precedence.
64
+ #
65
+ # @param metadata [Hash] key-value pairs to store as trace metadata
66
+ def set_metadata(metadata)
67
+ return unless metadata.is_a?(Hash)
68
+
69
+ trace_state = get_or_create_trace_state
70
+ trace_state[:metadata] = (trace_state[:metadata] || {}).merge(metadata)
71
+ rescue
72
+ # Silently ignore - never crash the host app
73
+ end
74
+
75
+ # Add a context entry to this trace.
76
+ # The entire hash is pushed as a single entry in the contexts array.
77
+ # Context entries are accumulated - multiple calls add to the list.
78
+ #
79
+ # @param context [Hash] key-value pairs to add as a single context entry
80
+ def add_context(context)
81
+ return unless context.is_a?(Hash)
82
+
83
+ trace_state = get_or_create_trace_state
84
+ trace_state[:contexts] ||= []
85
+ trace_state[:contexts] << context
86
+ rescue
87
+ # Silently ignore - never crash the host app
88
+ end
89
+
90
+ private
91
+
92
+ def get_or_create_trace_state
93
+ TraceState.get(@trace_id) || TraceState.create(@trace_id)
94
+ end
95
+ end
96
+
97
+ # Thread-local span stack for tracking nested spans.
98
+ # Each entry is a Hash with :trace_id and :span_id keys.
99
+ module SpanContext
100
+ STACK_KEY = :__bitfab_span_stack
101
+
102
+ module_function
103
+
104
+ def stack
105
+ Thread.current[STACK_KEY] ||= []
106
+ end
107
+
108
+ def current
109
+ stack.last
110
+ end
111
+
112
+ # Execute a block with a new span pushed onto the stack.
113
+ # The span is automatically popped when the block completes.
114
+ def with_span(trace_id:, span_id:)
115
+ entry = {trace_id:, span_id:}
116
+ stack.push(entry)
117
+ yield
118
+ ensure
119
+ stack.pop
120
+ end
121
+ end
122
+
123
+ # Global storage for trace states (trace_id -> state hash)
124
+ module TraceState
125
+ @states_mutex = Mutex.new
126
+ @states = {}
127
+
128
+ module_function
129
+
130
+ def get(trace_id)
131
+ @states_mutex.synchronize { @states[trace_id] }
132
+ end
133
+
134
+ def create(trace_id)
135
+ @states_mutex.synchronize do
136
+ @states[trace_id] ||= {
137
+ trace_id:,
138
+ started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
139
+ }
140
+ end
141
+ end
142
+
143
+ def delete(trace_id)
144
+ @states_mutex.synchronize { @states.delete(trace_id) }
145
+ end
146
+
147
+ def clear_all
148
+ @states_mutex.synchronize { @states.clear }
149
+ end
150
+ end
151
+ end