bitfab 0.12.0 → 0.12.2

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: 0e4ca1b81e502de48fe20835b4d072b05736ac5f4ff1a444d683627818648675
4
- data.tar.gz: d7120ea5b06e8da151cd9ca26c9549b017458cc2d3b1fa9078ffa512c59d8606
3
+ metadata.gz: 90f13972943bdfcdd6c5b72859d9ae03b23549577771f0e7d4f8726e2cff9037
4
+ data.tar.gz: f7c393d995069261d6ddf554727b02b25e6ec37f1f516a0ad01b114f684b5cb9
5
5
  SHA512:
6
- metadata.gz: b2cbfda78dfd7d726174ace8910f507ea3982d68bb19c402fcaa50b84a20eeddbaf7373932fbec81c93116d8056b41eef876f132aa27913cade2937506299e37
7
- data.tar.gz: 41fa08a3eb1a36f21af3358570012625564473eeafd847ab0073c33e012fb341c2b42463a1247e73c929679a1075220c096a4e35a2ac5c7e81151268f3cbbd3c
6
+ metadata.gz: 2ddfbc335058f37fc3f024b6596d3029dff1601be1243a8f779bbcd7dc899d78f75769c2db8e70894a316649ece042b56f9089fbe39b573bfe40cf313ec2c984
7
+ data.tar.gz: 0eca0c359e794699587347552b79d608d4ebf6016abef029795a2ffabc475f7d52bc16bcddf32525c7f9a714e7df6386af3c2f9ab6d40d0f8a13c638baf4bf3c
data/lib/bitfab/client.rb CHANGED
@@ -84,13 +84,12 @@ module Bitfab
84
84
  return yield unless @enabled
85
85
 
86
86
  parent = SpanContext.current
87
- trace_id = parent ? parent[:trace_id] : SecureRandom.uuid
87
+ replay_ctx = ReplayContext.current
88
+ trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
88
89
  span_id = SecureRandom.uuid
89
90
  parent_span_id = parent&.dig(:span_id)
90
91
  is_root_span = parent_span_id.nil?
91
92
  started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
92
-
93
- replay_ctx = ReplayContext.current
94
93
  resolved_test_run_id = replay_ctx&.dig(:test_run_id)
95
94
  resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
96
95
  resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
data/lib/bitfab/replay.rb CHANGED
@@ -22,19 +22,14 @@ module Bitfab
22
22
 
23
23
  # Execute a block with replay context set on the current thread.
24
24
  # The context is automatically cleared when the block completes.
25
- #
26
- # @param test_run_id [String]
27
- # @param input_source_span_id [String, nil]
28
- # @param input_source_trace_id [String, nil]
29
- # @param mock_tree [Hash{String => Hash}, nil] keyed by "#{key}:#{index}"
30
- # @param mock_strategy [String, nil] one of MOCK_STRATEGIES
31
- def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil,
25
+ def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
32
26
  mock_tree: nil, mock_strategy: nil)
33
27
  previous = Thread.current[REPLAY_CONTEXT_KEY]
34
28
  ctx = {
35
29
  test_run_id:,
36
30
  input_source_span_id:,
37
- input_source_trace_id:
31
+ input_source_trace_id:,
32
+ trace_id:
38
33
  }
39
34
  if mock_tree
40
35
  ctx[:mock_tree] = mock_tree
@@ -100,9 +95,14 @@ module Bitfab
100
95
  Bitfab.flush_traces
101
96
 
102
97
  begin
103
- http_client.complete_replay(test_run_id)
98
+ complete_response = http_client.complete_replay(test_run_id)
99
+ trace_id_map = complete_response&.dig("traceIds") || {}
100
+ result_items.each do |item|
101
+ item[:trace_id] = trace_id_map[item[:trace_id]]
102
+ end
104
103
  rescue => e
105
104
  warn "Bitfab: Failed to complete replay: #{e.message}"
105
+ result_items.each { |item| item[:trace_id] = nil }
106
106
  end
107
107
 
108
108
  {
@@ -248,11 +248,13 @@ module Bitfab
248
248
 
249
249
  fn_result = nil
250
250
  fn_error = nil
251
+ sdk_trace_id = SecureRandom.uuid
251
252
 
252
253
  ReplayContext.with_context(
253
254
  test_run_id:,
254
255
  input_source_span_id:,
255
256
  input_source_trace_id:,
257
+ trace_id: sdk_trace_id,
256
258
  mock_tree:,
257
259
  mock_strategy:
258
260
  ) do
@@ -272,7 +274,8 @@ module Bitfab
272
274
  error: fn_error,
273
275
  duration_ms: metrics[:duration_ms],
274
276
  tokens: metrics[:tokens],
275
- model: metrics[:model]
277
+ model: metrics[:model],
278
+ trace_id: sdk_trace_id
276
279
  }
277
280
  end
278
281
  end
@@ -6,43 +6,115 @@ require "time"
6
6
 
7
7
  module Bitfab
8
8
  module Serialize
9
+ # Cap on serialized payload size. Walking arbitrary objects (e.g. an
10
+ # OpenAI client passed as a span input) can produce hundreds of KB to MB
11
+ # of useless internal state. Anything beyond this cap is replaced with a
12
+ # stub so the span still ships and the trace isn't dropped server-side.
13
+ MAX_SERIALIZED_BYTES = 512_000
14
+
15
+ # Recursion guard for cyclic graphs and pathologically nested structures.
16
+ MAX_SERIALIZE_DEPTH = 16
17
+
9
18
  module_function
10
19
 
11
20
  # Serialize a value for JSON storage (human-readable).
12
21
  # Handles primitives, hashes, arrays, and objects with common conversion methods.
13
22
  # Note: We intentionally avoid as_json here because it requires ActiveSupport,
14
23
  # and we want to keep the SDK dependency-free (stdlib only).
24
+ #
25
+ # Guarantees:
26
+ # - Never raises. Pathological inputs (objects with raising to_s/to_h,
27
+ # cycles, BasicObject subclasses) return a stub string.
28
+ # - Never returns a value whose JSON encoding exceeds MAX_SERIALIZED_BYTES.
29
+ # Without this the wire-side JSON.dump in the http client can produce a
30
+ # request that times out or gets rejected, leaving a trace with zero spans.
15
31
  def serialize_value(value)
32
+ result = serialize_value_inner(value, 0)
33
+ return result unless oversized?(result)
34
+
35
+ unserializable_stub(value, "too_large")
36
+ rescue StandardError, SystemStackError
37
+ unserializable_stub(value, "unexpected_error")
38
+ end
39
+
40
+ def serialize_value_inner(value, depth)
41
+ return "<unserializable: max_depth>" if depth > MAX_SERIALIZE_DEPTH
42
+
16
43
  case value
17
44
  when nil, true, false, Integer, Float, String
18
45
  value
19
46
  when Hash
20
- value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
47
+ value.each_with_object({}) do |(k, v), acc|
48
+ acc[safe_to_s(k)] = serialize_value_inner(v, depth + 1)
49
+ end
21
50
  when Array
22
- value.map { |v| serialize_value(v) }
51
+ value.map { |v| serialize_value_inner(v, depth + 1) }
23
52
  when Set
24
- value.map { |v| serialize_value(v) }
53
+ value.map { |v| serialize_value_inner(v, depth + 1) }
25
54
  when Time, DateTime
26
- value.iso8601(3)
55
+ safely_call(value, "iso8601", 3) { safe_to_s(value) }
27
56
  when Date
28
- value.to_s
57
+ safe_to_s(value)
29
58
  when Symbol
30
59
  value.to_s
31
60
  else
32
61
  if value.respond_to?(:to_h)
33
- serialize_value(value.to_h)
62
+ h = safely_call(value, "to_h") { return unserializable_stub(value, "to_h_raised") }
63
+ serialize_value_inner(h, depth + 1)
34
64
  elsif value.respond_to?(:to_a)
35
- serialize_value(value.to_a)
65
+ a = safely_call(value, "to_a") { return unserializable_stub(value, "to_a_raised") }
66
+ serialize_value_inner(a, depth + 1)
36
67
  else
37
- value.to_s
68
+ safe_to_s(value)
38
69
  end
39
70
  end
71
+ rescue StandardError, SystemStackError
72
+ unserializable_stub(value, "inner_error")
73
+ end
74
+
75
+ # Call `value.<method>` and return the result, or yield to the fallback
76
+ # block if the call raises. Used to harden every call site that invokes a
77
+ # user-defined method on an arbitrary object.
78
+ def safely_call(value, method, *args)
79
+ value.public_send(method, *args)
80
+ rescue
81
+ yield
82
+ end
83
+
84
+ def safe_to_s(value)
85
+ str = value.to_s
86
+ str.is_a?(String) ? str : "<#{class_name(value)}: to_s returned non-String>"
87
+ rescue
88
+ "<#{class_name(value)}: to_s raised>"
89
+ end
90
+
91
+ def class_name(value)
92
+ value.class.name || "Object"
93
+ rescue
94
+ "Object"
95
+ end
96
+
97
+ def unserializable_stub(value, reason)
98
+ "<unserializable: #{class_name(value)} (#{reason})>"
99
+ end
100
+
101
+ def oversized?(value)
102
+ JSON.dump(value).bytesize > MAX_SERIALIZED_BYTES
103
+ rescue
104
+ # If JSON.dump can't handle it, the wire path can't either, so treat as
105
+ # oversized to force the stub fallback.
106
+ true
40
107
  end
41
108
 
42
109
  # Serialize function inputs (args + kwargs) for span data (human-readable).
43
110
  def serialize_inputs(args, kwargs = {})
44
111
  serialized = args.map { |arg| serialize_value(arg) }
45
- serialized << kwargs.transform_keys(&:to_s).transform_values { |v| serialize_value(v) } unless kwargs.empty?
112
+ unless kwargs.empty?
113
+ kw = kwargs.each_with_object({}) do |(k, v), acc|
114
+ acc[safe_to_s(k)] = serialize_value(v)
115
+ end
116
+ serialized << kw
117
+ end
46
118
  serialized
47
119
  end
48
120
 
@@ -51,8 +123,13 @@ module Bitfab
51
123
  #
52
124
  # @param value [Object] any Ruby value
53
125
  # @return [String, nil] Base64-encoded Marshal dump, or nil if marshalling fails
126
+ # (objects with non-marshallable members like Proc/IO, or whose marshalled
127
+ # size exceeds MAX_SERIALIZED_BYTES).
54
128
  def marshal_value(value)
55
- Base64.strict_encode64(Marshal.dump(value))
129
+ dumped = Marshal.dump(value)
130
+ return nil if dumped.bytesize > MAX_SERIALIZED_BYTES
131
+
132
+ Base64.strict_encode64(dumped)
56
133
  rescue TypeError, ArgumentError
57
134
  # Some objects (Proc, IO, etc.) can't be marshalled
58
135
  nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.2"
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.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team