braintrust 0.0.4 → 0.0.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: 39d85e02bd85a931ee7f16de103d48d1184048e3ad8d791eda37bc323a653716
4
- data.tar.gz: a0b1d5493e8ad3004007e78d608154077a33c92a436bce23eb36cfbe94c3bdd4
3
+ metadata.gz: 866cb2e797502f00cda1625ad90f4d734b4b83f0d21d8243675a933fae9df693
4
+ data.tar.gz: f74151b0e18b12cf19b61b1b75b2f58e784d4171f21c0996526d29c719174260
5
5
  SHA512:
6
- metadata.gz: a5dcbd1b2bf2c0ab2355ff36c9cfce4fe10e175c0aa8df80ea3176be4002271744ca3a9fd7ef52cec888e0b326772518554921f7d657a79ba347b26c4c93b80c
7
- data.tar.gz: 78677bd57e6ed1778f74b87e050dd5bbfdc8390e73f919aa57ea680cd2cd4338086e5df4982274c3fd62e690d34ec81078d7c76e75a467ab2f5b0667e6d530d6
6
+ metadata.gz: ad2f68a6de8d547b6a609c3393522c4ae3dfcb441a9fc841484bbbcb21de7648da7a00cd625612d98c6b99e4ad41186a2bc3fff706e17b9797e7ac514e685923
7
+ data.tar.gz: f0613e5fa08c07333c74467ec7830a40f72905475e35becf7a2add077168c7554046aa9a3824fe24006870338163526e8d170cfd25727af5d53416283ae03714
data/README.md CHANGED
@@ -155,7 +155,7 @@ message = tracer.in_span("chat-message") do |span|
155
155
  root_span = span
156
156
 
157
157
  client.messages.create(
158
- model: "claude-3-5-sonnet-20241022",
158
+ model: "claude-3-haiku-20240307",
159
159
  max_tokens: 100,
160
160
  system: "You are a helpful assistant.",
161
161
  messages: [
@@ -171,6 +171,34 @@ puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
171
171
  OpenTelemetry.tracer_provider.shutdown
172
172
  ```
173
173
 
174
+ ### RubyLLM Tracing
175
+
176
+ ```ruby
177
+ require "braintrust"
178
+ require "ruby_llm"
179
+
180
+ Braintrust.init
181
+
182
+ # Wrap RubyLLM globally (wraps all Chat instances)
183
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
184
+
185
+ tracer = OpenTelemetry.tracer_provider.tracer("ruby-llm-app")
186
+ root_span = nil
187
+
188
+ response = tracer.in_span("chat") do |span|
189
+ root_span = span
190
+
191
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
192
+ chat.ask("Say hello!")
193
+ end
194
+
195
+ puts "Response: #{response.content}"
196
+
197
+ puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
198
+
199
+ OpenTelemetry.tracer_provider.shutdown
200
+ ```
201
+
174
202
  ### Attachments
175
203
 
176
204
  Attachments allow you to log binary data (images, PDFs, audio, etc.) as part of your traces. This is particularly useful for multimodal AI applications like vision models.
@@ -236,7 +264,9 @@ Check out the [`examples/`](./examples/) directory for complete working examples
236
264
  - [eval.rb](./examples/eval.rb) - Create and run evaluations with custom test cases and scoring functions
237
265
  - [trace.rb](./examples/trace.rb) - Manual span creation and tracing
238
266
  - [openai.rb](./examples/openai.rb) - Automatically trace OpenAI API calls
267
+ - [alexrudall_openai.rb](./examples/alexrudall_openai.rb) - Automatically trace ruby-openai gem API calls
239
268
  - [anthropic.rb](./examples/anthropic.rb) - Automatically trace Anthropic API calls
269
+ - [ruby_llm.rb](./examples/ruby_llm.rb) - Automatically trace RubyLLM API calls
240
270
  - [trace/trace_attachments.rb](./examples/trace/trace_attachments.rb) - Log attachments (images, PDFs) in traces
241
271
  - [eval/dataset.rb](./examples/eval/dataset.rb) - Run evaluations using datasets stored in Braintrust
242
272
  - [eval/remote_functions.rb](./examples/eval/remote_functions.rb) - Use remote scoring functions
@@ -49,6 +49,20 @@ module Braintrust
49
49
  )
50
50
  end
51
51
 
52
+ # Create a State object directly with explicit parameters
53
+ # @param api_key [String] Braintrust API key (required)
54
+ # @param org_name [String, nil] Organization name
55
+ # @param org_id [String, nil] Organization ID (if provided, skips login - useful for testing)
56
+ # @param default_project [String, nil] Default project name
57
+ # @param app_url [String, nil] App URL (default: https://www.braintrust.dev)
58
+ # @param api_url [String, nil] API URL
59
+ # @param proxy_url [String, nil] Proxy URL
60
+ # @param blocking_login [Boolean] Login synchronously (default: false)
61
+ # @param enable_tracing [Boolean] Enable OpenTelemetry tracing (default: true)
62
+ # @param tracer_provider [TracerProvider, nil] Optional tracer provider
63
+ # @param config [Config, nil] Optional config object
64
+ # @param exporter [Exporter, nil] Optional exporter for testing
65
+ # @return [State] the created state
52
66
  def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, config: nil, exporter: nil)
53
67
  # Instance-level mutex for thread-safe login
54
68
  @login_mutex = Mutex.new
@@ -61,13 +75,17 @@ module Braintrust
61
75
  @app_url = app_url || "https://www.braintrust.dev"
62
76
  @api_url = api_url
63
77
  @proxy_url = proxy_url
64
- @logged_in = false
65
78
  @config = config
66
79
 
67
- # Perform login after state setup
68
- if blocking_login
80
+ # If org_id is provided, we're already "logged in" (useful for testing)
81
+ # Otherwise, perform login to discover org info
82
+ if org_id
83
+ @logged_in = true
84
+ elsif blocking_login
85
+ @logged_in = false
69
86
  login
70
87
  else
88
+ @logged_in = false
71
89
  login_in_thread
72
90
  end
73
91
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "opentelemetry/sdk"
4
4
  require "json"
5
+ require_relative "../tokens"
5
6
 
6
7
  module Braintrust
7
8
  module Trace
@@ -17,61 +18,11 @@ module Braintrust
17
18
  span.set_attribute(attr_name, JSON.generate(obj))
18
19
  end
19
20
 
20
- # Parse usage tokens from Anthropic API response, handling cache tokens
21
- # Maps Anthropic field names to Braintrust standard names:
22
- # - input_tokens → contributes to prompt_tokens
23
- # - cache_creation_input_tokens → prompt_cache_creation_tokens (and adds to prompt_tokens)
24
- # - cache_read_input_tokens → prompt_cached_tokens (and adds to prompt_tokens)
25
- # - output_tokens → completion_tokens
26
- # - total_tokens → tokens (or calculated if missing)
27
- #
21
+ # Parse usage tokens from Anthropic API response
28
22
  # @param usage [Hash, Object] usage object from Anthropic response
29
23
  # @return [Hash<String, Integer>] metrics hash with normalized names
30
24
  def self.parse_usage_tokens(usage)
31
- metrics = {}
32
- return metrics unless usage
33
-
34
- # Convert to hash if it's an object
35
- usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
36
-
37
- # Extract base values for calculation
38
- input_tokens = 0
39
- cache_creation_tokens = 0
40
- cache_read_tokens = 0
41
-
42
- usage_hash.each do |key, value|
43
- next unless value.is_a?(Numeric)
44
- key_str = key.to_s
45
-
46
- case key_str
47
- when "input_tokens"
48
- input_tokens = value.to_i
49
- when "cache_creation_input_tokens"
50
- cache_creation_tokens = value.to_i
51
- metrics["prompt_cache_creation_tokens"] = value.to_i
52
- when "cache_read_input_tokens"
53
- cache_read_tokens = value.to_i
54
- metrics["prompt_cached_tokens"] = value.to_i
55
- when "output_tokens"
56
- metrics["completion_tokens"] = value.to_i
57
- when "total_tokens"
58
- metrics["tokens"] = value.to_i
59
- else
60
- # Keep other numeric fields as-is (future-proofing)
61
- metrics[key_str] = value.to_i
62
- end
63
- end
64
-
65
- # Calculate total prompt tokens (input + cache creation + cache read)
66
- total_prompt_tokens = input_tokens + cache_creation_tokens + cache_read_tokens
67
- metrics["prompt_tokens"] = total_prompt_tokens
68
-
69
- # Calculate total tokens if not provided by Anthropic
70
- if !metrics.key?("tokens") && metrics.key?("completion_tokens")
71
- metrics["tokens"] = total_prompt_tokens + metrics["completion_tokens"]
72
- end
73
-
74
- metrics
25
+ Braintrust::Trace.parse_anthropic_usage_tokens(usage)
75
26
  end
76
27
 
77
28
  # Wrap an Anthropic::Client to automatically create spans for messages and responses
@@ -196,7 +147,6 @@ module Braintrust
196
147
  wrapper = Module.new do
197
148
  define_method(:stream) do |**params, &block|
198
149
  tracer = tracer_provider.tracer("braintrust")
199
- aggregated_events = []
200
150
 
201
151
  metadata = {
202
152
  "provider" => "anthropic",
@@ -256,183 +206,110 @@ module Braintrust
256
206
  end
257
207
 
258
208
  # Store references on the stream object itself for the wrapper
259
- stream.instance_variable_set(:@braintrust_aggregated_events, aggregated_events)
260
209
  stream.instance_variable_set(:@braintrust_span, span)
261
210
  stream.instance_variable_set(:@braintrust_metadata, metadata)
211
+ stream.instance_variable_set(:@braintrust_span_finished, false)
262
212
 
263
- # Wrap the stream to aggregate events
264
- original_each = stream.method(:each)
265
- stream.define_singleton_method(:each) do |&user_block|
266
- events = instance_variable_get(:@braintrust_aggregated_events)
267
- span_obj = instance_variable_get(:@braintrust_span)
268
- meta = instance_variable_get(:@braintrust_metadata)
269
-
270
- begin
271
- original_each.call do |event|
272
- # Store event data for aggregation
273
- events << event.to_h if event.respond_to?(:to_h)
274
- # Call user's block if provided
275
- user_block&.call(event)
276
- end
277
- rescue => e
278
- span_obj.record_exception(e)
279
- span_obj.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
280
- raise
281
- ensure
282
- # Always aggregate and finish span after stream completes
283
- unless events.empty?
284
- aggregated_output = Braintrust::Trace::Anthropic.aggregate_streaming_events(events)
285
-
286
- # Set output
287
- if aggregated_output[:content]
288
- output = [{
289
- role: "assistant",
290
- content: aggregated_output[:content]
291
- }]
292
- Braintrust::Trace::Anthropic.set_json_attr(span_obj, "braintrust.output_json", output)
293
- end
294
-
295
- # Set metrics if usage is available
296
- if aggregated_output[:usage]
297
- metrics = Braintrust::Trace::Anthropic.parse_usage_tokens(aggregated_output[:usage])
298
- Braintrust::Trace::Anthropic.set_json_attr(span_obj, "braintrust.metrics", metrics) unless metrics.empty?
299
- end
300
-
301
- # Update metadata with response fields
302
- meta["stop_reason"] = aggregated_output[:stop_reason] if aggregated_output[:stop_reason]
303
- meta["model"] = aggregated_output[:model] if aggregated_output[:model]
304
- Braintrust::Trace::Anthropic.set_json_attr(span_obj, "braintrust.metadata", meta)
305
- end
213
+ # Local helper for brevity
214
+ set_json_attr = ->(attr_name, obj) { Braintrust::Trace::Anthropic.set_json_attr(span, attr_name, obj) }
215
+
216
+ # Helper lambda to extract stream data and set span attributes
217
+ # This is DRY - used by both .each() and .text() wrappers
218
+ extract_stream_metadata = lambda do
219
+ # Extract the SDK's internal accumulated message (built during streaming)
220
+ acc_msg = stream.instance_variable_get(:@accumated_message_snapshot)
221
+ return unless acc_msg
306
222
 
307
- span_obj.finish
223
+ # Set output from accumulated message
224
+ if acc_msg.respond_to?(:content) && acc_msg.content
225
+ content_array = acc_msg.content.map(&:to_h)
226
+ output = [{
227
+ role: acc_msg.respond_to?(:role) ? acc_msg.role : "assistant",
228
+ content: content_array
229
+ }]
230
+ set_json_attr.call("braintrust.output_json", output)
308
231
  end
309
- end
310
232
 
311
- # If a block was provided to stream(), call each with it immediately
312
- if block
313
- stream.each(&block)
233
+ # Set metrics from accumulated message
234
+ if acc_msg.respond_to?(:usage) && acc_msg.usage
235
+ metrics = Braintrust::Trace::Anthropic.parse_usage_tokens(acc_msg.usage)
236
+ set_json_attr.call("braintrust.metrics", metrics) unless metrics.empty?
237
+ end
238
+
239
+ # Update metadata with response fields
240
+ if acc_msg.respond_to?(:stop_reason) && acc_msg.stop_reason
241
+ metadata["stop_reason"] = acc_msg.stop_reason
242
+ end
243
+ if acc_msg.respond_to?(:model) && acc_msg.model
244
+ metadata["model"] = acc_msg.model
245
+ end
246
+ set_json_attr.call("braintrust.metadata", metadata)
314
247
  end
315
248
 
316
- stream
317
- end
318
- end
249
+ # Helper lambda to finish span (prevents double-finishing via closure)
250
+ finish_braintrust_span = lambda do
251
+ return if stream.instance_variable_get(:@braintrust_span_finished)
252
+ stream.instance_variable_set(:@braintrust_span_finished, true)
319
253
 
320
- # Prepend the wrapper to the messages resource
321
- client.messages.singleton_class.prepend(wrapper)
322
- end
254
+ extract_stream_metadata.call
255
+ span.finish
256
+ end
323
257
 
324
- # Aggregate streaming events into a single response structure
325
- # @param events [Array<Hash>] array of event hashes from stream
326
- # @return [Hash] aggregated response with content, usage, etc.
327
- def self.aggregate_streaming_events(events)
328
- return {} if events.empty?
329
-
330
- result = {
331
- content: [],
332
- usage: {},
333
- stop_reason: nil,
334
- model: nil
335
- }
336
-
337
- # Track content blocks by index
338
- content_blocks = {}
339
- content_builders = {}
340
-
341
- events.each do |event|
342
- event_type = event[:type] || event["type"]
343
- next unless event_type
344
-
345
- case event_type
346
- when "message_start"
347
- # Extract model and initial usage (input tokens, cache tokens)
348
- message = event[:message] || event["message"]
349
- if message
350
- result[:model] = message[:model] || message["model"]
351
- if message[:usage] || message["usage"]
352
- usage = message[:usage] || message["usage"]
353
- result[:usage].merge!(usage)
354
- end
258
+ # Wrap .each() to ensure span finishes after consumption
259
+ original_each = stream.method(:each)
260
+ stream.define_singleton_method(:each) do |&user_block|
261
+ # Consume stream, calling user's block for each event
262
+ # The SDK builds @accumated_message_snapshot internally
263
+ original_each.call(&user_block)
264
+ rescue => e
265
+ span.record_exception(e)
266
+ span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
267
+ raise
268
+ ensure
269
+ # Extract accumulated message and finish span
270
+ finish_braintrust_span.call
355
271
  end
356
272
 
357
- when "content_block_start"
358
- # Initialize a new content block
359
- index = event[:index] || event["index"]
360
- content_block = event[:content_block] || event["content_block"]
361
- content_blocks[index] = content_block if index && content_block
362
-
363
- when "content_block_delta"
364
- # Accumulate deltas for content blocks
365
- index = event[:index] || event["index"]
366
- delta = event[:delta] || event["delta"]
367
- next unless index && delta
368
-
369
- delta_type = delta[:type] || delta["type"]
370
- content_blocks[index] ||= {}
371
-
372
- case delta_type
373
- when "text_delta"
374
- # Accumulate text
375
- text = delta[:text] || delta["text"]
376
- if text
377
- content_builders[index] ||= ""
378
- content_builders[index] += text
379
- content_blocks[index][:type] = "text"
380
- content_blocks[index]["type"] = "text"
381
- end
273
+ # Wrap .text() to return an Enumerable that ensures span finishes
274
+ original_text = stream.method(:text)
275
+ stream.define_singleton_method(:text) do
276
+ text_enum = original_text.call
382
277
 
383
- when "input_json_delta"
384
- # Accumulate JSON for tool_use blocks
385
- partial_json = delta[:partial_json] || delta["partial_json"]
386
- if partial_json
387
- content_builders[index] ||= ""
388
- content_builders[index] += partial_json
389
- content_blocks[index][:type] = "tool_use"
390
- content_blocks[index]["type"] = "tool_use"
278
+ # Return wrapper Enumerable that finishes span after consumption
279
+ Enumerator.new do |y|
280
+ # Consume text enumerable (this consumes underlying stream)
281
+ # The SDK builds @accumated_message_snapshot internally
282
+ text_enum.each { |text| y << text }
283
+ rescue => e
284
+ span.record_exception(e)
285
+ span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
286
+ raise
287
+ ensure
288
+ # Extract accumulated message and finish span
289
+ finish_braintrust_span.call
391
290
  end
392
291
  end
393
292
 
394
- when "message_delta"
395
- # Get final stop reason and cumulative usage (output tokens)
396
- delta = event[:delta] || event["delta"]
397
- if delta
398
- stop_reason = delta[:stop_reason] || delta["stop_reason"]
399
- result[:stop_reason] = stop_reason if stop_reason
293
+ # Wrap .close() to ensure span finishes even if stream not consumed
294
+ original_close = stream.method(:close)
295
+ stream.define_singleton_method(:close) do
296
+ original_close.call
297
+ ensure
298
+ # Finish span even if stream was closed early
299
+ finish_braintrust_span.call
400
300
  end
401
301
 
402
- usage = event[:usage] || event["usage"]
403
- result[:usage].merge!(usage) if usage
404
- end
405
- end
406
-
407
- # Build final content array from aggregated blocks
408
- content_builders.each do |index, text|
409
- block = content_blocks[index]
410
- next unless block
411
-
412
- block_type = block[:type] || block["type"]
413
- case block_type
414
- when "text"
415
- block[:text] = text
416
- block["text"] = text
417
- when "tool_use"
418
- # Parse the accumulated JSON string
419
- begin
420
- parsed = JSON.parse(text)
421
- block[:input] = parsed
422
- block["input"] = parsed
423
- rescue JSON::ParserError
424
- block[:input] = text
425
- block["input"] = text
302
+ # If a block was provided to stream(), call each with it immediately
303
+ if block
304
+ stream.each(&block)
426
305
  end
427
- end
428
- end
429
306
 
430
- # Convert blocks hash to sorted array
431
- if content_blocks.any?
432
- result[:content] = content_blocks.keys.sort.map { |idx| content_blocks[idx] }
307
+ stream
308
+ end
433
309
  end
434
310
 
435
- result
311
+ # Prepend the wrapper to the messages resource
312
+ client.messages.singleton_class.prepend(wrapper)
436
313
  end
437
314
  end
438
315
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+ require_relative "../../../../tokens"
6
+
7
+ module Braintrust
8
+ module Trace
9
+ module Contrib
10
+ module Github
11
+ module Alexrudall
12
+ module RubyOpenAI
13
+ # Helper to safely set a JSON attribute on a span
14
+ # Only sets the attribute if obj is present
15
+ # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
16
+ # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
17
+ # @param obj [Object] the object to serialize to JSON
18
+ # @return [void]
19
+ def self.set_json_attr(span, attr_name, obj)
20
+ return unless obj
21
+ span.set_attribute(attr_name, JSON.generate(obj))
22
+ end
23
+
24
+ # Parse usage tokens from OpenAI API response
25
+ # @param usage [Hash] usage hash from OpenAI response
26
+ # @return [Hash<String, Integer>] metrics hash with normalized names
27
+ def self.parse_usage_tokens(usage)
28
+ Braintrust::Trace.parse_openai_usage_tokens(usage)
29
+ end
30
+
31
+ # Wrap an OpenAI::Client (ruby-openai gem) to automatically create spans
32
+ # Supports both synchronous and streaming requests
33
+ # @param client [OpenAI::Client] the OpenAI client to wrap
34
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
35
+ def self.wrap(client, tracer_provider: nil)
36
+ tracer_provider ||= ::OpenTelemetry.tracer_provider
37
+
38
+ # Wrap chat completions
39
+ wrap_chat(client, tracer_provider)
40
+
41
+ client
42
+ end
43
+
44
+ # Wrap chat API
45
+ # @param client [OpenAI::Client] the OpenAI client
46
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
47
+ def self.wrap_chat(client, tracer_provider)
48
+ # Create a wrapper module that intercepts the chat method
49
+ wrapper = Module.new do
50
+ define_method(:chat) do |parameters:|
51
+ tracer = tracer_provider.tracer("braintrust")
52
+
53
+ tracer.in_span("openai.chat.completions.create") do |span|
54
+ # Initialize metadata hash
55
+ metadata = {
56
+ "provider" => "openai",
57
+ "endpoint" => "/v1/chat/completions"
58
+ }
59
+
60
+ # Capture request metadata fields
61
+ metadata_fields = %w[
62
+ model frequency_penalty logit_bias logprobs max_tokens n
63
+ presence_penalty response_format seed service_tier stop
64
+ stream stream_options temperature top_p top_logprobs
65
+ tools tool_choice parallel_tool_calls user functions function_call
66
+ ]
67
+
68
+ metadata_fields.each do |field|
69
+ field_sym = field.to_sym
70
+ if parameters.key?(field_sym)
71
+ # Special handling for stream parameter (it's a Proc)
72
+ metadata[field] = if field == "stream"
73
+ true # Just mark as streaming
74
+ else
75
+ parameters[field_sym]
76
+ end
77
+ end
78
+ end
79
+
80
+ # Set input messages as JSON
81
+ if parameters[:messages]
82
+ span.set_attribute("braintrust.input_json", JSON.generate(parameters[:messages]))
83
+ end
84
+
85
+ begin
86
+ # Call the original method
87
+ response = super(parameters: parameters)
88
+
89
+ # Set output (choices) as JSON
90
+ if response && response["choices"]&.any?
91
+ span.set_attribute("braintrust.output_json", JSON.generate(response["choices"]))
92
+ end
93
+
94
+ # Set metrics (token usage)
95
+ if response && response["usage"]
96
+ metrics = Braintrust::Trace::Contrib::Github::Alexrudall::RubyOpenAI.parse_usage_tokens(response["usage"])
97
+ span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
98
+ end
99
+
100
+ # Add response metadata fields
101
+ if response
102
+ metadata["id"] = response["id"] if response["id"]
103
+ metadata["created"] = response["created"] if response["created"]
104
+ metadata["system_fingerprint"] = response["system_fingerprint"] if response["system_fingerprint"]
105
+ metadata["service_tier"] = response["service_tier"] if response["service_tier"]
106
+ end
107
+
108
+ # Set metadata ONCE at the end with complete hash
109
+ span.set_attribute("braintrust.metadata", JSON.generate(metadata))
110
+
111
+ response
112
+ rescue => e
113
+ # Record exception in span
114
+ span.record_exception(e)
115
+ span.status = OpenTelemetry::Trace::Status.error("Exception: #{e.class} - #{e.message}")
116
+ raise
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Prepend the wrapper to the client's singleton class
123
+ client.singleton_class.prepend(wrapper)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ # Backwards compatibility: this module was originally at Braintrust::Trace::AlexRudall::RubyOpenAI
131
+ module AlexRudall
132
+ RubyOpenAI = Contrib::Github::Alexrudall::RubyOpenAI
133
+ end
134
+ end
135
+ end