bitfab 0.9.0 → 0.10.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78b34044dcc028414fb2f6fbe020f98bf0ce1a953507c8939bc12d8c15630b09
4
- data.tar.gz: 174aa2eb61c2aca0a11078f2361b4392d7612f2a582e3dd4046608cbbb539a27
3
+ metadata.gz: b789df310c91a7e9130f3bdb6a35e6237a8ddadede4df49a5f7dc4afc0525e91
4
+ data.tar.gz: 0cd79b9c9bc8bf15ef926625c0aa1aba8f3cf1798062c0d077e371d98b9f6aba
5
5
  SHA512:
6
- metadata.gz: d2eeec89057ecdee5f7f5eae5342ad9218aa190fc472aa01d3dbf0323262b33165faed2d61d6ae0be9bf7da3af9a7e88902e0fa426344b53751b78d0c1f5cf18
7
- data.tar.gz: 9726da71f354723007ff6b9bf38466544e93a1ae21f0b02e2b258827697d4996caea781d1c062c7386dd5508eddad097fbc85f4382798e07cd11a0535a6e1a7a
6
+ metadata.gz: fbf62eeabc5741c36ef1b867730f548039f20331d6591ccae7a66cc48882cd4638877edc1b80d57c3a326827edfbedff1d3ec226ed3b4e3358f9041e3405ea5f
7
+ data.tar.gz: 579cb384585a42160d5f1c9941cbf0762f53d35ffda7f109cb40c4c52a291a02d446e113ca664a2c84a08916481fe86912eb386a58956fb5651ff1a6dc108771
data/lib/bitfab/client.rb CHANGED
@@ -36,9 +36,18 @@ module Bitfab
36
36
  # @param limit [Integer] maximum number of traces to replay (default: 5)
37
37
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
38
38
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
39
+ # @param code_change_description [String, nil] optional rationale for the
40
+ # code change being tested in this replay (stored on the experiment)
41
+ # @param code_change_files [Array<Hash>, nil] optional list of edited files,
42
+ # each as { path:, before:, after: } (use "" for new/deleted files)
39
43
  # @return [Hash] with :items, :test_run_id, :test_run_url
40
- def replay(receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10)
41
- Replay.run(self, receiver, method_name, trace_function_key:, limit:, trace_ids:, max_concurrency:)
44
+ def replay(receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10,
45
+ code_change_description: nil, code_change_files: nil)
46
+ Replay.run(
47
+ self, receiver, method_name,
48
+ trace_function_key:, limit:, trace_ids:, max_concurrency:,
49
+ code_change_description:, code_change_files:
50
+ )
42
51
  end
43
52
 
44
53
  # Execute a block inside a span context, sending trace data on completion.
@@ -53,9 +62,13 @@ module Bitfab
53
62
  is_root_span = parent_span_id.nil?
54
63
  started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
55
64
 
65
+ replay_ctx = ReplayContext.current
66
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
67
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
68
+
56
69
  # Register trace state for root spans
57
70
  if is_root_span && !TraceState.get(trace_id)
58
- TraceState.create(trace_id)
71
+ TraceState.create(trace_id, test_run_id: resolved_test_run_id)
59
72
  end
60
73
 
61
74
  if is_root_span
@@ -66,27 +79,18 @@ module Bitfab
66
79
  error = nil
67
80
  span_contexts = nil
68
81
  span_prompt = nil
82
+ finalized = false
83
+
84
+ finalize = lambda do |final_result, final_error|
85
+ # Never crash the host app due to span building/sending. Idempotent —
86
+ # only the first call sends the span. Subsequent calls (e.g. from the
87
+ # enumerator wrapper after iteration completes) are no-ops.
88
+ next if finalized
89
+ finalized = true
69
90
 
70
- begin
71
- SpanContext.with_span(trace_id:, span_id:) do
72
- result = yield
73
- ensure
74
- # Capture contexts before the span context is popped
75
- span_contexts = SpanContext.current&.dig(:contexts)
76
- span_prompt = SpanContext.current&.dig(:prompt)
77
- end
78
- rescue => e
79
- error = e.message
80
- raise
81
- ensure
82
- # Never crash the host app due to span building/sending
83
91
  begin
84
92
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
85
93
 
86
- replay_ctx = ReplayContext.current
87
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
88
- resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
89
-
90
94
  span_thread = send_span(
91
95
  trace_function_key:,
92
96
  trace_id:,
@@ -99,8 +103,8 @@ module Bitfab
99
103
  prompt: span_prompt,
100
104
  args:,
101
105
  kwargs:,
102
- result:,
103
- error:,
106
+ result: final_result,
107
+ error: final_error,
104
108
  started_at:,
105
109
  ended_at:,
106
110
  test_run_id: resolved_test_run_id,
@@ -130,11 +134,78 @@ module Bitfab
130
134
  end
131
135
  end
132
136
 
137
+ begin
138
+ SpanContext.with_span(trace_id:, span_id:) do
139
+ result = yield
140
+ ensure
141
+ # Capture contexts before the span context is popped
142
+ span_contexts = SpanContext.current&.dig(:contexts)
143
+ span_prompt = SpanContext.current&.dig(:prompt)
144
+ end
145
+ rescue => e
146
+ error = e.message
147
+ finalize.call(result, error)
148
+ raise
149
+ end
150
+
151
+ # If the wrapped block returned an Enumerator (lazy iteration via
152
+ # `enum_for`, `to_enum`, `Enumerator.new`, `[...].lazy.map(...)`, etc.),
153
+ # the work hasn't actually run yet — the values are produced as the
154
+ # caller iterates. Without special handling we'd close the span here
155
+ # with `result == <the Enumerator object>`, and any nested `bitfab_span`
156
+ # calls inside the enumerator body would see an empty span stack and
157
+ # post their own root traces, fragmenting one logical workflow.
158
+ #
159
+ # Instead, hand the caller a wrapping Enumerator whose body restores
160
+ # the parent span stack on the iterating fiber, drives the source,
161
+ # collects yielded values as the span output, and finalizes the span
162
+ # once iteration completes (or errors).
163
+ #
164
+ # Limitation: when the source enumerator itself runs its body in a
165
+ # separate fiber (e.g. `Enumerator.new { |y| ... }` or `enum_for(...)`
166
+ # without a block), nested `bitfab_span` calls inside that body fiber
167
+ # still see an empty stack because `Thread.current[STACK_KEY]` is
168
+ # fiber-local. Lazy chains over collections (`.lazy.map`) and ordinary
169
+ # `each` callbacks DO run in the iterating fiber and nest correctly.
170
+ if result.is_a?(Enumerator)
171
+ return wrap_enumerator(result, trace_id:, span_id:, finalize:)
172
+ end
173
+
174
+ finalize.call(result, error)
133
175
  result
134
176
  end
135
177
 
136
178
  private
137
179
 
180
+ # Build an Enumerator that drives `source`, restoring `[trace_id, span_id]`
181
+ # on the iterating fiber so nested `bitfab_span` calls inside lazy / `each`
182
+ # callbacks nest under the parent span. Yielded values are collected as the
183
+ # span output. The span is sent exactly once — when iteration finishes,
184
+ # raises, or the wrapper is `.close`d.
185
+ def wrap_enumerator(source, trace_id:, span_id:, finalize:)
186
+ span_entry = {trace_id:, span_id:}
187
+ yielded = []
188
+
189
+ Enumerator.new do |yielder|
190
+ # Push our span onto this fiber's stack so anything that runs in this
191
+ # fiber (including the user's lazy block and `each` callbacks) sees
192
+ # the right parent.
193
+ SpanContext.stack.push(span_entry)
194
+ begin
195
+ source.each do |value|
196
+ yielded << value
197
+ yielder << value
198
+ end
199
+ finalize.call(yielded, nil)
200
+ rescue => e
201
+ finalize.call(yielded, e.message)
202
+ raise
203
+ ensure
204
+ SpanContext.stack.pop
205
+ end
206
+ end
207
+ end
208
+
138
209
  def validate_span_type!(type)
139
210
  return if SPAN_TYPES.include?(type.to_s)
140
211
 
@@ -169,6 +240,9 @@ module Bitfab
169
240
  if trace_state&.dig(:session_id)
170
241
  payload["sessionId"] = trace_state[:session_id]
171
242
  end
243
+ if trace_state&.dig(:test_run_id)
244
+ payload["testRunId"] = trace_state[:test_run_id]
245
+ end
172
246
 
173
247
  @http_client.send_external_trace(payload)
174
248
 
@@ -98,12 +98,19 @@ module Bitfab
98
98
 
99
99
  # Start a replay session by fetching historical traces.
100
100
  # Blocking call. Returns hash with testRunId, testRunUrl, and items array.
101
- def start_replay(trace_function_key, limit, trace_ids: nil)
101
+ #
102
+ # @param code_change_description [String, nil] optional rationale for the
103
+ # code change being tested in this replay
104
+ # @param code_change_files [Array<Hash>, nil] optional list of edited files,
105
+ # each as { path:, before:, after: } (use "" for new/deleted files)
106
+ def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil, code_change_files: nil)
102
107
  payload = {
103
108
  "traceFunctionKey" => trace_function_key,
104
109
  "limit" => limit
105
110
  }
106
111
  payload["traceIds"] = trace_ids if trace_ids
112
+ payload["codeChangeDescription"] = code_change_description unless code_change_description.nil?
113
+ payload["codeChangeFiles"] = normalize_code_change_files(code_change_files) unless code_change_files.nil?
107
114
 
108
115
  request("/api/sdk/replay/start", payload, timeout: 30)
109
116
  end
@@ -129,6 +136,18 @@ module Bitfab
129
136
 
130
137
  private
131
138
 
139
+ # Normalize each entry to a hash with stable string keys, accepting either
140
+ # symbol-keyed or string-keyed hashes from callers.
141
+ def normalize_code_change_files(files)
142
+ files.map do |file|
143
+ {
144
+ "path" => file[:path] || file["path"],
145
+ "before" => file[:before] || file["before"],
146
+ "after" => file[:after] || file["after"]
147
+ }
148
+ end
149
+ end
150
+
132
151
  def headers
133
152
  {
134
153
  "Content-Type" => "application/json",
data/lib/bitfab/replay.rb CHANGED
@@ -42,11 +42,22 @@ module Bitfab
42
42
  # @param limit [Integer] maximum number of traces to replay (default: 5)
43
43
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
44
44
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
45
+ # @param code_change_description [String, nil] optional rationale for the
46
+ # code change being tested in this replay (stored on the experiment)
47
+ # @param code_change_files [Array<Hash>, nil] optional list of edited files,
48
+ # each as { path:, before:, after: } (empty string for new/deleted files)
45
49
  # @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)
50
+ def run(client, receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10,
51
+ code_change_description: nil, code_change_files: nil)
47
52
  http_client = client.instance_variable_get(:@http_client)
48
53
 
49
- replay_data = http_client.start_replay(trace_function_key, limit, trace_ids:)
54
+ replay_data = http_client.start_replay(
55
+ trace_function_key,
56
+ limit,
57
+ trace_ids:,
58
+ code_change_description:,
59
+ code_change_files:
60
+ )
50
61
  test_run_id = replay_data["testRunId"]
51
62
  test_run_url = replay_data["testRunUrl"]
52
63
  server_items = replay_data["items"] || []
@@ -105,7 +116,8 @@ module Bitfab
105
116
  def process_single_item(http_client, server_item, receiver, method_name, test_run_id)
106
117
  span = http_client.get_external_span(server_item["externalSpanId"])
107
118
  item_data = extract_span_data(span)
108
- execute_item(item_data, receiver, method_name, test_run_id, span["id"])
119
+ metrics = extract_server_item_metrics(server_item)
120
+ execute_item(item_data, receiver, method_name, test_run_id, span["id"], metrics)
109
121
  end
110
122
 
111
123
  # Extract input/output data from an external span's rawData.
@@ -121,8 +133,29 @@ module Bitfab
121
133
  }
122
134
  end
123
135
 
136
+ # Pull durationMs / tokens / model from the start-replay server item.
137
+ # Normalizes to symbol-keyed tokens hash and nil-safe defaults so older
138
+ # servers without these fields still produce a consistent shape.
139
+ def extract_server_item_metrics(server_item)
140
+ raw_tokens = server_item["tokens"]
141
+ tokens = if raw_tokens.is_a?(Hash)
142
+ {
143
+ input: raw_tokens["input"],
144
+ output: raw_tokens["output"],
145
+ cached: raw_tokens["cached"],
146
+ total: raw_tokens["total"]
147
+ }
148
+ end
149
+
150
+ {
151
+ duration_ms: server_item["durationMs"],
152
+ tokens:,
153
+ model: server_item["model"]
154
+ }
155
+ end
156
+
124
157
  # 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)
158
+ def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {})
126
159
  args, kwargs = Serialize.deserialize_inputs(item)
127
160
 
128
161
  fn_result = nil
@@ -142,7 +175,10 @@ module Bitfab
142
175
  input: args,
143
176
  result: fn_result,
144
177
  original_output: item["output"],
145
- error: fn_error
178
+ error: fn_error,
179
+ duration_ms: metrics[:duration_ms],
180
+ tokens: metrics[:tokens],
181
+ model: metrics[:model]
146
182
  }
147
183
  end
148
184
  end
@@ -131,12 +131,13 @@ module Bitfab
131
131
  @states_mutex.synchronize { @states[trace_id] }
132
132
  end
133
133
 
134
- def create(trace_id)
134
+ def create(trace_id, test_run_id: nil)
135
135
  @states_mutex.synchronize do
136
136
  @states[trace_id] ||= {
137
137
  trace_id:,
138
- started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
139
- }
138
+ started_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
139
+ test_run_id:
140
+ }.compact
140
141
  end
141
142
  end
142
143
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team