bitfab 0.33.5 → 0.33.6

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: 1d72d477d9b3533bbba990387aeaf342774ec7e3271e11e51fc297a7de9534bb
4
+ data.tar.gz: 624832fc2f8b1e526370a6c07b75fcb36dc5ffe62871ed888392e3f3d9eeb5ca
5
5
  SHA512:
6
- metadata.gz: 8965c0c940f603355b3ddb75c9a93fc33e56d839a730bcbe750f2eee6db558cb4fcc2de79e9551ce5431737c8907284a7593abaa63935c84d49268b2a7eb1ed2
7
- data.tar.gz: 6c8883368d7b7bc68c823cd73860722a6460543484c2a5b687b4beec7b26f8737f46d935a33fdaf9b5760fc413ba04f6313426e67b4cc624429840ec0d9d12f7
6
+ metadata.gz: d8e17a56b93c6ab2ab59e5ba5f5bb264f68329f19f7c1801c10e750609be96b3ae2be5dca4117aeae6e41f2d62ae5be757188c6a85bc98548290f1afe6b0aef1
7
+ data.tar.gz: 7de5823eb5e244c4576723234d7ad6812fded976cd8f22837585a563ded544906d0f2c91114eb748d971056563fe2301a17ce937473538d8c3e507870a5e9929
data/README.md CHANGED
@@ -19,7 +19,8 @@ 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
+ - Optional: `opentelemetry-exporter-otlp`, only to deliver through an OpenTelemetry Collector. Use a version compatible with your `opentelemetry-sdk`; if the gem cannot load, the SDK warns once and keeps delivering directly to Bitfab
23
24
 
24
25
  ## Quick Start
25
26
 
@@ -355,17 +356,80 @@ end
355
356
 
356
357
  ### Automatic Flush
357
358
 
358
- Spans are sent in background threads and automatically flushed on exit via `at_exit` hook.
359
+ 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
360
 
360
361
  ### Manual Flush
361
362
 
362
363
  Wait for all pending spans to be sent:
363
364
 
364
365
  ```ruby
365
- Bitfab.flush_traces
366
- # Blocks until all background threads complete
366
+ raise "Bitfab traces were not delivered" unless Bitfab.flush_traces(timeout: 30)
367
+ ```
368
+
369
+ The return value is `false` when an export fails or the deadline expires.
370
+
371
+ ### Closing a Client
372
+
373
+ A long-running process that builds transient clients should release each one when it is done:
374
+
375
+ ```ruby
376
+ client = Bitfab::Client.new(api_key: ENV.fetch("BITFAB_API_KEY"))
377
+ begin
378
+ # ... traced work ...
379
+ ensure
380
+ client.close(timeout: 30)
381
+ end
367
382
  ```
368
383
 
384
+ A shared client needs no explicit close; `at_exit` shuts remaining transports down.
385
+
386
+ ## OpenTelemetry Transport
387
+
388
+ Bitfab keeps its public API (`Bitfab::Traceable`, `bitfab_span`, `Bitfab.span`), while one private
389
+ OpenTelemetry `TracerProvider` and `BatchSpanProcessor` per client manage the bounded queue, batch
390
+ worker, export scheduling, flush, and shutdown lifecycle. Pipelines are created lazily on the first
391
+ trace send and are never installed globally, so an unused or disabled client starts no OTel worker
392
+ and the SDK does not replace an application's own OpenTelemetry setup.
393
+
394
+ By default, batches are sent directly to Bitfab as OTLP/JSON. To send through a local OTel Collector
395
+ instead, add the `opentelemetry-exporter-otlp` gem and set `BITFAB_OTEL_EXPORTER_ENDPOINT` to the
396
+ Collector's OTLP/HTTP base URL (for example, `http://localhost:4318`). The SDK appends `/v1/traces`
397
+ and sends protobuf through OTel's official exporter. Production and replay traffic share the same
398
+ pipeline. Before finalizing a replay, the SDK flushes OTel and polls Bitfab's replay-status API
399
+ until every expected trace completion and span count is persisted, so Collector acceptance is never
400
+ mistaken for Bitfab persistence.
401
+
402
+ Direct requests carry at most eight carriers each and up to 32 run concurrently; set
403
+ `BITFAB_OTEL_EXPORT_CONCURRENCY` to an integer from `1` through `64` to tune that. Direct JSON and
404
+ Collector protobuf requests are partitioned at roughly 3 MB; set `BITFAB_OTEL_MAX_REQUEST_BYTES` to
405
+ a positive integer no greater than `3000000` for a Collector or proxy with a stricter limit.
406
+ Invalid values warn once and fall back to the defaults. If Bitfab accepts only part of a batch, it
407
+ returns the standard OTLP `partialSuccess` response and the SDK warns with the rejected-span count
408
+ and reason.
409
+
410
+ ```yaml
411
+ receivers:
412
+ otlp:
413
+ protocols:
414
+ http:
415
+
416
+ exporters:
417
+ otlphttp/bitfab:
418
+ endpoint: https://bitfab.ai/api/sdk/otel
419
+ encoding: json
420
+ headers:
421
+ Authorization: Bearer ${env:BITFAB_API_KEY}
422
+
423
+ service:
424
+ pipelines:
425
+ traces:
426
+ receivers: [otlp]
427
+ exporters: [otlphttp/bitfab]
428
+ ```
429
+
430
+ See the [OpenTelemetry Transport Architecture](https://docs.bitfab.ai/otel-architecture) for the
431
+ full component ownership, carrier format, replay barrier, batching, and lifecycle design.
432
+
369
433
  ### Reset Client
370
434
 
371
435
  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,6 +21,31 @@ 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.
@@ -37,7 +63,7 @@ module Bitfab
37
63
  http.read_timeout = request_timeout
38
64
 
39
65
  req = Net::HTTP::Post.new(uri.path, headers)
40
- req.body = safe_generate(payload, endpoint)
66
+ req.body = Serialize.safe_generate(payload)
41
67
 
42
68
  response = http.request(req)
43
69
 
@@ -62,14 +88,9 @@ module Bitfab
62
88
  raise last_error
63
89
  end
64
90
 
65
- # Send an external span in a background thread.
66
- # Returns the thread for callers that need to await completion.
91
+ # Queue an external span on this client's trace transport (fire-and-forget).
67
92
  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
93
+ trace_transport&.submit("external_span", payload.merge("sdkVersion" => VERSION))
73
94
  end
74
95
 
75
96
  # Make a GET request to the Bitfab API.
@@ -208,54 +229,45 @@ module Bitfab
208
229
  )
209
230
  end
210
231
 
211
- # Send an external trace (fire-and-forget in background thread).
232
+ # Queue an external trace on this client's trace transport (fire-and-forget).
212
233
  def send_external_trace(payload)
213
- merged = payload.merge("sdkVersion" => VERSION)
234
+ trace_transport&.submit("external_trace", payload.merge("sdkVersion" => VERSION))
235
+ end
214
236
 
215
- Bitfab._run_in_background do
216
- request("/api/sdk/externalTraces", merged, timeout: 10)
217
- end
237
+ # Read the replay traces the server has fully persisted so far.
238
+ def get_replay_status(test_run_id, expected_span_counts)
239
+ request(
240
+ "/api/sdk/replay/status",
241
+ {"testRunId" => test_run_id, "expectedSpanCounts" => expected_span_counts},
242
+ timeout: 30
243
+ )
218
244
  end
219
245
 
220
246
  private
221
247
 
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
- )
248
+ # Returns nil once the client is closed. A stray traced call after close
249
+ # must not crash the host app, and raising here would be swallowed by the
250
+ # span finalize path, dropping the span with no explanation.
251
+ def trace_transport
252
+ @transport_mutex.synchronize do
253
+ if @closed
254
+ Bitfab.warn_once(
255
+ "client-closed",
256
+ "client is closed; dropping spans. Close it after the traced work " \
257
+ "finishes, or keep one client for the life of the process."
258
+ )
259
+ next nil
260
+ end
240
261
 
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"})
262
+ @transport ||= Transport.create_trace_transport(
263
+ api_key: @api_key,
264
+ direct_sender: method(:send_transport_request)
265
+ )
246
266
  end
247
267
  end
248
268
 
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
269
+ def send_transport_request(endpoint, payload, timeout)
270
+ request(endpoint, payload, timeout:, max_retries: 1)
259
271
  end
260
272
 
261
273
  # Normalize each entry to a hash with stable string keys, accepting either
@@ -281,39 +293,15 @@ module Bitfab
281
293
  end
282
294
  end
283
295
 
284
- # --- Background thread management ---
285
-
286
- @pending_threads_mutex = Mutex.new
287
- @pending_threads = []
288
-
289
296
  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.
297
+ # Wait for queued spans and trace completions to reach the server, within
298
+ # one total deadline. Returns true when everything landed in time.
310
299
  def flush_traces(timeout: 30)
311
- threads = @pending_threads_mutex.synchronize { @pending_threads.dup }
312
- threads.each { |t| t.join(timeout) }
300
+ Transport.flush_trace_transports([timeout, 0].max)
313
301
  end
314
302
  end
315
303
 
316
304
  at_exit do
317
- Bitfab.flush_traces(timeout: 2)
305
+ Transport.shutdown_trace_transports(4)
318
306
  end
319
307
  end