braintrust 0.0.12 → 0.1.0

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +213 -180
  3. data/exe/braintrust +143 -0
  4. data/lib/braintrust/contrib/anthropic/deprecated.rb +24 -0
  5. data/lib/braintrust/contrib/anthropic/instrumentation/common.rb +53 -0
  6. data/lib/braintrust/contrib/anthropic/instrumentation/messages.rb +232 -0
  7. data/lib/braintrust/contrib/anthropic/integration.rb +53 -0
  8. data/lib/braintrust/contrib/anthropic/patcher.rb +62 -0
  9. data/lib/braintrust/contrib/context.rb +56 -0
  10. data/lib/braintrust/contrib/integration.rb +160 -0
  11. data/lib/braintrust/contrib/openai/deprecated.rb +22 -0
  12. data/lib/braintrust/contrib/openai/instrumentation/chat.rb +298 -0
  13. data/lib/braintrust/contrib/openai/instrumentation/common.rb +134 -0
  14. data/lib/braintrust/contrib/openai/instrumentation/responses.rb +187 -0
  15. data/lib/braintrust/contrib/openai/integration.rb +58 -0
  16. data/lib/braintrust/contrib/openai/patcher.rb +130 -0
  17. data/lib/braintrust/contrib/patcher.rb +76 -0
  18. data/lib/braintrust/contrib/rails/railtie.rb +16 -0
  19. data/lib/braintrust/contrib/registry.rb +107 -0
  20. data/lib/braintrust/contrib/ruby_llm/deprecated.rb +45 -0
  21. data/lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb +464 -0
  22. data/lib/braintrust/contrib/ruby_llm/instrumentation/common.rb +58 -0
  23. data/lib/braintrust/contrib/ruby_llm/integration.rb +54 -0
  24. data/lib/braintrust/contrib/ruby_llm/patcher.rb +44 -0
  25. data/lib/braintrust/contrib/ruby_openai/deprecated.rb +24 -0
  26. data/lib/braintrust/contrib/ruby_openai/instrumentation/chat.rb +149 -0
  27. data/lib/braintrust/contrib/ruby_openai/instrumentation/common.rb +138 -0
  28. data/lib/braintrust/contrib/ruby_openai/instrumentation/responses.rb +146 -0
  29. data/lib/braintrust/contrib/ruby_openai/integration.rb +58 -0
  30. data/lib/braintrust/contrib/ruby_openai/patcher.rb +85 -0
  31. data/lib/braintrust/contrib/setup.rb +168 -0
  32. data/lib/braintrust/contrib/support/openai.rb +72 -0
  33. data/lib/braintrust/contrib/support/otel.rb +23 -0
  34. data/lib/braintrust/contrib.rb +205 -0
  35. data/lib/braintrust/internal/env.rb +33 -0
  36. data/lib/braintrust/internal/time.rb +44 -0
  37. data/lib/braintrust/setup.rb +50 -0
  38. data/lib/braintrust/state.rb +5 -0
  39. data/lib/braintrust/trace.rb +0 -51
  40. data/lib/braintrust/version.rb +1 -1
  41. data/lib/braintrust.rb +10 -1
  42. metadata +38 -7
  43. data/lib/braintrust/trace/contrib/anthropic.rb +0 -316
  44. data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +0 -377
  45. data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +0 -631
  46. data/lib/braintrust/trace/contrib/openai.rb +0 -611
  47. data/lib/braintrust/trace/tokens.rb +0 -109
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+
6
+ require_relative "common"
7
+ require_relative "../../../internal/time"
8
+ require_relative "../../support/otel"
9
+ require_relative "../../support/openai"
10
+
11
+ module Braintrust
12
+ module Contrib
13
+ module OpenAI
14
+ module Instrumentation
15
+ # Chat completions instrumentation for OpenAI.
16
+ # Wraps create(), stream(), and stream_raw() methods to create spans.
17
+ module Chat
18
+ module Completions
19
+ def self.included(base)
20
+ base.prepend(InstanceMethods) unless applied?(base)
21
+ end
22
+
23
+ def self.applied?(base)
24
+ base.ancestors.include?(InstanceMethods)
25
+ end
26
+
27
+ METADATA_FIELDS = %i[
28
+ model frequency_penalty logit_bias logprobs max_tokens n
29
+ presence_penalty response_format seed service_tier stop
30
+ stream stream_options temperature top_p top_logprobs
31
+ tools tool_choice parallel_tool_calls user functions function_call
32
+ ].freeze
33
+
34
+ module InstanceMethods
35
+ # Wrap create method for non-streaming completions
36
+ def create(**params)
37
+ client = instance_variable_get(:@client)
38
+ tracer = Braintrust::Contrib.tracer_for(client)
39
+
40
+ tracer.in_span("Chat Completion") do |span|
41
+ metadata = build_metadata(params)
42
+
43
+ set_input(span, params)
44
+
45
+ response = nil
46
+ time_to_first_token = Braintrust::Internal::Time.measure do
47
+ response = super
48
+ end
49
+
50
+ set_output(span, response)
51
+ set_metrics(span, response, time_to_first_token)
52
+ finalize_metadata(span, metadata, response)
53
+
54
+ response
55
+ end
56
+ end
57
+
58
+ # Wrap stream_raw for streaming chat completions (returns Internal::Stream)
59
+ # Stores context on stream object for span creation during consumption
60
+ def stream_raw(**params)
61
+ client = instance_variable_get(:@client)
62
+ tracer = Braintrust::Contrib.tracer_for(client)
63
+ metadata = build_metadata(params, stream: true)
64
+
65
+ stream_obj = super
66
+ Braintrust::Contrib::Context.set!(stream_obj,
67
+ tracer: tracer,
68
+ params: params,
69
+ metadata: metadata,
70
+ completions_instance: self,
71
+ stream_type: :raw)
72
+ stream_obj
73
+ end
74
+
75
+ # Wrap stream for streaming chat completions (returns ChatCompletionStream)
76
+ # Stores context on stream object for span creation during consumption
77
+ def stream(**params)
78
+ client = instance_variable_get(:@client)
79
+ tracer = Braintrust::Contrib.tracer_for(client)
80
+ metadata = build_metadata(params, stream: true)
81
+
82
+ stream_obj = super
83
+ Braintrust::Contrib::Context.set!(stream_obj,
84
+ tracer: tracer,
85
+ params: params,
86
+ metadata: metadata,
87
+ completions_instance: self,
88
+ stream_type: :chat_completion)
89
+ stream_obj
90
+ end
91
+
92
+ private
93
+
94
+ def build_metadata(params, stream: false)
95
+ metadata = {
96
+ "provider" => "openai",
97
+ "endpoint" => "/v1/chat/completions"
98
+ }
99
+ metadata["stream"] = true if stream
100
+ Completions::METADATA_FIELDS.each do |field|
101
+ metadata[field.to_s] = params[field] if params.key?(field)
102
+ end
103
+ metadata
104
+ end
105
+
106
+ def set_input(span, params)
107
+ return unless params[:messages]
108
+
109
+ messages_array = params[:messages].map(&:to_h)
110
+ Support::OTel.set_json_attr(span, "braintrust.input_json", messages_array)
111
+ end
112
+
113
+ def set_output(span, response)
114
+ return unless response.respond_to?(:choices) && response.choices&.any?
115
+
116
+ choices_array = response.choices.map(&:to_h)
117
+ Support::OTel.set_json_attr(span, "braintrust.output_json", choices_array)
118
+ end
119
+
120
+ def set_metrics(span, response, time_to_first_token)
121
+ metrics = {}
122
+ if response.respond_to?(:usage) && response.usage
123
+ metrics = Support::OpenAI.parse_usage_tokens(response.usage)
124
+ end
125
+ metrics["time_to_first_token"] = time_to_first_token
126
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
127
+ end
128
+
129
+ def finalize_metadata(span, metadata, response)
130
+ metadata["id"] = response.id if response.respond_to?(:id) && response.id
131
+ metadata["created"] = response.created if response.respond_to?(:created) && response.created
132
+ metadata["model"] = response.model if response.respond_to?(:model) && response.model
133
+ metadata["system_fingerprint"] = response.system_fingerprint if response.respond_to?(:system_fingerprint) && response.system_fingerprint
134
+ metadata["service_tier"] = response.service_tier if response.respond_to?(:service_tier) && response.service_tier
135
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
136
+ end
137
+ end
138
+ end
139
+
140
+ # Instrumentation for ChatCompletionStream (returned by stream())
141
+ # Uses current_completion_snapshot for accumulated output
142
+ module ChatCompletionStream
143
+ def self.included(base)
144
+ base.prepend(InstanceMethods) unless applied?(base)
145
+ end
146
+
147
+ def self.applied?(base)
148
+ base.ancestors.include?(InstanceMethods)
149
+ end
150
+
151
+ module InstanceMethods
152
+ def each(&block)
153
+ ctx = Braintrust::Contrib::Context.from(self)
154
+ return super unless ctx&.[](:tracer) && !ctx[:consumed]
155
+
156
+ trace_consumption(ctx) { super(&block) }
157
+ end
158
+
159
+ def text
160
+ ctx = Braintrust::Contrib::Context.from(self)
161
+ return super unless ctx&.[](:tracer) && !ctx[:consumed]
162
+
163
+ original_enum = super
164
+ Enumerator.new do |y|
165
+ trace_consumption(ctx) do
166
+ original_enum.each { |t| y << t }
167
+ end
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def trace_consumption(ctx)
174
+ ctx[:consumed] = true
175
+
176
+ tracer = ctx[:tracer]
177
+ params = ctx[:params]
178
+ metadata = ctx[:metadata]
179
+ completions_instance = ctx[:completions_instance]
180
+ start_time = Braintrust::Internal::Time.measure
181
+
182
+ tracer.in_span("Chat Completion") do |span|
183
+ completions_instance.send(:set_input, span, params)
184
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
185
+
186
+ yield
187
+
188
+ finalize_stream_span(span, start_time, metadata, completions_instance)
189
+ end
190
+ end
191
+
192
+ def finalize_stream_span(span, start_time, metadata, completions_instance)
193
+ time_to_first_token = Braintrust::Internal::Time.measure(start_time)
194
+
195
+ begin
196
+ snapshot = current_completion_snapshot
197
+ return unless snapshot
198
+
199
+ # Set output from accumulated choices
200
+ if snapshot.choices&.any?
201
+ choices_array = snapshot.choices.map(&:to_h)
202
+ Support::OTel.set_json_attr(span, "braintrust.output_json", choices_array)
203
+ end
204
+
205
+ # Set metrics
206
+ metrics = {}
207
+ if snapshot.usage
208
+ metrics = Support::OpenAI.parse_usage_tokens(snapshot.usage)
209
+ end
210
+ metrics["time_to_first_token"] = time_to_first_token
211
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
212
+
213
+ # Update metadata with response fields
214
+ metadata["id"] = snapshot.id if snapshot.respond_to?(:id) && snapshot.id
215
+ metadata["created"] = snapshot.created if snapshot.respond_to?(:created) && snapshot.created
216
+ metadata["model"] = snapshot.model if snapshot.respond_to?(:model) && snapshot.model
217
+ metadata["system_fingerprint"] = snapshot.system_fingerprint if snapshot.respond_to?(:system_fingerprint) && snapshot.system_fingerprint
218
+ metadata["service_tier"] = snapshot.service_tier if snapshot.respond_to?(:service_tier) && snapshot.service_tier
219
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
220
+ rescue => e
221
+ Braintrust::Log.debug("Failed to get completion snapshot: #{e.message}")
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ # Instrumentation for Internal::Stream (returned by stream_raw())
228
+ # Aggregates chunks manually since Internal::Stream has no built-in accumulation
229
+ module InternalStream
230
+ def self.included(base)
231
+ base.prepend(InstanceMethods) unless applied?(base)
232
+ end
233
+
234
+ def self.applied?(base)
235
+ base.ancestors.include?(InstanceMethods)
236
+ end
237
+
238
+ module InstanceMethods
239
+ def each(&block)
240
+ ctx = Braintrust::Contrib::Context.from(self)
241
+ # Only trace if context present and is for chat completions (not other endpoints)
242
+ return super unless ctx&.[](:tracer) && !ctx[:consumed] && ctx[:stream_type] == :raw
243
+
244
+ ctx[:consumed] = true
245
+
246
+ tracer = ctx[:tracer]
247
+ params = ctx[:params]
248
+ metadata = ctx[:metadata]
249
+ completions_instance = ctx[:completions_instance]
250
+ aggregated_chunks = []
251
+ start_time = Braintrust::Internal::Time.measure
252
+ time_to_first_token = nil
253
+
254
+ tracer.in_span("Chat Completion") do |span|
255
+ completions_instance.send(:set_input, span, params)
256
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
257
+
258
+ super do |chunk|
259
+ time_to_first_token ||= Braintrust::Internal::Time.measure(start_time)
260
+ aggregated_chunks << chunk.to_h
261
+ block&.call(chunk)
262
+ end
263
+
264
+ finalize_stream_span(span, aggregated_chunks, time_to_first_token, metadata)
265
+ end
266
+ end
267
+
268
+ private
269
+
270
+ def finalize_stream_span(span, aggregated_chunks, time_to_first_token, metadata)
271
+ return if aggregated_chunks.empty?
272
+
273
+ aggregated_output = Common.aggregate_streaming_chunks(aggregated_chunks)
274
+ Support::OTel.set_json_attr(span, "braintrust.output_json", aggregated_output[:choices])
275
+
276
+ # Set metrics
277
+ metrics = {}
278
+ if aggregated_output[:usage]
279
+ metrics = Support::OpenAI.parse_usage_tokens(aggregated_output[:usage])
280
+ end
281
+ metrics["time_to_first_token"] = time_to_first_token
282
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
283
+
284
+ # Update metadata with response fields
285
+ metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
286
+ metadata["created"] = aggregated_output[:created] if aggregated_output[:created]
287
+ metadata["model"] = aggregated_output[:model] if aggregated_output[:model]
288
+ metadata["system_fingerprint"] = aggregated_output[:system_fingerprint] if aggregated_output[:system_fingerprint]
289
+ metadata["service_tier"] = aggregated_output[:service_tier] if aggregated_output[:service_tier]
290
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Contrib
5
+ module OpenAI
6
+ module Instrumentation
7
+ # Aggregation utilities for official OpenAI SDK instrumentation.
8
+ # These are specific to the official openai gem's data structures (symbol keys, SDK objects).
9
+ module Common
10
+ # Aggregate streaming chunks into a single response structure.
11
+ # Specific to official OpenAI SDK which uses symbol keys and SDK objects.
12
+ # @param chunks [Array<Hash>] array of chunk hashes from stream (symbol keys)
13
+ # @return [Hash] aggregated response with choices, usage, etc. (symbol keys)
14
+ def self.aggregate_streaming_chunks(chunks)
15
+ return {} if chunks.empty?
16
+
17
+ # Initialize aggregated structure
18
+ aggregated = {
19
+ id: nil,
20
+ created: nil,
21
+ model: nil,
22
+ system_fingerprint: nil,
23
+ choices: [],
24
+ usage: nil
25
+ }
26
+
27
+ # Track aggregated content and tool_calls for each choice index
28
+ choice_data = {}
29
+
30
+ chunks.each do |chunk|
31
+ # Capture top-level fields from any chunk that has them
32
+ aggregated[:id] ||= chunk[:id]
33
+ aggregated[:created] ||= chunk[:created]
34
+ aggregated[:model] ||= chunk[:model]
35
+ aggregated[:system_fingerprint] ||= chunk[:system_fingerprint]
36
+
37
+ # Aggregate usage (usually only in last chunk if stream_options.include_usage is set)
38
+ aggregated[:usage] = chunk[:usage] if chunk[:usage]
39
+
40
+ # Process choices
41
+ next unless chunk[:choices].is_a?(Array)
42
+ chunk[:choices].each do |choice|
43
+ index = choice[:index] || 0
44
+ choice_data[index] ||= {
45
+ index: index,
46
+ role: nil,
47
+ content: +"",
48
+ tool_calls: [],
49
+ finish_reason: nil
50
+ }
51
+
52
+ delta = choice[:delta] || {}
53
+
54
+ # Aggregate role (set once from first delta that has it)
55
+ choice_data[index][:role] ||= delta[:role]
56
+
57
+ # Aggregate content
58
+ choice_data[index][:content] << delta[:content] if delta[:content]
59
+
60
+ # Aggregate tool_calls
61
+ if delta[:tool_calls].is_a?(Array) && delta[:tool_calls].any?
62
+ delta[:tool_calls].each do |tool_call_delta|
63
+ if tool_call_delta[:id] && !tool_call_delta[:id].empty?
64
+ # New tool call (dup strings to avoid mutating input)
65
+ choice_data[index][:tool_calls] << {
66
+ id: tool_call_delta[:id],
67
+ type: tool_call_delta[:type],
68
+ function: {
69
+ name: +(tool_call_delta.dig(:function, :name) || ""),
70
+ arguments: +(tool_call_delta.dig(:function, :arguments) || "")
71
+ }
72
+ }
73
+ elsif choice_data[index][:tool_calls].any?
74
+ # Continuation - append arguments to last tool call
75
+ last_tool_call = choice_data[index][:tool_calls].last
76
+ if tool_call_delta.dig(:function, :arguments)
77
+ last_tool_call[:function][:arguments] << tool_call_delta[:function][:arguments]
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ # Capture finish_reason
84
+ choice_data[index][:finish_reason] = choice[:finish_reason] if choice[:finish_reason]
85
+ end
86
+ end
87
+
88
+ # Build final choices array
89
+ aggregated[:choices] = choice_data.values.sort_by { |c| c[:index] }.map do |choice|
90
+ message = {
91
+ role: choice[:role],
92
+ content: choice[:content].empty? ? nil : choice[:content]
93
+ }
94
+
95
+ # Add tool_calls to message if any
96
+ message[:tool_calls] = choice[:tool_calls] if choice[:tool_calls].any?
97
+
98
+ {
99
+ index: choice[:index],
100
+ message: message,
101
+ finish_reason: choice[:finish_reason]
102
+ }
103
+ end
104
+
105
+ aggregated
106
+ end
107
+
108
+ # Aggregate responses streaming events into a single response structure.
109
+ # Specific to official OpenAI SDK which returns typed event objects.
110
+ # @param events [Array] array of event objects from stream
111
+ # @return [Hash] aggregated response with output, usage, etc.
112
+ def self.aggregate_responses_events(events)
113
+ return {} if events.empty?
114
+
115
+ # Find the response.completed event which has the final response
116
+ completed_event = events.find { |e| e.respond_to?(:type) && e.type == :"response.completed" }
117
+
118
+ if completed_event&.respond_to?(:response)
119
+ response = completed_event.response
120
+ return {
121
+ id: response.respond_to?(:id) ? response.id : nil,
122
+ output: response.respond_to?(:output) ? response.output : nil,
123
+ usage: response.respond_to?(:usage) ? response.usage : nil
124
+ }
125
+ end
126
+
127
+ # Fallback if no completed event found
128
+ {}
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+
6
+ require_relative "common"
7
+ require_relative "../../../internal/time"
8
+ require_relative "../../support/otel"
9
+ require_relative "../../support/openai"
10
+
11
+ module Braintrust
12
+ module Contrib
13
+ module OpenAI
14
+ module Instrumentation
15
+ # Responses API instrumentation for OpenAI.
16
+ # Wraps create() and stream() methods to create spans.
17
+ module Responses
18
+ def self.included(base)
19
+ base.prepend(InstanceMethods) unless applied?(base)
20
+ end
21
+
22
+ def self.applied?(base)
23
+ base.ancestors.include?(InstanceMethods)
24
+ end
25
+
26
+ METADATA_FIELDS = %i[
27
+ model instructions modalities tools parallel_tool_calls
28
+ tool_choice temperature max_tokens top_p frequency_penalty
29
+ presence_penalty seed user metadata store response_format
30
+ reasoning previous_response_id truncation
31
+ ].freeze
32
+
33
+ module InstanceMethods
34
+ # Wrap non-streaming create method
35
+ def create(**params)
36
+ client = instance_variable_get(:@client)
37
+ tracer = Braintrust::Contrib.tracer_for(client)
38
+
39
+ tracer.in_span("openai.responses.create") do |span|
40
+ metadata = build_metadata(params)
41
+
42
+ set_input(span, params)
43
+
44
+ response = nil
45
+ time_to_first_token = Braintrust::Internal::Time.measure do
46
+ response = super
47
+ end
48
+
49
+ set_output(span, response)
50
+ set_metrics(span, response, time_to_first_token)
51
+ finalize_metadata(span, metadata, response)
52
+
53
+ response
54
+ end
55
+ end
56
+
57
+ # Wrap streaming method
58
+ # Stores context on stream object for span creation during consumption
59
+ def stream(**params)
60
+ client = instance_variable_get(:@client)
61
+ tracer = Braintrust::Contrib.tracer_for(client)
62
+ metadata = build_metadata(params, stream: true)
63
+
64
+ stream_obj = super
65
+
66
+ Braintrust::Contrib::Context.set!(stream_obj,
67
+ tracer: tracer,
68
+ params: params,
69
+ metadata: metadata,
70
+ responses_instance: self)
71
+ stream_obj
72
+ end
73
+
74
+ private
75
+
76
+ def build_metadata(params, stream: false)
77
+ metadata = {
78
+ "provider" => "openai",
79
+ "endpoint" => "/v1/responses"
80
+ }
81
+ metadata["stream"] = true if stream
82
+ Responses::METADATA_FIELDS.each do |field|
83
+ metadata[field.to_s] = params[field] if params.key?(field)
84
+ end
85
+ metadata
86
+ end
87
+
88
+ def set_input(span, params)
89
+ return unless params[:input]
90
+
91
+ Support::OTel.set_json_attr(span, "braintrust.input_json", params[:input])
92
+ end
93
+
94
+ def set_output(span, response)
95
+ return unless response.respond_to?(:output) && response.output
96
+
97
+ Support::OTel.set_json_attr(span, "braintrust.output_json", response.output)
98
+ end
99
+
100
+ def set_metrics(span, response, time_to_first_token)
101
+ metrics = {}
102
+ if response.respond_to?(:usage) && response.usage
103
+ metrics = Support::OpenAI.parse_usage_tokens(response.usage)
104
+ end
105
+ metrics["time_to_first_token"] = time_to_first_token
106
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
107
+ end
108
+
109
+ def finalize_metadata(span, metadata, response)
110
+ metadata["id"] = response.id if response.respond_to?(:id) && response.id
111
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
112
+ end
113
+ end
114
+ end
115
+
116
+ # Instrumentation for ResponseStream (returned by stream())
117
+ # Aggregates events and creates span lazily when consumed
118
+ module ResponseStream
119
+ def self.included(base)
120
+ base.prepend(InstanceMethods) unless applied?(base)
121
+ end
122
+
123
+ def self.applied?(base)
124
+ base.ancestors.include?(InstanceMethods)
125
+ end
126
+
127
+ module InstanceMethods
128
+ def each(&block)
129
+ ctx = Braintrust::Contrib::Context.from(self)
130
+ return super unless ctx&.[](:tracer) && !ctx[:consumed]
131
+
132
+ ctx[:consumed] = true
133
+
134
+ tracer = ctx[:tracer]
135
+ params = ctx[:params]
136
+ metadata = ctx[:metadata]
137
+ responses_instance = ctx[:responses_instance]
138
+ aggregated_events = []
139
+ start_time = Braintrust::Internal::Time.measure
140
+ time_to_first_token = nil
141
+
142
+ tracer.in_span("openai.responses.create") do |span|
143
+ responses_instance.send(:set_input, span, params)
144
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
145
+
146
+ begin
147
+ super do |event|
148
+ time_to_first_token ||= Braintrust::Internal::Time.measure(start_time)
149
+ aggregated_events << event
150
+ block&.call(event)
151
+ end
152
+ rescue => e
153
+ span.record_exception(e)
154
+ span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
155
+ raise
156
+ end
157
+
158
+ finalize_stream_span(span, aggregated_events, time_to_first_token, metadata)
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def finalize_stream_span(span, aggregated_events, time_to_first_token, metadata)
165
+ return if aggregated_events.empty?
166
+
167
+ aggregated_output = Common.aggregate_responses_events(aggregated_events)
168
+ Support::OTel.set_json_attr(span, "braintrust.output_json", aggregated_output[:output]) if aggregated_output[:output]
169
+
170
+ # Set metrics
171
+ metrics = {}
172
+ if aggregated_output[:usage]
173
+ metrics = Support::OpenAI.parse_usage_tokens(aggregated_output[:usage])
174
+ end
175
+ metrics["time_to_first_token"] = time_to_first_token
176
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
177
+
178
+ # Update metadata with response fields
179
+ metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
180
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+ require_relative "deprecated"
5
+
6
+ module Braintrust
7
+ module Contrib
8
+ module OpenAI
9
+ # OpenAI integration for automatic instrumentation.
10
+ # Instruments the official openai gem (not ruby-openai).
11
+ class Integration
12
+ include Braintrust::Contrib::Integration
13
+
14
+ MINIMUM_VERSION = "0.1.0"
15
+
16
+ GEM_NAMES = ["openai"].freeze
17
+ REQUIRE_PATHS = ["openai"].freeze
18
+
19
+ # @return [Symbol] Unique identifier for this integration
20
+ def self.integration_name
21
+ :openai
22
+ end
23
+
24
+ # @return [Array<String>] Gem names this integration supports
25
+ def self.gem_names
26
+ GEM_NAMES
27
+ end
28
+
29
+ # @return [Array<String>] Require paths for auto-instrument detection
30
+ def self.require_paths
31
+ REQUIRE_PATHS
32
+ end
33
+
34
+ # @return [String] Minimum compatible version
35
+ def self.minimum_version
36
+ MINIMUM_VERSION
37
+ end
38
+
39
+ # @return [Boolean] true if official openai gem is available
40
+ def self.loaded?
41
+ # Check if the official openai gem is loaded (not ruby-openai).
42
+ # The ruby-openai gem also uses "require 'openai'", so we need to distinguish them.
43
+
44
+ # This module is defined ONLY in the official OpenAI gem
45
+ defined?(::OpenAI::Internal) ? true : false
46
+ end
47
+
48
+ # Lazy-load the patcher only when actually patching.
49
+ # This keeps the integration stub lightweight.
50
+ # @return [Class] The patcher class
51
+ def self.patchers
52
+ require_relative "patcher"
53
+ [ChatPatcher, ResponsesPatcher]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end