bitfab 0.21.0 → 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: 7536f774b491c131b04a135a41c5f753c7c507615e54aa675c314cff785b08df
4
- data.tar.gz: 47e7804161bc40563f5aca99147ef43d2af1af6d73d22f6ad3ae3402ea4b51f4
3
+ metadata.gz: 364e0ec3b3aa00cc47a398872911e18b1a3e5253f4448214da67f9c12af51814
4
+ data.tar.gz: 482340c71e8d2cb0f8ed92efa7a214278529a670e836e46b6209cc18126f230b
5
5
  SHA512:
6
- metadata.gz: 4aed7d077e6aa0ca89e8130b526578276591e60b41ae10670dc14db4eaca6221c30481dcad84acb8d7dfd7e549b2b92aa2c5a750efa36c4df85224b01360fe19
7
- data.tar.gz: d73a3933eceba9c705fdab9f8b38f9f46766e026e59cdf61ee0ca2ea49a01ec4e5029fcbc87161a0a16602a2365ef3eeddba1acff27e659bf8fcf69b08d85138
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
@@ -101,59 +102,93 @@ module Bitfab
101
102
  mock_on_replay: false)
102
103
  return yield unless @enabled
103
104
 
104
- parent = SpanContext.current
105
- replay_ctx = ReplayContext.current
106
- trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
107
- span_id = SecureRandom.uuid
108
- parent_span_id = parent&.dig(:span_id)
109
- is_root_span = parent_span_id.nil?
110
- started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
111
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
112
- resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
113
- resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
114
-
115
- # Register trace state for root spans
116
- if is_root_span && !TraceState.get(trace_id)
117
- TraceState.create(
118
- trace_id,
119
- test_run_id: resolved_test_run_id,
120
- input_source_trace_id: resolved_input_source_trace_id
121
- )
122
- 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
123
138
 
124
- if is_root_span
125
- @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
126
- end
139
+ if is_root_span
140
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
141
+ end
127
142
 
128
- # Advance the per-(key, name) call counter for any non-root span under
129
- # an active mock tree, even when this span won't itself be mocked.
130
- # Unmarked spans must consume an index so subsequent marked siblings
131
- # line up with `build_mock_tree`'s sequential numbering for the same
132
- # (key, name) pair. Different (key, name) pairs have independent
133
- # counters: they cannot shift each other.
134
- call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
135
- if call_index
136
- mocked_output = check_mock_replay(
137
- replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
138
- )
139
- if mocked_output != MOCK_REPLAY_MISS
140
- send_mocked_span(
141
- trace_function_key:,
142
- trace_id:,
143
- span_id:,
144
- parent_span_id:,
145
- span_name:,
146
- span_type:,
147
- function_name:,
148
- args:,
149
- kwargs:,
150
- mocked_output:,
151
- started_at:,
152
- test_run_id: resolved_test_run_id,
153
- 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:
154
153
  )
155
- 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) }
156
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
157
192
  end
158
193
 
159
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
@@ -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.21.0"
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.21.0
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