bitfab 0.20.2 → 0.21.1

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: d2428cc39dcd97d154e36ba9792628478e86a4b4dde5101c79d37ebb1635378f
4
- data.tar.gz: effa0dba40674696ad3d9155615d9d9e2e40a3fe7f9aed9c8ee97516985e13c6
3
+ metadata.gz: 364e0ec3b3aa00cc47a398872911e18b1a3e5253f4448214da67f9c12af51814
4
+ data.tar.gz: 482340c71e8d2cb0f8ed92efa7a214278529a670e836e46b6209cc18126f230b
5
5
  SHA512:
6
- metadata.gz: 5f172f0178829a08315ae1caf3de6ff4a22389b2f83dbf449721ca53ca3a466580af00c15bd5279dffc2c9d074aee2afd0e157733f9257987460ef8d101350c9
7
- data.tar.gz: 9c6af19d4ebac35d13280aeaa89ac6104cbff3020e24813e292e4170c6524016a0df943025f3ced5510345c863e4b86f9f027fe281912e99a6438f863dde9d28
6
+ metadata.gz: d9c10548592e02d52a74b96b268bea01d33200c3ff338b61758ec6d54dc2adcf07ca5c442212d464cb53cb86a1aeeccb7466c9ca71ba49810902ff6f5445b0b5
7
+ data.tar.gz: 3f12de6d0a9eeb5f7c8218d977c4d344a972f762f6c7380e5123d8976032c2215f63337698e4c7d6f18fd669786e4fded0084e0be2ef8fce1e7a696324c2f9b5
data/lib/bitfab/client.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "http_client"
8
8
  require_relative "replay"
9
9
  require_relative "span_context"
10
10
  require_relative "serialize"
11
+ require_relative "warn_once"
11
12
 
12
13
  module Bitfab
13
14
  class Client
@@ -59,14 +60,21 @@ module Bitfab
59
60
  # onto the method's current signature when its shape changed after the
60
61
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
61
62
  # { trace_id:, source_span_id: }, and returns [new_args, new_kwargs].
63
+ # @param on_progress [#call, nil] optional callback invoked once per item as
64
+ # it finishes, with a running-totals hash { completed:, total:, succeeded:,
65
+ # errored: }. Use it to render replay progress (e.g. a terminal progress
66
+ # bar). Replay does not know pass/fail yet, so the totals only distinguish
67
+ # items whose method ran (:succeeded) from items that raised (:errored).
68
+ # A raising callback never crashes the run.
62
69
  # @return [Hash] with :items, :test_run_id, :test_run_url
63
70
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
64
71
  code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "none",
65
- adapt_inputs: nil, environment: nil)
72
+ adapt_inputs: nil, environment: nil, on_progress: nil)
66
73
  Replay.run(
67
74
  self, receiver, method_name,
68
75
  trace_function_key:, limit:, trace_ids:, max_concurrency:,
69
- code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:, environment:
76
+ code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:, environment:,
77
+ on_progress:
70
78
  )
71
79
  end
72
80
 
@@ -94,59 +102,93 @@ module Bitfab
94
102
  mock_on_replay: false)
95
103
  return yield unless @enabled
96
104
 
97
- parent = SpanContext.current
98
- replay_ctx = ReplayContext.current
99
- trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
100
- span_id = SecureRandom.uuid
101
- parent_span_id = parent&.dig(:span_id)
102
- is_root_span = parent_span_id.nil?
103
- started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
104
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
105
- resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
106
- resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
107
-
108
- # Register trace state for root spans
109
- if is_root_span && !TraceState.get(trace_id)
110
- TraceState.create(
111
- trace_id,
112
- test_run_id: resolved_test_run_id,
113
- input_source_trace_id: resolved_input_source_trace_id
114
- )
115
- end
105
+ # Span setup runs before the user's block. Tracing is a side-channel, so
106
+ # if anything here raises (id generation, trace-state bookkeeping, a
107
+ # malformed replay mock tree) the user's method must still run. On failure
108
+ # we clean up any partially registered trace state, warn once, and run the
109
+ # block untraced. `trace_id` is declared out here so the rescue can clean
110
+ # it up; the other locals stay visible to the real path below.
111
+ trace_id = nil
112
+ span_id = nil
113
+ parent_span_id = nil
114
+ is_root_span = nil
115
+ started_at = nil
116
+ resolved_test_run_id = nil
117
+ resolved_input_source_span_id = nil
118
+ begin
119
+ parent = SpanContext.current
120
+ replay_ctx = ReplayContext.current
121
+ trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
122
+ span_id = SecureRandom.uuid
123
+ parent_span_id = parent&.dig(:span_id)
124
+ is_root_span = parent_span_id.nil?
125
+ started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
126
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
127
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
128
+ resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
129
+
130
+ # Register trace state for root spans
131
+ if is_root_span && !TraceState.get(trace_id)
132
+ TraceState.create(
133
+ trace_id,
134
+ test_run_id: resolved_test_run_id,
135
+ input_source_trace_id: resolved_input_source_trace_id
136
+ )
137
+ end
116
138
 
117
- if is_root_span
118
- @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
119
- end
139
+ if is_root_span
140
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
141
+ end
120
142
 
121
- # Advance the per-(key, name) call counter for any non-root span under
122
- # an active mock tree, even when this span won't itself be mocked.
123
- # Unmarked spans must consume an index so subsequent marked siblings
124
- # line up with `build_mock_tree`'s sequential numbering for the same
125
- # (key, name) pair. Different (key, name) pairs have independent
126
- # counters: they cannot shift each other.
127
- call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
128
- if call_index
129
- mocked_output = check_mock_replay(
130
- replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
131
- )
132
- if mocked_output != MOCK_REPLAY_MISS
133
- send_mocked_span(
134
- trace_function_key:,
135
- trace_id:,
136
- span_id:,
137
- parent_span_id:,
138
- span_name:,
139
- span_type:,
140
- function_name:,
141
- args:,
142
- kwargs:,
143
- mocked_output:,
144
- started_at:,
145
- test_run_id: resolved_test_run_id,
146
- input_source_span_id: resolved_input_source_span_id
143
+ # Advance the per-(key, name) call counter for any non-root span under
144
+ # an active mock tree, even when this span won't itself be mocked.
145
+ # Unmarked spans must consume an index so subsequent marked siblings
146
+ # line up with `build_mock_tree`'s sequential numbering for the same
147
+ # (key, name) pair. Different (key, name) pairs have independent
148
+ # counters: they cannot shift each other.
149
+ call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
150
+ if call_index
151
+ mocked_output = check_mock_replay(
152
+ replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
147
153
  )
148
- return mocked_output
154
+ if mocked_output != MOCK_REPLAY_MISS
155
+ send_mocked_span(
156
+ trace_function_key:,
157
+ trace_id:,
158
+ span_id:,
159
+ parent_span_id:,
160
+ span_name:,
161
+ span_type:,
162
+ function_name:,
163
+ args:,
164
+ kwargs:,
165
+ mocked_output:,
166
+ started_at:,
167
+ test_run_id: resolved_test_run_id,
168
+ input_source_span_id: resolved_input_source_span_id
169
+ )
170
+ return mocked_output
171
+ end
172
+ end
173
+ rescue
174
+ # Clean up any trace state this partial setup registered so it does not
175
+ # leak.
176
+ if trace_id
177
+ TraceState.delete(trace_id)
178
+ @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) }
149
179
  end
180
+ # During replay (a controlled eval) a setup failure must surface, not
181
+ # silently run the block untraced: swallowing it would execute real code
182
+ # with real side effects and skew the mock call counter, defeating the
183
+ # replay. The never-crash fallback is for production hosts only.
184
+ raise if ReplayContext.current
185
+
186
+ Bitfab.warn_once(
187
+ "span-setup:#{trace_function_key}",
188
+ "span setup failed for '#{trace_function_key}'; this call runs untraced. " \
189
+ "Your method still executes and returns normally."
190
+ )
191
+ return yield
150
192
  end
151
193
 
152
194
  result = nil
@@ -7,6 +7,7 @@ require "uri"
7
7
  require_relative "constants"
8
8
  require_relative "serialize"
9
9
  require_relative "version"
10
+ require_relative "warn_once"
10
11
 
11
12
  module Bitfab
12
13
  class HttpClient
@@ -181,14 +182,13 @@ module Bitfab
181
182
  def safe_generate(payload, endpoint)
182
183
  JSON.generate(payload)
183
184
  rescue => e
184
- begin
185
- warn "Bitfab: request body to #{endpoint} held a non-serializable " \
186
- "value (#{e.message}); it was stubbed so the span still sends, " \
187
- "but the trace may be incomplete or not replayable. Capture a " \
188
- "JSON-safe projection of this input to make it replayable."
189
- rescue
190
- # Never crash the host app over a log line.
191
- end
185
+ Bitfab.warn_once(
186
+ "request-body-stubbed",
187
+ "a request body held a non-serializable value (#{e.message}); it was " \
188
+ "stubbed so the span still sends, but the trace may be incomplete or " \
189
+ "not replayable. Capture a JSON-safe projection of this input to make " \
190
+ "it replayable."
191
+ )
192
192
 
193
193
  begin
194
194
  JSON.generate(sanitize_payload(payload))
@@ -242,11 +242,11 @@ module Bitfab
242
242
  thread = Thread.new do
243
243
  block.call
244
244
  rescue => e
245
- begin
246
- warn "Bitfab: Failed to send request: #{e.message}"
247
- rescue
248
- # Never crash the host app
249
- end
245
+ Bitfab.warn_once(
246
+ "send-request-failed",
247
+ "failed to send a request to the backend (further occurrences " \
248
+ "suppressed): #{e.message}"
249
+ )
250
250
  ensure
251
251
  @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
252
252
  end
data/lib/bitfab/replay.rb CHANGED
@@ -100,10 +100,14 @@ module Bitfab
100
100
  # { trace_id:, source_span_id: }, and returns [new_args, new_kwargs]. Runs
101
101
  # per item inside the same rescue as the method, so a raising adapter sets
102
102
  # that item's :error rather than crashing the run.
103
+ # @param on_progress [#call, nil] optional callback invoked once per item as
104
+ # it finishes, with a running-totals hash { completed:, total:, succeeded:,
105
+ # errored: }. Use it to render replay progress (e.g. a terminal progress
106
+ # bar). A raising callback never crashes the run.
103
107
  # @return [Hash] with :items, :test_run_id, :test_run_url
104
108
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
105
109
  code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "none",
106
- adapt_inputs: nil, environment: nil)
110
+ adapt_inputs: nil, environment: nil, on_progress: nil)
107
111
  unless MOCK_STRATEGIES.include?(mock.to_s)
108
112
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
109
113
  end
@@ -157,7 +161,7 @@ module Bitfab
157
161
 
158
162
  result_items = if server_items.any?
159
163
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
160
- adapt_inputs, include_db_branch_lease)
164
+ adapt_inputs, include_db_branch_lease, on_progress:)
161
165
  else
162
166
  []
163
167
  end
@@ -237,13 +241,39 @@ module Bitfab
237
241
 
238
242
  # Process all replay items, optionally in parallel using threads.
239
243
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
240
- adapt_inputs = nil, include_db_branch_lease = false)
244
+ adapt_inputs = nil, include_db_branch_lease = false, on_progress: nil)
241
245
  concurrency = max_concurrency || server_items.length
242
246
 
247
+ # Reports running totals once per item as it settles. In the parallel
248
+ # path it runs from worker threads, so the mutex both makes the counter
249
+ # updates safe and serializes the user's callback (never called
250
+ # concurrently). A raising callback is swallowed: progress UI must never
251
+ # crash the run.
252
+ total = server_items.length
253
+ progress_mutex = Mutex.new
254
+ completed = 0
255
+ succeeded = 0
256
+ errored = 0
257
+ report = lambda do |result|
258
+ return unless on_progress
259
+
260
+ progress_mutex.synchronize do
261
+ completed += 1
262
+ result[:error].nil? ? (succeeded += 1) : (errored += 1)
263
+ begin
264
+ on_progress.call({completed:, total:, succeeded:, errored:})
265
+ rescue => e
266
+ warn "Bitfab: replay on_progress callback raised: #{e.message}"
267
+ end
268
+ end
269
+ end
270
+
243
271
  if concurrency <= 1
244
272
  server_items.map do |item|
245
- process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs,
246
- include_db_branch_lease)
273
+ result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
274
+ adapt_inputs, include_db_branch_lease)
275
+ report.call(result)
276
+ result
247
277
  end
248
278
  else
249
279
  results_mutex = Mutex.new
@@ -260,6 +290,7 @@ module Bitfab
260
290
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
261
291
  adapt_inputs, include_db_branch_lease)
262
292
  results_mutex.synchronize { results[idx] = result }
293
+ report.call(result)
263
294
  end
264
295
  end
265
296
  end
@@ -173,7 +173,12 @@ module Bitfab
173
173
 
174
174
  wrapper = Module.new do
175
175
  define_method(method_name) do |*args, **kwargs, &block|
176
- Bitfab.client.send(:execute_span,
176
+ # If Bitfab was never configured, run the real method untraced
177
+ # rather than raising a "not configured" error into the host app.
178
+ client = Bitfab.client_or_nil
179
+ return super(*args, **kwargs, &block) unless client
180
+
181
+ client.send(:execute_span,
177
182
  trace_function_key:,
178
183
  span_name:,
179
184
  span_type: type,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.20.2"
4
+ VERSION = "0.21.1"
5
5
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Emit a warning at most once per distinct key for the life of the process.
5
+ #
6
+ # The SDK must never crash or spam the host app, so every failure on the
7
+ # user's path degrades quietly: a span is dropped, a call runs untraced, a
8
+ # payload is stubbed. Silent is safe but undebuggable; warning on every call
9
+ # from a hot path is its own problem. A one-time warning per distinct issue
10
+ # restores the signal without the flood. Keys should identify the specific
11
+ # degradation (e.g. include the trace_function_key) so each distinct issue
12
+ # warns once, not just the first one seen.
13
+ @warn_once_seen = Set.new
14
+ @warn_once_mutex = Mutex.new
15
+
16
+ class << self
17
+ def warn_once(key, message)
18
+ @warn_once_mutex.synchronize do
19
+ return if @warn_once_seen.include?(key)
20
+
21
+ @warn_once_seen.add(key)
22
+ end
23
+ begin
24
+ warn "Bitfab: #{message}"
25
+ rescue Exception # rubocop:disable Lint/RescueException
26
+ # Logging must never crash the host app (e.g. a closed $stderr).
27
+ end
28
+ end
29
+
30
+ # Test-only: clear the dedup set so a warning can fire again.
31
+ def _reset_warn_once
32
+ @warn_once_mutex.synchronize { @warn_once_seen.clear }
33
+ end
34
+ end
35
+ end
data/lib/bitfab.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "bitfab/version"
4
4
  require_relative "bitfab/constants"
5
+ require_relative "bitfab/warn_once"
5
6
  require_relative "bitfab/serialize"
6
7
  require_relative "bitfab/db_snapshot"
7
8
  require_relative "bitfab/span_context"
@@ -65,6 +66,13 @@ module Bitfab
65
66
  @client or raise "Bitfab not configured. Call Bitfab.configure(api_key: '...') first."
66
67
  end
67
68
 
69
+ # Returns the global client, or nil if not configured. Used on the traced
70
+ # call path so a method invoked before Bitfab.configure runs untraced
71
+ # rather than crashing the host app with a "not configured" error.
72
+ def client_or_nil
73
+ @client
74
+ end
75
+
68
76
  # Reset the global client (primarily for testing).
69
77
  def reset!
70
78
  @client = nil
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.20.2
4
+ version: 0.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -127,6 +127,7 @@ files:
127
127
  - lib/bitfab/span_context.rb
128
128
  - lib/bitfab/traceable.rb
129
129
  - lib/bitfab/version.rb
130
+ - lib/bitfab/warn_once.rb
130
131
  homepage: https://bitfab.ai
131
132
  licenses:
132
133
  - MIT