bitfab 0.33.5 → 0.34.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40eaaf83e783c54b0a0b373b681d82e1d96b01f4e99a424f68af1fffcd82a79e
4
- data.tar.gz: ed01d854b7ed2ee101a6bab9d659bd977c7f5ee6f0cbd0b481675d76dba449ec
3
+ metadata.gz: 897fb65140b86333bb5012746e570c785560c7865cd79da1e872e4ec567fada2
4
+ data.tar.gz: 26b89a73668a0f86746a349c8f57fe39317f069dbab04dd8731ff91950d24a49
5
5
  SHA512:
6
- metadata.gz: 8965c0c940f603355b3ddb75c9a93fc33e56d839a730bcbe750f2eee6db558cb4fcc2de79e9551ce5431737c8907284a7593abaa63935c84d49268b2a7eb1ed2
7
- data.tar.gz: 6c8883368d7b7bc68c823cd73860722a6460543484c2a5b687b4beec7b26f8737f46d935a33fdaf9b5760fc413ba04f6313426e67b4cc624429840ec0d9d12f7
6
+ metadata.gz: cb837f5a94d68391c0b25ac1bc073cba636816270abd623b825b59a2a3d8f387ac67a49aaef93aebcc3201ad24dbe4ef9f4d4738955d5db5387181eb1f94ee68
7
+ data.tar.gz: 0b609be82dca49043083bf966b52b01466f8252abe15b243e4d2ac6a3ea06d5238fb5fdf7b88e7a2c1be22fa4c36b06425baa2261a62ffed6967b5e8c86a8150
data/README.md CHANGED
@@ -19,7 +19,7 @@ gem install bitfab
19
19
  ## Requirements
20
20
 
21
21
  - Ruby >= 3.4
22
- - No external runtime dependencies (uses stdlib only)
22
+ - Runtime dependencies: `base64`, `opentelemetry-sdk` (`>= 1.2, < 2`, verified against 1.2 through 1.13, so an application already pinning its own OpenTelemetry version does not have to move)
23
23
 
24
24
  ## Quick Start
25
25
 
@@ -355,17 +355,56 @@ end
355
355
 
356
356
  ### Automatic Flush
357
357
 
358
- Spans are sent in background threads and automatically flushed on exit via `at_exit` hook.
358
+ Spans are queued on a batch worker and delivered on exit via an `at_exit` hook that flushes and shuts the worker down. Every other API call is blocking.
359
359
 
360
360
  ### Manual Flush
361
361
 
362
362
  Wait for all pending spans to be sent:
363
363
 
364
364
  ```ruby
365
- Bitfab.flush_traces
366
- # Blocks until all background threads complete
365
+ raise "Bitfab traces were not delivered" unless Bitfab.flush_traces(timeout: 30)
367
366
  ```
368
367
 
368
+ The return value is `false` when an export fails or the deadline expires.
369
+
370
+ ### Closing a Client
371
+
372
+ A long-running process that builds transient clients should release each one when it is done:
373
+
374
+ ```ruby
375
+ client = Bitfab::Client.new(api_key: ENV.fetch("BITFAB_API_KEY"))
376
+ begin
377
+ # ... traced work ...
378
+ ensure
379
+ client.close(timeout: 30)
380
+ end
381
+ ```
382
+
383
+ A shared client needs no explicit close; `at_exit` shuts remaining transports down.
384
+
385
+ ## OpenTelemetry Transport
386
+
387
+ Bitfab keeps its public API (`Bitfab::Traceable`, `bitfab_span`, `Bitfab.span`), while one private
388
+ OpenTelemetry `TracerProvider` and `BatchSpanProcessor` per client manage the bounded queue, batch
389
+ worker, export scheduling, flush, and shutdown lifecycle. Pipelines are created lazily on the first
390
+ trace send and are never installed globally, so an unused or disabled client starts no OTel worker
391
+ and the SDK does not replace an application's own OpenTelemetry setup.
392
+
393
+ Requests carry at most eight carriers each and up to 32 run concurrently; set
394
+ `BITFAB_OTEL_EXPORT_CONCURRENCY` to an integer from `1` through `64` to tune that. Requests are
395
+ partitioned at roughly 3 MB; set `BITFAB_OTEL_MAX_REQUEST_BYTES` to a positive integer no greater
396
+ than `3000000` for a proxy with a stricter limit. Invalid values warn once and fall back to the
397
+ defaults. Each carrier is encoded once and the request body is assembled from those encodings, so a
398
+ batch is never re-encoded to measure its size. If Bitfab accepts only part of a batch, it returns
399
+ the standard OTLP `partialSuccess` response and the SDK warns with the rejected-span count and
400
+ reason.
401
+
402
+ Before finalizing a replay, the SDK flushes OTel and polls Bitfab's replay-status API until every
403
+ expected trace completion and span count is persisted.
404
+
405
+ See the [OpenTelemetry Transport Architecture](https://docs.bitfab.ai/otel-architecture) for the
406
+ full component ownership, carrier format, replay barrier, batching, and lifecycle design.
407
+
369
408
  ### Reset Client
370
409
 
371
410
  Clear the global client (useful for testing):
data/lib/bitfab/client.rb CHANGED
@@ -42,8 +42,6 @@ module Bitfab
42
42
  # The key is NOT read here. HttpClient gets a proc so the key is resolved
43
43
  # at send time, after any env loading has run.
44
44
  @http_client = HttpClient.new(api_key: -> { resolve_api_key }, service_url: @service_url)
45
- @pending_span_threads = {}
46
- @pending_span_mutex = Mutex.new
47
45
  # Mock overrides registered via register_mock_override, applied to every
48
46
  # replay on this client (after any per-call mock_override). Instance
49
47
  # state, no global; clear_mock_overrides resets it.
@@ -57,6 +55,25 @@ module Bitfab
57
55
  @api_key_config.respond_to?(:call) ? @api_key_config.call : @api_key_config
58
56
  end
59
57
 
58
+ # Flush and permanently close this client's tracing transport, releasing its
59
+ # batch worker. Long-running processes that build transient clients should
60
+ # call this; a shared client is closed at process exit.
61
+ #
62
+ # @param timeout [Numeric] maximum total seconds to wait
63
+ # @return [Boolean] true when everything queued was delivered in time
64
+ def close(timeout: 30)
65
+ @http_client.close(timeout:)
66
+ end
67
+
68
+ # Wait for the spans and traces this client queued to be delivered, without
69
+ # closing it.
70
+ #
71
+ # @param timeout [Numeric] maximum total seconds to wait
72
+ # @return [Boolean] true when everything queued was delivered in time
73
+ def flush(timeout: 30)
74
+ @http_client.flush(timeout:)
75
+ end
76
+
60
77
  # Effective tracing state, evaluated lazily: enabled only when not
61
78
  # explicitly disabled AND a key resolves. Reading this resolves the key
62
79
  # (and may emit the one-time empty-key warning), exactly as the first traced
@@ -294,10 +311,6 @@ module Bitfab
294
311
  )
295
312
  end
296
313
 
297
- if is_root_span
298
- @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
299
- end
300
-
301
314
  # Advance the per-(key, name) call counter for any non-root span under
302
315
  # an active mock tree, even when this span won't itself be mocked.
303
316
  # Unmarked spans must consume an index so subsequent marked siblings
@@ -332,10 +345,7 @@ module Bitfab
332
345
  rescue
333
346
  # Clean up any trace state this partial setup registered so it does not
334
347
  # leak.
335
- if trace_id
336
- TraceState.delete(trace_id)
337
- @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) }
338
- end
348
+ TraceState.delete(trace_id) if trace_id
339
349
  # During replay (a controlled eval) a setup failure must surface, not
340
350
  # silently run the block untraced: swallowing it would execute real code
341
351
  # with real side effects and skew the mock call counter, defeating the
@@ -366,7 +376,7 @@ module Bitfab
366
376
  begin
367
377
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
368
378
 
369
- span_thread = send_span(
379
+ send_span(
370
380
  trace_function_key:,
371
381
  trace_id:,
372
382
  span_id:,
@@ -387,10 +397,6 @@ module Bitfab
387
397
  )
388
398
 
389
399
  if is_root_span
390
- pending = @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) || [] }
391
- pending << span_thread if span_thread
392
- pending.each { |t| t.join(5) }
393
-
394
400
  # Built AFTER the wrapped method finished (finalize runs at root
395
401
  # span end), so :accessed reflects whether customer code obtained
396
402
  # the branch URL during this item. nil (key omitted) when no
@@ -409,29 +415,13 @@ module Bitfab
409
415
  }
410
416
  end
411
417
 
412
- completion_thread = send_trace_completion(
418
+ send_trace_completion(
413
419
  trace_function_key:,
414
420
  trace_id:,
415
421
  started_at:,
416
422
  ended_at:,
417
423
  db_snapshot_usage:
418
424
  )
419
-
420
- # In replay, persistence is correctness: the replay runner joins
421
- # these threads before calling complete_replay, or the server's
422
- # trace-ID mapping races the uploads and every item's trace_id
423
- # comes back nil. The 5s join above is best-effort only; this
424
- # hands the full set (span uploads + trace completion) to the
425
- # runner. No-op outside replay, where sends stay fire-and-forget.
426
- persistence = ReplayContext.current&.dig(:pending_persistence)
427
- if persistence
428
- persistence.concat(pending)
429
- persistence << completion_thread if completion_thread
430
- end
431
- else
432
- @pending_span_mutex.synchronize do
433
- @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
434
- end
435
425
  end
436
426
  rescue Exception # rubocop:disable Lint/RescueException
437
427
  # Silently ignore: user's result/exception takes priority
@@ -633,14 +623,10 @@ module Bitfab
633
623
  payload["testRunId"] = trace_state[:test_run_id]
634
624
  end
635
625
 
636
- completion_thread = @http_client.send_external_trace(payload)
626
+ @http_client.send_external_trace(payload)
637
627
 
638
628
  # Clean up trace state
639
629
  TraceState.delete(trace_id)
640
-
641
- # Returned so the replay path can join it: trace completions must be
642
- # persisted before complete_replay builds the trace-ID mapping.
643
- completion_thread
644
630
  end
645
631
 
646
632
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
@@ -649,8 +635,7 @@ module Bitfab
649
635
  # If drop() was called on this trace, suppress the span PAYLOAD upload for
650
636
  # every span that completes after the flag was set. The trace completion
651
637
  # still rides out with dropped: true, so the server scrubs any sibling
652
- # spans that already raced out before the flag was set. Returning nil
653
- # signals "no span thread" to the caller, which already handles nil.
638
+ # spans that already raced out before the flag was set.
654
639
  trace_dropped = TraceState.get(trace_id)&.dig(:dropped) == true
655
640
  return nil if trace_dropped
656
641
 
@@ -835,16 +820,12 @@ module Bitfab
835
820
 
836
821
  # Record a span entry for a mocked invocation so the test run reflects the
837
822
  # mocked execution. Mirrors send_span's payload shape but with the mocked
838
- # output as the result and no error. The returned background thread is
839
- # registered with @pending_span_threads so the root span's finalize joins
840
- # it before sending trace completion; without this the trace completion
841
- # can race ahead of the mocked span's HTTP send and the trace lands
842
- # temporarily incomplete on the server.
823
+ # output as the result and no error.
843
824
  def send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
844
825
  span_name:, span_type:, function_name:, args:, kwargs:, mocked_output:,
845
826
  started_at:, test_run_id:, input_source_span_id:)
846
827
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
847
- span_thread = send_span(
828
+ send_span(
848
829
  trace_function_key:,
849
830
  trace_id:,
850
831
  span_id:,
@@ -864,12 +845,6 @@ module Bitfab
864
845
  input_source_span_id:,
865
846
  mocked: true
866
847
  )
867
- # Mocked spans are always non-root (advance_mock_counter returns nil for
868
- # root spans, so check_mock_replay never short-circuits them), so the
869
- # thread always belongs in the parent's pending list, never standalone.
870
- @pending_span_mutex.synchronize do
871
- @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
872
- end
873
848
  rescue Exception # rubocop:disable Lint/RescueException
874
849
  # Never crash the host app: mocked span recording is best-effort
875
850
  end
@@ -6,6 +6,7 @@ require "uri"
6
6
 
7
7
  require_relative "constants"
8
8
  require_relative "serialize"
9
+ require_relative "transport"
9
10
  require_relative "version"
10
11
  require_relative "warn_once"
11
12
 
@@ -20,11 +21,42 @@ module Bitfab
20
21
  @api_key = api_key
21
22
  @service_url = (service_url || DEFAULT_SERVICE_URL).chomp("/")
22
23
  @timeout = timeout
24
+ @transport_mutex = Mutex.new
25
+ @transport = nil
26
+ @closed = false
27
+ end
28
+
29
+ # Flush and permanently close this client's tracing transport.
30
+ # Returns true when everything it queued was delivered within the deadline.
31
+ def close(timeout: 30)
32
+ transport = @transport_mutex.synchronize do
33
+ current = @transport
34
+ @transport = nil
35
+ @closed = true
36
+ current
37
+ end
38
+ return true if transport.nil?
39
+
40
+ transport.shutdown(timeout)
41
+ end
42
+
43
+ # Wait for the spans and traces this client queued to be delivered.
44
+ def flush(timeout: 30)
45
+ transport = @transport_mutex.synchronize { @transport }
46
+ return true if transport.nil?
47
+
48
+ transport.flush(timeout)
23
49
  end
24
50
 
25
51
  # Make a POST request to the Bitfab API.
26
52
  # Returns parsed JSON response hash.
27
53
  def request(endpoint, payload, timeout: nil, max_retries: 1, retry_delay: 0.1)
54
+ send_encoded(endpoint, Serialize.safe_generate(payload), timeout:, max_retries:, retry_delay:)
55
+ end
56
+
57
+ # POST an already-encoded body. The span transport encodes its own batches,
58
+ # so routing them back through #request would encode the same data twice.
59
+ def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1)
28
60
  uri = URI("#{@service_url}#{endpoint}")
29
61
  request_timeout = timeout || @timeout
30
62
 
@@ -37,7 +69,7 @@ module Bitfab
37
69
  http.read_timeout = request_timeout
38
70
 
39
71
  req = Net::HTTP::Post.new(uri.path, headers)
40
- req.body = safe_generate(payload, endpoint)
72
+ req.body = body
41
73
 
42
74
  response = http.request(req)
43
75
 
@@ -62,14 +94,9 @@ module Bitfab
62
94
  raise last_error
63
95
  end
64
96
 
65
- # Send an external span in a background thread.
66
- # Returns the thread for callers that need to await completion.
97
+ # Queue an external span on this client's trace transport (fire-and-forget).
67
98
  def send_external_span(payload)
68
- merged = payload.merge("sdkVersion" => VERSION)
69
-
70
- Bitfab._run_in_background do
71
- request("/api/sdk/externalSpans", merged, timeout: 30)
72
- end
99
+ trace_transport&.submit("external_span", payload.merge("sdkVersion" => VERSION))
73
100
  end
74
101
 
75
102
  # Make a GET request to the Bitfab API.
@@ -208,54 +235,44 @@ module Bitfab
208
235
  )
209
236
  end
210
237
 
211
- # Send an external trace (fire-and-forget in background thread).
238
+ # Queue an external trace on this client's trace transport (fire-and-forget).
212
239
  def send_external_trace(payload)
213
- merged = payload.merge("sdkVersion" => VERSION)
240
+ trace_transport&.submit("external_trace", payload.merge("sdkVersion" => VERSION))
241
+ end
214
242
 
215
- Bitfab._run_in_background do
216
- request("/api/sdk/externalTraces", merged, timeout: 10)
217
- end
243
+ # Read the replay traces the server has fully persisted so far.
244
+ def get_replay_status(test_run_id, expected_span_counts)
245
+ request(
246
+ "/api/sdk/replay/status",
247
+ {"testRunId" => test_run_id, "expectedSpanCounts" => expected_span_counts},
248
+ timeout: 30
249
+ )
218
250
  end
219
251
 
220
252
  private
221
253
 
222
- # JSON-encode a request body without ever raising on a stray value.
223
- #
224
- # Upstream serialization (Serialize.serialize_value) should already have
225
- # flattened user data. This is the boundary backstop: if anything
226
- # non-serializable still slips through, it is run through serialize_value
227
- # (which never raises and stubs strays) instead of letting JSON.generate
228
- # raise and drop the whole span/trace silently. A degraded payload warns
229
- # loudly so the trace isn't quietly left incomplete or not replayable.
230
- def safe_generate(payload, endpoint)
231
- JSON.generate(payload)
232
- rescue => e
233
- Bitfab.warn_once(
234
- "request-body-stubbed",
235
- "a request body held a non-serializable value (#{e.message}); it was " \
236
- "stubbed so the span still sends, but the trace may be incomplete or " \
237
- "not replayable. Capture a JSON-safe projection of this input to make " \
238
- "it replayable."
239
- )
254
+ # Returns nil once the client is closed. A stray traced call after close
255
+ # must not crash the host app, and raising here would be swallowed by the
256
+ # span finalize path, dropping the span with no explanation.
257
+ def trace_transport
258
+ @transport_mutex.synchronize do
259
+ if @closed
260
+ Bitfab.warn_once(
261
+ "client-closed",
262
+ "client is closed; dropping spans. Close it after the traced work " \
263
+ "finishes, or keep one client for the life of the process."
264
+ )
265
+ next nil
266
+ end
240
267
 
241
- begin
242
- JSON.generate(sanitize_payload(payload))
243
- rescue
244
- # Truly pathological. Still never drop silently: send a marker body.
245
- JSON.generate({"error" => "payload_serialize_failed"})
268
+ @transport ||= Transport.create_trace_transport(
269
+ direct_sender: method(:send_transport_request)
270
+ )
246
271
  end
247
272
  end
248
273
 
249
- # Serialize each top-level value so a bad or oversize value is stubbed in
250
- # place while the payload keeps its object shape. Running serialize_value on
251
- # the whole payload could collapse the entire body to a single stub string
252
- # (oversize/cyclic), sending a JSON string instead of a span object.
253
- def sanitize_payload(payload)
254
- return {"error" => "payload_serialize_failed"} unless payload.is_a?(Hash)
255
-
256
- payload.each_with_object({}) do |(k, v), acc|
257
- acc[k.to_s] = Serialize.serialize_value(v)
258
- end
274
+ def send_transport_request(endpoint, body, timeout)
275
+ send_encoded(endpoint, body, timeout:, max_retries: 1)
259
276
  end
260
277
 
261
278
  # Normalize each entry to a hash with stable string keys, accepting either
@@ -281,39 +298,15 @@ module Bitfab
281
298
  end
282
299
  end
283
300
 
284
- # --- Background thread management ---
285
-
286
- @pending_threads_mutex = Mutex.new
287
- @pending_threads = []
288
-
289
301
  class << self
290
- # Run a block in a background thread with tracking.
291
- # Returns the thread for callers that need to join on it.
292
- def _run_in_background(&block)
293
- thread = Thread.new do
294
- block.call
295
- rescue => e
296
- Bitfab.warn_once(
297
- "send-request-failed",
298
- "failed to send a request to the backend (further occurrences " \
299
- "suppressed): #{e.message}"
300
- )
301
- ensure
302
- @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
303
- end
304
-
305
- @pending_threads_mutex.synchronize { @pending_threads << thread }
306
- thread
307
- end
308
-
309
- # Wait for all pending background threads to complete.
302
+ # Wait for queued spans and trace completions to reach the server, within
303
+ # one total deadline. Returns true when everything landed in time.
310
304
  def flush_traces(timeout: 30)
311
- threads = @pending_threads_mutex.synchronize { @pending_threads.dup }
312
- threads.each { |t| t.join(timeout) }
305
+ Transport.flush_trace_transports([timeout, 0].max)
313
306
  end
314
307
  end
315
308
 
316
309
  at_exit do
317
- Bitfab.flush_traces(timeout: 2)
310
+ Transport.shutdown_trace_transports(4)
318
311
  end
319
312
  end