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 +4 -4
- data/README.md +31 -1
- data/lib/braintrust/state.rb +21 -3
- data/lib/braintrust/trace/contrib/anthropic.rb +85 -208
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +135 -0
- data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +447 -0
- data/lib/braintrust/trace/contrib/openai.rb +121 -68
- data/lib/braintrust/trace/tokens.rb +101 -0
- data/lib/braintrust/trace.rb +38 -3
- data/lib/braintrust/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 866cb2e797502f00cda1625ad90f4d734b4b83f0d21d8243675a933fae9df693
|
|
4
|
+
data.tar.gz: f74151b0e18b12cf19b61b1b75b2f58e784d4171f21c0996526d29c719174260
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
data/lib/braintrust/state.rb
CHANGED
|
@@ -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
|
-
#
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
254
|
+
extract_stream_metadata.call
|
|
255
|
+
span.finish
|
|
256
|
+
end
|
|
323
257
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
result[:content] = content_blocks.keys.sort.map { |idx| content_blocks[idx] }
|
|
307
|
+
stream
|
|
308
|
+
end
|
|
433
309
|
end
|
|
434
310
|
|
|
435
|
-
|
|
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
|