bitfab 0.9.0 → 0.10.4

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: e5d6df92ce06e91ff963c8dc0e78af3a0f591c51b53330c1c8630bb5d66e7663
4
+ data.tar.gz: 199d4883cfad921563b3b5786d2db5b017d4b618e000c7d259ed9f69be3eb7ba
5
5
  SHA512:
6
- metadata.gz: d2eeec89057ecdee5f7f5eae5342ad9218aa190fc472aa01d3dbf0323262b33165faed2d61d6ae0be9bf7da3af9a7e88902e0fa426344b53751b78d0c1f5cf18
7
- data.tar.gz: 9726da71f354723007ff6b9bf38466544e93a1ae21f0b02e2b258827697d4996caea781d1c062c7386dd5508eddad097fbc85f4382798e07cd11a0535a6e1a7a
6
+ metadata.gz: 02c87809df7f4039dd608d3e60ddc0ff85b791d834ea7dc5557a368987d5dba1a174705074a26a9df80d4a8bb7c4ff7493f4c8ac03beb457629d896768a70567
7
+ data.tar.gz: 579f94120498658ceeb5129b3a21a9d1477d816be9216963c168e0c8747aef0687298f632539f33ace2960db53a19a9ab7847f776d27d971e1b006ad1ebbc909
data/lib/bitfab/client.rb CHANGED
@@ -53,9 +53,13 @@ module Bitfab
53
53
  is_root_span = parent_span_id.nil?
54
54
  started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
55
55
 
56
+ replay_ctx = ReplayContext.current
57
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
58
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
59
+
56
60
  # Register trace state for root spans
57
61
  if is_root_span && !TraceState.get(trace_id)
58
- TraceState.create(trace_id)
62
+ TraceState.create(trace_id, test_run_id: resolved_test_run_id)
59
63
  end
60
64
 
61
65
  if is_root_span
@@ -66,27 +70,18 @@ module Bitfab
66
70
  error = nil
67
71
  span_contexts = nil
68
72
  span_prompt = nil
73
+ finalized = false
74
+
75
+ finalize = lambda do |final_result, final_error|
76
+ # Never crash the host app due to span building/sending. Idempotent —
77
+ # only the first call sends the span. Subsequent calls (e.g. from the
78
+ # enumerator wrapper after iteration completes) are no-ops.
79
+ next if finalized
80
+ finalized = true
69
81
 
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
82
  begin
84
83
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
85
84
 
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
85
  span_thread = send_span(
91
86
  trace_function_key:,
92
87
  trace_id:,
@@ -99,8 +94,8 @@ module Bitfab
99
94
  prompt: span_prompt,
100
95
  args:,
101
96
  kwargs:,
102
- result:,
103
- error:,
97
+ result: final_result,
98
+ error: final_error,
104
99
  started_at:,
105
100
  ended_at:,
106
101
  test_run_id: resolved_test_run_id,
@@ -130,11 +125,78 @@ module Bitfab
130
125
  end
131
126
  end
132
127
 
128
+ begin
129
+ SpanContext.with_span(trace_id:, span_id:) do
130
+ result = yield
131
+ ensure
132
+ # Capture contexts before the span context is popped
133
+ span_contexts = SpanContext.current&.dig(:contexts)
134
+ span_prompt = SpanContext.current&.dig(:prompt)
135
+ end
136
+ rescue => e
137
+ error = e.message
138
+ finalize.call(result, error)
139
+ raise
140
+ end
141
+
142
+ # If the wrapped block returned an Enumerator (lazy iteration via
143
+ # `enum_for`, `to_enum`, `Enumerator.new`, `[...].lazy.map(...)`, etc.),
144
+ # the work hasn't actually run yet — the values are produced as the
145
+ # caller iterates. Without special handling we'd close the span here
146
+ # with `result == <the Enumerator object>`, and any nested `bitfab_span`
147
+ # calls inside the enumerator body would see an empty span stack and
148
+ # post their own root traces, fragmenting one logical workflow.
149
+ #
150
+ # Instead, hand the caller a wrapping Enumerator whose body restores
151
+ # the parent span stack on the iterating fiber, drives the source,
152
+ # collects yielded values as the span output, and finalizes the span
153
+ # once iteration completes (or errors).
154
+ #
155
+ # Limitation: when the source enumerator itself runs its body in a
156
+ # separate fiber (e.g. `Enumerator.new { |y| ... }` or `enum_for(...)`
157
+ # without a block), nested `bitfab_span` calls inside that body fiber
158
+ # still see an empty stack because `Thread.current[STACK_KEY]` is
159
+ # fiber-local. Lazy chains over collections (`.lazy.map`) and ordinary
160
+ # `each` callbacks DO run in the iterating fiber and nest correctly.
161
+ if result.is_a?(Enumerator)
162
+ return wrap_enumerator(result, trace_id:, span_id:, finalize:)
163
+ end
164
+
165
+ finalize.call(result, error)
133
166
  result
134
167
  end
135
168
 
136
169
  private
137
170
 
171
+ # Build an Enumerator that drives `source`, restoring `[trace_id, span_id]`
172
+ # on the iterating fiber so nested `bitfab_span` calls inside lazy / `each`
173
+ # callbacks nest under the parent span. Yielded values are collected as the
174
+ # span output. The span is sent exactly once — when iteration finishes,
175
+ # raises, or the wrapper is `.close`d.
176
+ def wrap_enumerator(source, trace_id:, span_id:, finalize:)
177
+ span_entry = {trace_id:, span_id:}
178
+ yielded = []
179
+
180
+ Enumerator.new do |yielder|
181
+ # Push our span onto this fiber's stack so anything that runs in this
182
+ # fiber (including the user's lazy block and `each` callbacks) sees
183
+ # the right parent.
184
+ SpanContext.stack.push(span_entry)
185
+ begin
186
+ source.each do |value|
187
+ yielded << value
188
+ yielder << value
189
+ end
190
+ finalize.call(yielded, nil)
191
+ rescue => e
192
+ finalize.call(yielded, e.message)
193
+ raise
194
+ ensure
195
+ SpanContext.stack.pop
196
+ end
197
+ end
198
+ end
199
+
138
200
  def validate_span_type!(type)
139
201
  return if SPAN_TYPES.include?(type.to_s)
140
202
 
@@ -169,6 +231,9 @@ module Bitfab
169
231
  if trace_state&.dig(:session_id)
170
232
  payload["sessionId"] = trace_state[:session_id]
171
233
  end
234
+ if trace_state&.dig(:test_run_id)
235
+ payload["testRunId"] = trace_state[:test_run_id]
236
+ end
172
237
 
173
238
  @http_client.send_external_trace(payload)
174
239
 
data/lib/bitfab/replay.rb CHANGED
@@ -105,7 +105,8 @@ module Bitfab
105
105
  def process_single_item(http_client, server_item, receiver, method_name, test_run_id)
106
106
  span = http_client.get_external_span(server_item["externalSpanId"])
107
107
  item_data = extract_span_data(span)
108
- execute_item(item_data, receiver, method_name, test_run_id, span["id"])
108
+ metrics = extract_server_item_metrics(server_item)
109
+ execute_item(item_data, receiver, method_name, test_run_id, span["id"], metrics)
109
110
  end
110
111
 
111
112
  # Extract input/output data from an external span's rawData.
@@ -121,8 +122,29 @@ module Bitfab
121
122
  }
122
123
  end
123
124
 
125
+ # Pull durationMs / tokens / model from the start-replay server item.
126
+ # Normalizes to symbol-keyed tokens hash and nil-safe defaults so older
127
+ # servers without these fields still produce a consistent shape.
128
+ def extract_server_item_metrics(server_item)
129
+ raw_tokens = server_item["tokens"]
130
+ tokens = if raw_tokens.is_a?(Hash)
131
+ {
132
+ input: raw_tokens["input"],
133
+ output: raw_tokens["output"],
134
+ cached: raw_tokens["cached"],
135
+ total: raw_tokens["total"]
136
+ }
137
+ end
138
+
139
+ {
140
+ duration_ms: server_item["durationMs"],
141
+ tokens:,
142
+ model: server_item["model"]
143
+ }
144
+ end
145
+
124
146
  # 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)
147
+ def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {})
126
148
  args, kwargs = Serialize.deserialize_inputs(item)
127
149
 
128
150
  fn_result = nil
@@ -142,7 +164,10 @@ module Bitfab
142
164
  input: args,
143
165
  result: fn_result,
144
166
  original_output: item["output"],
145
- error: fn_error
167
+ error: fn_error,
168
+ duration_ms: metrics[:duration_ms],
169
+ tokens: metrics[:tokens],
170
+ model: metrics[:model]
146
171
  }
147
172
  end
148
173
  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.4"
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team