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.
- checksums.yaml +7 -0
- data/README.md +342 -0
- data/lib/bitfab/client.rb +226 -0
- data/lib/bitfab/constants.rb +6 -0
- data/lib/bitfab/http_client.rb +175 -0
- data/lib/bitfab/replay.rb +149 -0
- data/lib/bitfab/serialize.rb +103 -0
- data/lib/bitfab/span_context.rb +151 -0
- data/lib/bitfab/traceable.rb +141 -0
- data/lib/bitfab/version.rb +5 -0
- data/lib/bitfab.rb +97 -0
- metadata +152 -0
|
@@ -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
|