braintrust 0.0.5 → 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: 6321acf7b780922ed97ea3cc57dde47a52947a10650a082dcfd9af780056d99a
4
- data.tar.gz: 67c181e53537829931de704c7503cc056646652f9c1a61d914bc1ee0b7af69a2
3
+ metadata.gz: 866cb2e797502f00cda1625ad90f4d734b4b83f0d21d8243675a933fae9df693
4
+ data.tar.gz: f74151b0e18b12cf19b61b1b75b2f58e784d4171f21c0996526d29c719174260
5
5
  SHA512:
6
- metadata.gz: bb8546fdbf0a448016a1d31ceb8729a40be59e0d8d081ef275f763a11dbb2f5df0134ec52fc3b1c15c41d9dcdf42fbbe6becaf00ab7ac882c8f2f7e173a9a61f
7
- data.tar.gz: 41e6d13504302a3b3ec26697cb50ce4736040d910c8e293d37088311922daa77274fc4214c86543ca21209bf56e82b2f6f00d5fbc2d7d0d0baad6f1e77cc48ff
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
@@ -2,140 +2,134 @@
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
8
- module AlexRudall
9
- module RubyOpenAI
10
- # Helper to safely set a JSON attribute on a span
11
- # Only sets the attribute if obj is present
12
- # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
13
- # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
14
- # @param obj [Object] the object to serialize to JSON
15
- # @return [void]
16
- def self.set_json_attr(span, attr_name, obj)
17
- return unless obj
18
- span.set_attribute(attr_name, JSON.generate(obj))
19
- end
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
20
23
 
21
- # Parse usage tokens from OpenAI API response, handling nested token_details
22
- # Maps OpenAI field names to Braintrust standard names:
23
- # - prompt_tokens prompt_tokens
24
- # - completion_tokens → completion_tokens
25
- # - total_tokens → tokens
26
- #
27
- # @param usage [Hash] usage hash from OpenAI response
28
- # @return [Hash<String, Integer>] metrics hash with normalized names
29
- def self.parse_usage_tokens(usage)
30
- metrics = {}
31
- return metrics unless usage
32
-
33
- # Basic token counts
34
- metrics["prompt_tokens"] = usage["prompt_tokens"].to_i if usage["prompt_tokens"]
35
- metrics["completion_tokens"] = usage["completion_tokens"].to_i if usage["completion_tokens"]
36
- metrics["total_tokens"] = usage["total_tokens"].to_i if usage["total_tokens"]
37
-
38
- # Rename total_tokens to tokens for consistency
39
- metrics["tokens"] = metrics.delete("total_tokens") if metrics["total_tokens"]
40
-
41
- metrics
42
- end
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
43
30
 
44
- # Wrap an OpenAI::Client (ruby-openai gem) to automatically create spans
45
- # Supports both synchronous and streaming requests
46
- # @param client [OpenAI::Client] the OpenAI client to wrap
47
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
48
- def self.wrap(client, tracer_provider: nil)
49
- tracer_provider ||= ::OpenTelemetry.tracer_provider
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
50
37
 
51
- # Wrap chat completions
52
- wrap_chat(client, tracer_provider)
38
+ # Wrap chat completions
39
+ wrap_chat(client, tracer_provider)
53
40
 
54
- client
55
- end
41
+ client
42
+ end
56
43
 
57
- # Wrap chat API
58
- # @param client [OpenAI::Client] the OpenAI client
59
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
60
- def self.wrap_chat(client, tracer_provider)
61
- # Create a wrapper module that intercepts the chat method
62
- wrapper = Module.new do
63
- define_method(:chat) do |parameters:|
64
- tracer = tracer_provider.tracer("braintrust")
65
-
66
- tracer.in_span("openai.chat.completions.create") do |span|
67
- # Initialize metadata hash
68
- metadata = {
69
- "provider" => "openai",
70
- "endpoint" => "/v1/chat/completions"
71
- }
72
-
73
- # Capture request metadata fields
74
- metadata_fields = %w[
75
- model frequency_penalty logit_bias logprobs max_tokens n
76
- presence_penalty response_format seed service_tier stop
77
- stream stream_options temperature top_p top_logprobs
78
- tools tool_choice parallel_tool_calls user functions function_call
79
- ]
80
-
81
- metadata_fields.each do |field|
82
- field_sym = field.to_sym
83
- if parameters.key?(field_sym)
84
- # Special handling for stream parameter (it's a Proc)
85
- metadata[field] = if field == "stream"
86
- true # Just mark as streaming
87
- else
88
- parameters[field_sym]
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
89
78
  end
90
- end
91
- end
92
-
93
- # Set input messages as JSON
94
- if parameters[:messages]
95
- span.set_attribute("braintrust.input_json", JSON.generate(parameters[:messages]))
96
- end
97
-
98
- begin
99
- # Call the original method
100
- response = super(parameters: parameters)
101
79
 
102
- # Set output (choices) as JSON
103
- if response && response["choices"]&.any?
104
- span.set_attribute("braintrust.output_json", JSON.generate(response["choices"]))
105
- end
106
-
107
- # Set metrics (token usage)
108
- if response && response["usage"]
109
- metrics = Braintrust::Trace::AlexRudall::RubyOpenAI.parse_usage_tokens(response["usage"])
110
- span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
111
- end
80
+ # Set input messages as JSON
81
+ if parameters[:messages]
82
+ span.set_attribute("braintrust.input_json", JSON.generate(parameters[:messages]))
83
+ end
112
84
 
113
- # Add response metadata fields
114
- if response
115
- metadata["id"] = response["id"] if response["id"]
116
- metadata["created"] = response["created"] if response["created"]
117
- metadata["system_fingerprint"] = response["system_fingerprint"] if response["system_fingerprint"]
118
- metadata["service_tier"] = response["service_tier"] if response["service_tier"]
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
119
118
  end
120
-
121
- # Set metadata ONCE at the end with complete hash
122
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
123
-
124
- response
125
- rescue => e
126
- # Record exception in span
127
- span.record_exception(e)
128
- span.status = OpenTelemetry::Trace::Status.error("Exception: #{e.class} - #{e.message}")
129
- raise
130
119
  end
131
120
  end
121
+
122
+ # Prepend the wrapper to the client's singleton class
123
+ client.singleton_class.prepend(wrapper)
132
124
  end
133
125
  end
134
-
135
- # Prepend the wrapper to the client's singleton class
136
- client.singleton_class.prepend(wrapper)
137
126
  end
138
127
  end
139
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
140
134
  end
141
135
  end
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+ require_relative "../../../tokens"
6
+ require_relative "../../../../logger"
7
+
8
+ module Braintrust
9
+ module Trace
10
+ module Contrib
11
+ module Github
12
+ module Crmne
13
+ module RubyLLM
14
+ # Helper to safely set a JSON attribute on a span
15
+ # Only sets the attribute if obj is present
16
+ # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
17
+ # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
18
+ # @param obj [Object] the object to serialize to JSON
19
+ # @return [void]
20
+ def self.set_json_attr(span, attr_name, obj)
21
+ return unless obj
22
+ span.set_attribute(attr_name, JSON.generate(obj))
23
+ rescue => e
24
+ Log.debug("Failed to serialize #{attr_name}: #{e.message}")
25
+ end
26
+
27
+ # Parse usage tokens from RubyLLM response
28
+ # RubyLLM uses Anthropic-style field naming (input_tokens, output_tokens)
29
+ # @param usage [Hash, Object] usage object from RubyLLM response
30
+ # @return [Hash<String, Integer>] metrics hash with normalized names
31
+ def self.parse_usage_tokens(usage)
32
+ Braintrust::Trace.parse_anthropic_usage_tokens(usage)
33
+ end
34
+
35
+ # Wrap RubyLLM to automatically create spans for chat requests
36
+ # Supports both synchronous and streaming requests
37
+ #
38
+ # Usage:
39
+ # # Wrap the class once (affects all future instances):
40
+ # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
41
+ #
42
+ # # Or wrap a specific instance:
43
+ # chat = RubyLLM.chat(model: "gpt-4o-mini")
44
+ # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(chat)
45
+ #
46
+ # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to wrap (if nil, wraps the class)
47
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
48
+ def self.wrap(chat = nil, tracer_provider: nil)
49
+ tracer_provider ||= ::OpenTelemetry.tracer_provider
50
+
51
+ # If no chat instance provided, wrap the class globally via initialize hook
52
+ if chat.nil?
53
+ return if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
54
+
55
+ # Create module that wraps initialize to auto-wrap each new instance
56
+ wrapper_module = Module.new do
57
+ define_method(:initialize) do |*args, **kwargs, &block|
58
+ super(*args, **kwargs, &block)
59
+ # Auto-wrap this instance during initialization
60
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(self, tracer_provider: tracer_provider)
61
+ self
62
+ end
63
+ end
64
+
65
+ # Store reference to wrapper module for cleanup
66
+ ::RubyLLM::Chat.instance_variable_set(:@braintrust_wrapper_module, wrapper_module)
67
+ ::RubyLLM::Chat.prepend(wrapper_module)
68
+ return nil
69
+ end
70
+
71
+ # Check if already wrapped to make this idempotent
72
+ return chat if chat.instance_variable_get(:@braintrust_wrapped)
73
+
74
+ # Create a wrapper module that intercepts chat.ask
75
+ wrapper = create_wrapper_module(tracer_provider)
76
+
77
+ # Mark as wrapped and prepend the wrapper to the chat instance
78
+ chat.instance_variable_set(:@braintrust_wrapped, true)
79
+ chat.singleton_class.prepend(wrapper)
80
+ chat
81
+ end
82
+
83
+ # Unwrap RubyLLM to remove Braintrust tracing
84
+ # For class-level unwrapping, removes the initialize override from the wrapper module
85
+ # For instance-level unwrapping, clears the wrapped flag
86
+ #
87
+ # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to unwrap (if nil, unwraps the class)
88
+ def self.unwrap(chat = nil)
89
+ # If no chat instance provided, unwrap the class globally
90
+ if chat.nil?
91
+ if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
92
+ wrapper_module = ::RubyLLM::Chat.instance_variable_get(:@braintrust_wrapper_module)
93
+ # Redefine initialize to just call super (disables auto-wrapping)
94
+ # We can't actually remove a prepended module, so we make it a no-op
95
+ wrapper_module.module_eval do
96
+ define_method(:initialize) do |*args, **kwargs, &block|
97
+ super(*args, **kwargs, &block)
98
+ end
99
+ end
100
+ ::RubyLLM::Chat.remove_instance_variable(:@braintrust_wrapper_module)
101
+ end
102
+ return nil
103
+ end
104
+
105
+ # Unwrap instance
106
+ chat.remove_instance_variable(:@braintrust_wrapped) if chat.instance_variable_defined?(:@braintrust_wrapped)
107
+ chat
108
+ end
109
+
110
+ # Wrap the RubyLLM::Chat class globally
111
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
112
+ def self.wrap_class(tracer_provider)
113
+ return unless defined?(::RubyLLM::Chat)
114
+
115
+ wrapper = create_wrapper_module(tracer_provider)
116
+ ::RubyLLM::Chat.prepend(wrapper)
117
+ end
118
+
119
+ # Create the wrapper module that intercepts chat.ask
120
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
121
+ # @return [Module] the wrapper module
122
+ def self.create_wrapper_module(tracer_provider)
123
+ Module.new do
124
+ define_method(:ask) do |prompt = nil, **params, &block|
125
+ tracer = tracer_provider.tracer("braintrust")
126
+
127
+ if block
128
+ # Handle streaming request
129
+ wrapped_block = proc do |chunk|
130
+ block.call(chunk)
131
+ end
132
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_streaming_ask(self, tracer, prompt, params, block) do |aggregated_chunks|
133
+ super(prompt, **params) do |chunk|
134
+ aggregated_chunks << chunk
135
+ wrapped_block.call(chunk)
136
+ end
137
+ end
138
+ else
139
+ # Handle non-streaming request
140
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_non_streaming_ask(self, tracer, prompt, params) do
141
+ super(prompt, **params)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # Handle streaming chat request with tracing
149
+ # @param chat [RubyLLM::Chat] the chat instance
150
+ # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
151
+ # @param prompt [String, nil] the user prompt
152
+ # @param params [Hash] additional parameters
153
+ # @param block [Proc] the streaming block
154
+ def self.handle_streaming_ask(chat, tracer, prompt, params, block)
155
+ # Start span immediately for accurate timing
156
+ span = tracer.start_span("ruby_llm.chat.ask")
157
+
158
+ aggregated_chunks = []
159
+
160
+ # Extract metadata and build input messages
161
+ metadata = extract_metadata(chat, stream: true)
162
+ input_messages = build_input_messages(chat, prompt)
163
+
164
+ # Set input and metadata
165
+ set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
166
+ set_json_attr(span, "braintrust.metadata", metadata)
167
+
168
+ # Call original method, passing aggregated_chunks to the block
169
+ begin
170
+ result = yield aggregated_chunks
171
+ rescue => e
172
+ span.record_exception(e)
173
+ span.status = ::OpenTelemetry::Trace::Status.error("RubyLLM error: #{e.message}")
174
+ span.finish
175
+ raise
176
+ end
177
+
178
+ # Set output and metrics from aggregated chunks
179
+ capture_streaming_output(span, aggregated_chunks, result)
180
+ span.finish
181
+ result
182
+ end
183
+
184
+ # Handle non-streaming chat request with tracing
185
+ # @param chat [RubyLLM::Chat] the chat instance
186
+ # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
187
+ # @param prompt [String, nil] the user prompt
188
+ # @param params [Hash] additional parameters
189
+ def self.handle_non_streaming_ask(chat, tracer, prompt, params)
190
+ # Start span immediately for accurate timing
191
+ span = tracer.start_span("ruby_llm.chat.ask")
192
+
193
+ begin
194
+ # Extract metadata and build input messages
195
+ metadata = extract_metadata(chat)
196
+ input_messages = build_input_messages(chat, prompt)
197
+ set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
198
+
199
+ # Remember message count before the call (for tool call detection)
200
+ messages_before_count = (chat.respond_to?(:messages) && chat.messages) ? chat.messages.length : 0
201
+
202
+ # Call the original method
203
+ response = yield
204
+
205
+ # Capture output and metrics
206
+ capture_non_streaming_output(span, chat, response, messages_before_count)
207
+
208
+ # Set metadata
209
+ set_json_attr(span, "braintrust.metadata", metadata)
210
+
211
+ response
212
+ ensure
213
+ span.finish
214
+ end
215
+ end
216
+
217
+ # Extract metadata from chat instance (provider, model, tools, stream flag)
218
+ # @param chat [RubyLLM::Chat] the chat instance
219
+ # @param stream [Boolean] whether this is a streaming request
220
+ # @return [Hash] metadata hash
221
+ def self.extract_metadata(chat, stream: false)
222
+ metadata = {"provider" => "ruby_llm"}
223
+ metadata["stream"] = true if stream
224
+
225
+ # Extract model
226
+ if chat.respond_to?(:model) && chat.model
227
+ model = chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
228
+ metadata["model"] = model
229
+ end
230
+
231
+ # Extract tools (only for non-streaming)
232
+ if !stream && chat.respond_to?(:tools) && chat.tools&.any?
233
+ metadata["tools"] = extract_tools_metadata(chat)
234
+ end
235
+
236
+ metadata
237
+ end
238
+
239
+ # Extract tools metadata from chat instance
240
+ # @param chat [RubyLLM::Chat] the chat instance
241
+ # @return [Array<Hash>] array of tool schemas
242
+ def self.extract_tools_metadata(chat)
243
+ provider = chat.instance_variable_get(:@provider) if chat.instance_variable_defined?(:@provider)
244
+
245
+ chat.tools.map do |_name, tool|
246
+ format_tool_schema(tool, provider)
247
+ end
248
+ end
249
+
250
+ # Format a tool into OpenAI-compatible schema
251
+ # @param tool [Object] the tool object
252
+ # @param provider [Object, nil] the provider instance
253
+ # @return [Hash] tool schema
254
+ def self.format_tool_schema(tool, provider)
255
+ tool_schema = nil
256
+
257
+ # Use provider-specific tool_for method if available
258
+ if provider
259
+ begin
260
+ tool_schema = if provider.is_a?(::RubyLLM::Providers::OpenAI)
261
+ ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
262
+ elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
263
+ ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
264
+ elsif tool.respond_to?(:params_schema) && tool.params_schema
265
+ build_basic_tool_schema(tool)
266
+ else
267
+ build_minimal_tool_schema(tool)
268
+ end
269
+ rescue NameError, ArgumentError => e
270
+ # If provider-specific tool_for fails, fall back to basic format
271
+ Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
272
+ tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
273
+ end
274
+ else
275
+ # No provider, use basic format with params_schema if available
276
+ tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
277
+ end
278
+
279
+ # Strip RubyLLM-specific fields to match native OpenAI format
280
+ # Handle both symbol and string keys
281
+ function_key = tool_schema&.key?(:function) ? :function : "function"
282
+ if tool_schema && tool_schema[function_key]
283
+ tool_params = tool_schema[function_key][:parameters] || tool_schema[function_key]["parameters"]
284
+ if tool_params.is_a?(Hash)
285
+ tool_params.delete("strict")
286
+ tool_params.delete(:strict)
287
+ tool_params.delete("additionalProperties")
288
+ tool_params.delete(:additionalProperties)
289
+ end
290
+ end
291
+
292
+ tool_schema
293
+ end
294
+
295
+ # Build a basic tool schema with parameters
296
+ # @param tool [Object] the tool object
297
+ # @return [Hash] tool schema
298
+ def self.build_basic_tool_schema(tool)
299
+ {
300
+ "type" => "function",
301
+ "function" => {
302
+ "name" => tool.name.to_s,
303
+ "description" => tool.description,
304
+ "parameters" => tool.params_schema
305
+ }
306
+ }
307
+ end
308
+
309
+ # Build a minimal tool schema without parameters
310
+ # @param tool [Object] the tool object
311
+ # @return [Hash] tool schema
312
+ def self.build_minimal_tool_schema(tool)
313
+ {
314
+ "type" => "function",
315
+ "function" => {
316
+ "name" => tool.name.to_s,
317
+ "description" => tool.description,
318
+ "parameters" => {}
319
+ }
320
+ }
321
+ end
322
+
323
+ # Build input messages array from chat history and prompt
324
+ # @param chat [RubyLLM::Chat] the chat instance
325
+ # @param prompt [String, nil] the user prompt
326
+ # @return [Array<Hash>] array of message hashes
327
+ def self.build_input_messages(chat, prompt)
328
+ input_messages = []
329
+
330
+ # Add conversation history
331
+ if chat.respond_to?(:messages) && chat.messages&.any?
332
+ input_messages = chat.messages.map { |m| m.respond_to?(:to_h) ? m.to_h : m }
333
+ end
334
+
335
+ # Add current prompt
336
+ input_messages << {role: "user", content: prompt} if prompt
337
+
338
+ input_messages
339
+ end
340
+
341
+ # Capture streaming output and metrics
342
+ # @param span [OpenTelemetry::Trace::Span] the span
343
+ # @param aggregated_chunks [Array] the aggregated chunks
344
+ # @param result [Object] the result object
345
+ def self.capture_streaming_output(span, aggregated_chunks, result)
346
+ return if aggregated_chunks.empty?
347
+
348
+ # Aggregate content from chunks
349
+ aggregated_content = aggregated_chunks.map { |c|
350
+ c.respond_to?(:content) ? c.content : c.to_s
351
+ }.join
352
+
353
+ output = [{
354
+ role: "assistant",
355
+ content: aggregated_content
356
+ }]
357
+ set_json_attr(span, "braintrust.output_json", output)
358
+
359
+ # Try to extract usage from the result
360
+ if result.respond_to?(:usage) && result.usage
361
+ metrics = parse_usage_tokens(result.usage)
362
+ set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
363
+ end
364
+ end
365
+
366
+ # Capture non-streaming output and metrics
367
+ # @param span [OpenTelemetry::Trace::Span] the span
368
+ # @param chat [RubyLLM::Chat] the chat instance
369
+ # @param response [Object] the response object
370
+ # @param messages_before_count [Integer] message count before the call
371
+ def self.capture_non_streaming_output(span, chat, response, messages_before_count)
372
+ return unless response
373
+
374
+ # Build message object from response
375
+ message = {
376
+ "role" => "assistant",
377
+ "content" => nil
378
+ }
379
+
380
+ # Add content if it's a simple text response
381
+ if response.respond_to?(:content) && response.content && !response.content.empty?
382
+ message["content"] = response.content
383
+ end
384
+
385
+ # Check if there are tool calls in the messages history
386
+ if chat.respond_to?(:messages) && chat.messages
387
+ assistant_msg = chat.messages[(messages_before_count + 1)..].find { |m|
388
+ m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
389
+ }
390
+
391
+ if assistant_msg&.tool_calls&.any?
392
+ message["tool_calls"] = format_tool_calls(assistant_msg.tool_calls)
393
+ message["content"] = nil
394
+ end
395
+ end
396
+
397
+ # Format as OpenAI choices[] structure
398
+ output = [{
399
+ "index" => 0,
400
+ "message" => message,
401
+ "finish_reason" => message["tool_calls"] ? "tool_calls" : "stop"
402
+ }]
403
+
404
+ set_json_attr(span, "braintrust.output_json", output)
405
+
406
+ # Set metrics (token usage)
407
+ if response.respond_to?(:to_h)
408
+ response_hash = response.to_h
409
+ usage = {
410
+ "input_tokens" => response_hash[:input_tokens],
411
+ "output_tokens" => response_hash[:output_tokens],
412
+ "cached_tokens" => response_hash[:cached_tokens],
413
+ "cache_creation_tokens" => response_hash[:cache_creation_tokens]
414
+ }.compact
415
+
416
+ unless usage.empty?
417
+ metrics = parse_usage_tokens(usage)
418
+ set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
419
+ end
420
+ end
421
+ end
422
+
423
+ # Format tool calls into OpenAI format
424
+ # @param tool_calls [Hash, Array] the tool calls
425
+ # @return [Array<Hash>] formatted tool calls
426
+ def self.format_tool_calls(tool_calls)
427
+ tool_calls.map do |_id, tc|
428
+ # Ensure arguments is a JSON string (OpenAI format)
429
+ args = tc.arguments
430
+ args_string = args.is_a?(String) ? args : JSON.generate(args)
431
+
432
+ {
433
+ "id" => tc.id,
434
+ "type" => "function",
435
+ "function" => {
436
+ "name" => tc.name,
437
+ "arguments" => args_string
438
+ }
439
+ }
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
447
+ end
@@ -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,72 +18,11 @@ module Braintrust
17
18
  span.set_attribute(attr_name, JSON.generate(obj))
18
19
  end
19
20
 
20
- # Parse usage tokens from OpenAI API response, handling nested token_details
21
- # Maps OpenAI field names to Braintrust standard names:
22
- # - input_tokens → prompt_tokens
23
- # - output_tokens → completion_tokens
24
- # - total_tokens → tokens
25
- # - *_tokens_details.* → prefix_*
26
- #
21
+ # Parse usage tokens from OpenAI API response
27
22
  # @param usage [Hash, Object] usage object from OpenAI response
28
23
  # @return [Hash<String, Integer>] metrics hash with normalized names
29
24
  def self.parse_usage_tokens(usage)
30
- metrics = {}
31
- return metrics unless usage
32
-
33
- # Convert to hash if it's an object
34
- usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
35
-
36
- usage_hash.each do |key, value|
37
- key_str = key.to_s
38
-
39
- # Handle nested *_tokens_details objects
40
- if key_str.end_with?("_tokens_details")
41
- # Convert to hash if it's an object (OpenAI gem returns objects)
42
- details_hash = value.respond_to?(:to_h) ? value.to_h : value
43
- next unless details_hash.is_a?(Hash)
44
-
45
- # Extract prefix (e.g., "prompt" from "prompt_tokens_details")
46
- prefix = key_str.sub(/_tokens_details$/, "")
47
- # Translate "input" → "prompt", "output" → "completion"
48
- prefix = translate_metric_prefix(prefix)
49
-
50
- # Process nested fields (e.g., cached_tokens, reasoning_tokens)
51
- details_hash.each do |detail_key, detail_value|
52
- next unless detail_value.is_a?(Numeric)
53
- metrics["#{prefix}_#{detail_key}"] = detail_value.to_i
54
- end
55
- elsif value.is_a?(Numeric)
56
- # Handle top-level token fields
57
- case key_str
58
- when "input_tokens"
59
- metrics["prompt_tokens"] = value.to_i
60
- when "output_tokens"
61
- metrics["completion_tokens"] = value.to_i
62
- when "total_tokens"
63
- metrics["tokens"] = value.to_i
64
- else
65
- # Keep other numeric fields as-is (future-proofing)
66
- metrics[key_str] = value.to_i
67
- end
68
- end
69
- end
70
-
71
- metrics
72
- end
73
-
74
- # Translate metric prefix to be consistent between different API formats
75
- # @param prefix [String] the prefix to translate
76
- # @return [String] translated prefix
77
- def self.translate_metric_prefix(prefix)
78
- case prefix
79
- when "input"
80
- "prompt"
81
- when "output"
82
- "completion"
83
- else
84
- prefix
85
- end
25
+ Braintrust::Trace.parse_openai_usage_tokens(usage)
86
26
  end
87
27
 
88
28
  # Aggregate streaming chunks into a single response structure
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Trace
5
+ # Parse OpenAI usage tokens into normalized Braintrust metrics.
6
+ # Handles standard fields and *_tokens_details nested objects.
7
+ # @param usage [Hash, Object] usage object from OpenAI response
8
+ # @return [Hash<String, Integer>] normalized metrics
9
+ def self.parse_openai_usage_tokens(usage)
10
+ metrics = {}
11
+ return metrics unless usage
12
+
13
+ usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
14
+ return metrics unless usage_hash.is_a?(Hash)
15
+
16
+ # Field mappings: OpenAI → Braintrust
17
+ field_map = {
18
+ "prompt_tokens" => "prompt_tokens",
19
+ "completion_tokens" => "completion_tokens",
20
+ "total_tokens" => "tokens"
21
+ }
22
+
23
+ # Prefix mappings for *_tokens_details
24
+ prefix_map = {
25
+ "prompt" => "prompt",
26
+ "completion" => "completion"
27
+ }
28
+
29
+ usage_hash.each do |key, value|
30
+ key_str = key.to_s
31
+
32
+ if value.is_a?(Numeric)
33
+ target = field_map[key_str]
34
+ metrics[target] = value.to_i if target
35
+ elsif key_str.end_with?("_tokens_details")
36
+ # Convert to hash if it's an object (OpenAI SDK returns objects)
37
+ details_hash = value.respond_to?(:to_h) ? value.to_h : value
38
+ next unless details_hash.is_a?(Hash)
39
+
40
+ raw_prefix = key_str.sub(/_tokens_details$/, "")
41
+ prefix = prefix_map[raw_prefix] || raw_prefix
42
+ details_hash.each do |detail_key, detail_value|
43
+ next unless detail_value.is_a?(Numeric)
44
+ metrics["#{prefix}_#{detail_key}"] = detail_value.to_i
45
+ end
46
+ end
47
+ end
48
+
49
+ # Calculate total if missing
50
+ if !metrics.key?("tokens") && metrics.key?("prompt_tokens") && metrics.key?("completion_tokens")
51
+ metrics["tokens"] = metrics["prompt_tokens"] + metrics["completion_tokens"]
52
+ end
53
+
54
+ metrics
55
+ end
56
+
57
+ # Parse Anthropic usage tokens into normalized Braintrust metrics.
58
+ # Accumulates cache tokens into prompt_tokens and calculates total.
59
+ # @param usage [Hash, Object] usage object from Anthropic response
60
+ # @return [Hash<String, Integer>] normalized metrics
61
+ def self.parse_anthropic_usage_tokens(usage)
62
+ metrics = {}
63
+ return metrics unless usage
64
+
65
+ usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
66
+ return metrics unless usage_hash.is_a?(Hash)
67
+
68
+ # Field mappings: Anthropic → Braintrust
69
+ # Also handles RubyLLM's simplified cache field names
70
+ field_map = {
71
+ "input_tokens" => "prompt_tokens",
72
+ "output_tokens" => "completion_tokens",
73
+ "cache_read_input_tokens" => "prompt_cached_tokens",
74
+ "cache_creation_input_tokens" => "prompt_cache_creation_tokens",
75
+ # RubyLLM uses simplified names
76
+ "cached_tokens" => "prompt_cached_tokens",
77
+ "cache_creation_tokens" => "prompt_cache_creation_tokens"
78
+ }
79
+
80
+ usage_hash.each do |key, value|
81
+ next unless value.is_a?(Numeric)
82
+ key_str = key.to_s
83
+ target = field_map[key_str]
84
+ metrics[target] = value.to_i if target
85
+ end
86
+
87
+ # Accumulate cache tokens into prompt_tokens (matching TS/Python SDKs)
88
+ prompt_tokens = (metrics["prompt_tokens"] || 0) +
89
+ (metrics["prompt_cached_tokens"] || 0) +
90
+ (metrics["prompt_cache_creation_tokens"] || 0)
91
+ metrics["prompt_tokens"] = prompt_tokens if prompt_tokens > 0
92
+
93
+ # Calculate total
94
+ if metrics.key?("prompt_tokens") && metrics.key?("completion_tokens")
95
+ metrics["tokens"] = metrics["prompt_tokens"] + metrics["completion_tokens"]
96
+ end
97
+
98
+ metrics
99
+ end
100
+ end
101
+ end
@@ -40,6 +40,23 @@ rescue LoadError
40
40
  # Anthropic gem not installed - integration will not be available
41
41
  end
42
42
 
43
+ # RubyLLM integration is optional - automatically loaded if ruby_llm gem is available
44
+ #
45
+ # Usage:
46
+ # # Wrap the class once (affects all instances):
47
+ # Braintrust::Trace::RubyLLM.wrap
48
+ #
49
+ # # Or wrap a specific instance:
50
+ # chat = RubyLLM.chat(model: "gpt-4o-mini")
51
+ # Braintrust::Trace::RubyLLM.wrap(chat)
52
+ #
53
+ begin
54
+ require "ruby_llm"
55
+ require_relative "trace/contrib/github.com/crmne/ruby_llm"
56
+ rescue LoadError
57
+ # RubyLLM gem not installed - integration will not be available
58
+ end
59
+
43
60
  module Braintrust
44
61
  module Trace
45
62
  # Set up OpenTelemetry tracing with Braintrust
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.0.5"
4
+ VERSION = "0.0.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: braintrust
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -205,9 +205,11 @@ files:
205
205
  - lib/braintrust/trace/attachment.rb
206
206
  - lib/braintrust/trace/contrib/anthropic.rb
207
207
  - lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb
208
+ - lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb
208
209
  - lib/braintrust/trace/contrib/openai.rb
209
210
  - lib/braintrust/trace/span_filter.rb
210
211
  - lib/braintrust/trace/span_processor.rb
212
+ - lib/braintrust/trace/tokens.rb
211
213
  - lib/braintrust/version.rb
212
214
  homepage: https://github.com/braintrustdata/braintrust-sdk-ruby
213
215
  licenses: