braintrust 0.1.0 → 0.1.1

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: b28ce15a3d9599baecd29de264cd9455811cc18b1338fb948a19347f3c6327cc
4
- data.tar.gz: f752b82b8f79ec77f72b2b95b5c98f77b5de92af77d39370eccc87af4a2835d7
3
+ metadata.gz: 40d5c41ef999d495f9eb7b3a4cd41f1bb9ee7ba831de2074195a7d9e18da2e3a
4
+ data.tar.gz: 8e48aee25cd02a4b936da1264a9964097c6e07744faf0564d81885daa72b87da
5
5
  SHA512:
6
- metadata.gz: 86b922e9252bb1e390e892458931b4fc19d495ba04842f54f37d3a0fdb52db02fb7e5237bee65df74f870fa457cfefa98d46abe65bdf855ce46bdfe7056d9c97
7
- data.tar.gz: 7f426e8c342d57136c175dae2c69359a8103da8ec7425750c3bc86de0232ac505b78f2d82c547124070e8e7cacd6cd9c479e45b74ad06b6379118af1eb281cb8
6
+ metadata.gz: 9391f2dcec3c92e032d3e7009ec4bf1d26f6d2a606f0edad78acf78078f116393d95a4a3d70a08010b89df214f92498905c67cd02b3bd6327b5aa2cbf1dda872
7
+ data.tar.gz: 5665f9bcb49a8b5ca90f5d58d9c279120a053756c6ec03ba17557b6064c7a7142ac9674789fe4c89df70e2ac61d1d1ce9318a33b897b55da1d45b9bc9df1bf15
data/README.md CHANGED
@@ -136,6 +136,7 @@ Braintrust.init(
136
136
  | `BRAINTRUST_AUTO_INSTRUMENT` | Set to `false` to disable auto-instrumentation |
137
137
  | `BRAINTRUST_DEBUG` | Set to `true` to enable debug logging |
138
138
  | `BRAINTRUST_DEFAULT_PROJECT` | Default project for spans |
139
+ | `BRAINTRUST_FLUSH_ON_EXIT` | Set to `false` to disable automatic span flushing on program exit |
139
140
  | `BRAINTRUST_INSTRUMENT_EXCEPT` | Comma-separated list of integrations to skip |
140
141
  | `BRAINTRUST_INSTRUMENT_ONLY` | Comma-separated list of integrations to enable (e.g., `openai,anthropic`) |
141
142
  | `BRAINTRUST_ORG_NAME` | Organization name |
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+ require_relative "../../support/otel"
6
+ require_relative "common"
7
+ require_relative "../../../internal/time"
8
+
9
+ module Braintrust
10
+ module Contrib
11
+ module Anthropic
12
+ module Instrumentation
13
+ # Beta Messages instrumentation for Anthropic.
14
+ # Wraps client.beta.messages.create() and stream() methods to create spans.
15
+ #
16
+ # @note Beta APIs are experimental and subject to change between SDK versions.
17
+ # This module includes defensive coding to handle response format changes.
18
+ module BetaMessages
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
+ module InstanceMethods
28
+ # Standard metadata fields (shared with stable API)
29
+ METADATA_FIELDS = %i[
30
+ model max_tokens temperature top_p top_k stop_sequences
31
+ stream tools tool_choice thinking metadata service_tier
32
+ ].freeze
33
+
34
+ # Beta-specific metadata fields
35
+ BETA_METADATA_FIELDS = %i[
36
+ betas output_format
37
+ ].freeze
38
+
39
+ # Wrap synchronous beta.messages.create
40
+ def create(**params)
41
+ client = instance_variable_get(:@client)
42
+ tracer = Braintrust::Contrib.tracer_for(client)
43
+
44
+ tracer.in_span("anthropic.messages.create") do |span|
45
+ # Pre-call instrumentation (swallow errors)
46
+ metadata = nil
47
+ begin
48
+ metadata = build_metadata(params)
49
+ set_input(span, params)
50
+ rescue => e
51
+ Braintrust::Log.debug("Beta API: Failed to capture request: #{e.message}")
52
+ metadata ||= {"provider" => "anthropic", "api_version" => "beta"}
53
+ end
54
+
55
+ # API call - let errors propagate naturally
56
+ response = nil
57
+ time_to_first_token = Braintrust::Internal::Time.measure do
58
+ response = super(**params)
59
+ end
60
+
61
+ # Post-call instrumentation (swallow errors)
62
+ begin
63
+ set_output(span, response)
64
+ set_metrics(span, response, time_to_first_token)
65
+ finalize_metadata(span, metadata, response)
66
+ rescue => e
67
+ Braintrust::Log.debug("Beta API: Failed to capture response: #{e.message}")
68
+ end
69
+
70
+ response
71
+ end
72
+ end
73
+
74
+ # Wrap streaming beta.messages.stream
75
+ # Stores context on stream object for span creation during consumption
76
+ def stream(**params)
77
+ client = instance_variable_get(:@client)
78
+ tracer = Braintrust::Contrib.tracer_for(client)
79
+
80
+ # Pre-call instrumentation (swallow errors)
81
+ metadata = nil
82
+ begin
83
+ metadata = build_metadata(params, stream: true)
84
+ rescue => e
85
+ Braintrust::Log.debug("Beta API: Failed to build stream metadata: #{e.message}")
86
+ metadata = {"provider" => "anthropic", "api_version" => "beta", "stream" => true}
87
+ end
88
+
89
+ # API call - let errors propagate naturally
90
+ stream_obj = super
91
+
92
+ # Post-call instrumentation (swallow errors)
93
+ begin
94
+ Braintrust::Contrib::Context.set!(stream_obj,
95
+ tracer: tracer,
96
+ params: params,
97
+ metadata: metadata,
98
+ messages_instance: self,
99
+ start_time: Braintrust::Internal::Time.measure)
100
+ rescue => e
101
+ Braintrust::Log.debug("Beta API: Failed to set stream context: #{e.message}")
102
+ end
103
+
104
+ stream_obj
105
+ end
106
+
107
+ private
108
+
109
+ def finalize_stream_span(span, stream_obj, metadata, time_to_first_token)
110
+ if stream_obj.respond_to?(:accumulated_message)
111
+ begin
112
+ msg = stream_obj.accumulated_message
113
+ set_output(span, msg)
114
+ set_metrics(span, msg, time_to_first_token)
115
+ metadata["stop_reason"] = msg.stop_reason if msg.respond_to?(:stop_reason) && msg.stop_reason
116
+ metadata["model"] = msg.model if msg.respond_to?(:model) && msg.model
117
+ rescue => e
118
+ Braintrust::Log.debug("Beta API: Failed to get accumulated message: #{e.message}")
119
+ end
120
+ end
121
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
122
+ end
123
+
124
+ def build_metadata(params, stream: false)
125
+ metadata = {
126
+ "provider" => "anthropic",
127
+ "endpoint" => "/v1/messages",
128
+ "api_version" => "beta"
129
+ }
130
+ metadata["stream"] = true if stream
131
+
132
+ # Capture standard fields
133
+ METADATA_FIELDS.each do |field|
134
+ metadata[field.to_s] = params[field] if params.key?(field)
135
+ end
136
+
137
+ # Capture beta-specific fields with defensive handling
138
+ capture_beta_fields(metadata, params)
139
+
140
+ metadata
141
+ rescue => e
142
+ Braintrust::Log.debug("Beta API: Failed to build metadata: #{e.message}")
143
+ {"provider" => "anthropic", "api_version" => "beta"}
144
+ end
145
+
146
+ def capture_beta_fields(metadata, params)
147
+ # Capture betas array (e.g., ["structured-outputs-2025-11-13"])
148
+ if params.key?(:betas)
149
+ betas = params[:betas]
150
+ metadata["betas"] = betas.is_a?(Array) ? betas : [betas]
151
+ end
152
+
153
+ # Capture output_format for structured outputs
154
+ if params.key?(:output_format)
155
+ output_format = params[:output_format]
156
+ metadata["output_format"] = begin
157
+ if output_format.respond_to?(:to_h)
158
+ output_format.to_h
159
+ else
160
+ output_format
161
+ end
162
+ rescue
163
+ output_format.to_s
164
+ end
165
+ end
166
+ end
167
+
168
+ def set_input(span, params)
169
+ input_messages = []
170
+
171
+ begin
172
+ if params[:system]
173
+ system_content = params[:system]
174
+ if system_content.is_a?(Array)
175
+ system_text = system_content.map { |blk|
176
+ blk.is_a?(Hash) ? blk[:text] : blk
177
+ }.join("\n")
178
+ input_messages << {role: "system", content: system_text}
179
+ else
180
+ input_messages << {role: "system", content: system_content}
181
+ end
182
+ end
183
+
184
+ if params[:messages]
185
+ messages_array = params[:messages].map { |m| m.respond_to?(:to_h) ? m.to_h : m }
186
+ input_messages.concat(messages_array)
187
+ end
188
+
189
+ Support::OTel.set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
190
+ rescue => e
191
+ Braintrust::Log.debug("Beta API: Failed to capture input: #{e.message}")
192
+ end
193
+ end
194
+
195
+ def set_output(span, response)
196
+ return unless response
197
+
198
+ begin
199
+ return unless response.respond_to?(:content) && response.content
200
+
201
+ content_array = response.content.map { |c| c.respond_to?(:to_h) ? c.to_h : c }
202
+ output = [{
203
+ role: response.respond_to?(:role) ? response.role : "assistant",
204
+ content: content_array
205
+ }]
206
+ Support::OTel.set_json_attr(span, "braintrust.output_json", output)
207
+ rescue => e
208
+ Braintrust::Log.debug("Beta API: Failed to capture output: #{e.message}")
209
+ end
210
+ end
211
+
212
+ def set_metrics(span, response, time_to_first_token)
213
+ metrics = {}
214
+
215
+ begin
216
+ if response.respond_to?(:usage) && response.usage
217
+ metrics = Common.parse_usage_tokens(response.usage)
218
+ end
219
+ metrics["time_to_first_token"] = time_to_first_token if time_to_first_token
220
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
221
+ rescue => e
222
+ Braintrust::Log.debug("Beta API: Failed to capture metrics: #{e.message}")
223
+ end
224
+ end
225
+
226
+ def finalize_metadata(span, metadata, response)
227
+ begin
228
+ metadata["stop_reason"] = response.stop_reason if response.respond_to?(:stop_reason) && response.stop_reason
229
+ metadata["stop_sequence"] = response.stop_sequence if response.respond_to?(:stop_sequence) && response.stop_sequence
230
+ metadata["model"] = response.model if response.respond_to?(:model) && response.model
231
+ rescue => e
232
+ Braintrust::Log.debug("Beta API: Failed to finalize metadata: #{e.message}")
233
+ end
234
+
235
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -40,12 +40,12 @@ module Braintrust
40
40
  defined?(::Anthropic::Client) ? true : false
41
41
  end
42
42
 
43
- # Lazy-load the patcher only when actually patching.
43
+ # Lazy-load the patchers only when actually patching.
44
44
  # This keeps the integration stub lightweight.
45
45
  # @return [Array<Class>] The patcher classes
46
46
  def self.patchers
47
47
  require_relative "patcher"
48
- [MessagesPatcher]
48
+ [MessagesPatcher, BetaMessagesPatcher]
49
49
  end
50
50
  end
51
51
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../patcher"
4
4
  require_relative "instrumentation/messages"
5
+ require_relative "instrumentation/beta_messages"
5
6
 
6
7
  module Braintrust
7
8
  module Contrib
@@ -57,6 +58,88 @@ module Braintrust
57
58
  end
58
59
  end
59
60
  end
61
+
62
+ # Patcher for Anthropic beta messages API.
63
+ # Instruments client.beta.messages.create and stream methods.
64
+ #
65
+ # @note Beta APIs are experimental and subject to change between SDK versions.
66
+ # Braintrust will make reasonable efforts to maintain compatibility, but
67
+ # breaking changes may require SDK updates.
68
+ #
69
+ # @see https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs
70
+ # for structured outputs documentation
71
+ class BetaMessagesPatcher < Braintrust::Contrib::Patcher
72
+ # Version constraints for beta patcher.
73
+ # Set MAXIMUM_VERSION when a breaking change is discovered to disable
74
+ # beta instrumentation on incompatible versions until a fix is released.
75
+ # Currently nil = rely on class existence check only.
76
+ MAXIMUM_VERSION = nil
77
+
78
+ class << self
79
+ def applicable?
80
+ return false unless defined?(::Anthropic::Client)
81
+ return false unless defined?(::Anthropic::Resources::Beta::Messages)
82
+ return false if MAXIMUM_VERSION && !version_compatible?
83
+ true
84
+ end
85
+
86
+ def patched?(**options)
87
+ target_class = get_singleton_class(options[:target]) || ::Anthropic::Resources::Beta::Messages
88
+ Instrumentation::BetaMessages.applied?(target_class)
89
+ end
90
+
91
+ # Perform the actual patching.
92
+ # @param options [Hash] Configuration options passed from integration
93
+ # @option options [Object] :target Optional target instance to patch
94
+ # @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
95
+ # @return [void]
96
+ def perform_patch(**options)
97
+ return unless applicable?
98
+
99
+ Braintrust::Log.debug("Instrumenting Anthropic beta.messages API (experimental)")
100
+
101
+ # MessageStream is shared with stable API - already patched by MessagesPatcher.
102
+ # The BetaMessages instrumentation sets api_version: "beta" in context,
103
+ # which MessageStream uses to include in metadata.
104
+ patch_message_stream
105
+
106
+ if options[:target]
107
+ # Instance-level (for only this client instance)
108
+ raise ArgumentError, "target must be a kind of ::Anthropic::Client" unless options[:target].is_a?(::Anthropic::Client)
109
+
110
+ get_singleton_class(options[:target]).include(Instrumentation::BetaMessages)
111
+ else
112
+ # Class-level (for all client instances)
113
+ ::Anthropic::Resources::Beta::Messages.include(Instrumentation::BetaMessages)
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def version_compatible?
120
+ return true unless MAXIMUM_VERSION
121
+
122
+ spec = Gem.loaded_specs["anthropic"]
123
+ return true unless spec
124
+
125
+ spec.version <= Gem::Version.new(MAXIMUM_VERSION)
126
+ end
127
+
128
+ def get_singleton_class(client)
129
+ client&.beta&.messages&.singleton_class
130
+ rescue
131
+ # Defensive: beta namespace may not exist or may have changed
132
+ nil
133
+ end
134
+
135
+ def patch_message_stream
136
+ return unless defined?(::Anthropic::Helpers::Streaming::MessageStream)
137
+ return if Instrumentation::MessageStream.applied?(::Anthropic::Helpers::Streaming::MessageStream)
138
+
139
+ ::Anthropic::Helpers::Streaming::MessageStream.include(Instrumentation::MessageStream)
140
+ end
141
+ end
142
+ end
60
143
  end
61
144
  end
62
145
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+
6
+ require_relative "../../../internal/time"
7
+ require_relative "../../support/otel"
8
+
9
+ module Braintrust
10
+ module Contrib
11
+ module OpenAI
12
+ module Instrumentation
13
+ # Moderations API instrumentation for OpenAI.
14
+ # Wraps create() method to create spans.
15
+ module Moderations
16
+ def self.included(base)
17
+ base.prepend(InstanceMethods) unless applied?(base)
18
+ end
19
+
20
+ def self.applied?(base)
21
+ base.ancestors.include?(InstanceMethods)
22
+ end
23
+
24
+ METADATA_FIELDS = %i[
25
+ model
26
+ ].freeze
27
+
28
+ module InstanceMethods
29
+ # Wrap non-streaming create method
30
+ def create(**params)
31
+ client = instance_variable_get(:@client)
32
+ tracer = Braintrust::Contrib.tracer_for(client)
33
+
34
+ tracer.in_span("openai.moderations.create") do |span|
35
+ metadata = build_metadata(params)
36
+
37
+ set_input(span, params)
38
+
39
+ response = nil
40
+ time_to_first_token = Braintrust::Internal::Time.measure do
41
+ response = super
42
+ end
43
+
44
+ set_output(span, response)
45
+ set_metrics(span, time_to_first_token)
46
+ finalize_metadata(span, metadata, response)
47
+
48
+ response
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def build_metadata(params)
55
+ metadata = {
56
+ "provider" => "openai",
57
+ "endpoint" => "/v1/moderations"
58
+ }
59
+ Moderations::METADATA_FIELDS.each do |field|
60
+ metadata[field.to_s] = params[field] if params.key?(field)
61
+ end
62
+ metadata
63
+ end
64
+
65
+ def set_input(span, params)
66
+ return unless params[:input]
67
+
68
+ Support::OTel.set_json_attr(span, "braintrust.input_json", params[:input])
69
+ end
70
+
71
+ def set_output(span, response)
72
+ return unless response.respond_to?(:results) && response.results
73
+
74
+ Support::OTel.set_json_attr(span, "braintrust.output_json", response.results)
75
+ end
76
+
77
+ def set_metrics(span, time_to_first_token)
78
+ metrics = {}
79
+ metrics["time_to_first_token"] = time_to_first_token
80
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
81
+ end
82
+
83
+ def finalize_metadata(span, metadata, response)
84
+ metadata["id"] = response.id if response.respond_to?(:id) && response.id
85
+ metadata["model"] = response.model if response.respond_to?(:model) && response.model
86
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -50,7 +50,7 @@ module Braintrust
50
50
  # @return [Class] The patcher class
51
51
  def self.patchers
52
52
  require_relative "patcher"
53
- [ChatPatcher, ResponsesPatcher]
53
+ [ChatPatcher, ResponsesPatcher, ModerationsPatcher]
54
54
  end
55
55
  end
56
56
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../patcher"
4
4
  require_relative "instrumentation/chat"
5
5
  require_relative "instrumentation/responses"
6
+ require_relative "instrumentation/moderations"
6
7
 
7
8
  module Braintrust
8
9
  module Contrib
@@ -125,6 +126,48 @@ module Braintrust
125
126
  end
126
127
  end
127
128
  end
129
+
130
+ # Patcher for OpenAI Moderations API - implements class-level patching.
131
+ # All new OpenAI::Client instances created after patch! will be automatically instrumented.
132
+ class ModerationsPatcher < Braintrust::Contrib::Patcher
133
+ class << self
134
+ def applicable?
135
+ defined?(::OpenAI::Client) && ::OpenAI::Client.instance_methods.include?(:moderations)
136
+ end
137
+
138
+ def patched?(**options)
139
+ # Use the target's singleton class if provided, otherwise check the base class.
140
+ target_class = get_singleton_class(options[:target]) || ::OpenAI::Resources::Moderations
141
+
142
+ Instrumentation::Moderations.applied?(target_class)
143
+ end
144
+
145
+ # Perform the actual patching.
146
+ # @param options [Hash] Configuration options passed from integration
147
+ # @option options [Object] :target Optional target instance to patch
148
+ # @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
149
+ # @return [void]
150
+ def perform_patch(**options)
151
+ return unless applicable?
152
+
153
+ if options[:target]
154
+ # Instance-level (for only this client)
155
+ raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
156
+
157
+ get_singleton_class(options[:target]).include(Instrumentation::Moderations)
158
+ else
159
+ # Class-level (for all clients)
160
+ ::OpenAI::Resources::Moderations.include(Instrumentation::Moderations)
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def get_singleton_class(client)
167
+ client&.moderations&.singleton_class
168
+ end
169
+ end
170
+ end
128
171
  end
129
172
  end
130
173
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+
6
+ require_relative "../../support/otel"
7
+ require_relative "../../../internal/time"
8
+
9
+ module Braintrust
10
+ module Contrib
11
+ module RubyOpenAI
12
+ module Instrumentation
13
+ # Moderations API instrumentation for ruby-openai.
14
+ # Provides module that can be prepended to OpenAI::Client to instrument the moderations method.
15
+ module Moderations
16
+ def self.included(base)
17
+ base.prepend(InstanceMethods) unless applied?(base)
18
+ end
19
+
20
+ def self.applied?(base)
21
+ base.ancestors.include?(InstanceMethods)
22
+ end
23
+
24
+ METADATA_FIELDS = %i[
25
+ model
26
+ ].freeze
27
+
28
+ module InstanceMethods
29
+ # Wrap moderations method for ruby-openai gem
30
+ # ruby-openai API: client.moderations(parameters: {...})
31
+ def moderations(parameters:)
32
+ tracer = Braintrust::Contrib.tracer_for(self)
33
+
34
+ tracer.in_span("openai.moderations.create") do |span|
35
+ metadata = build_moderations_metadata(parameters)
36
+ set_moderations_input(span, parameters)
37
+
38
+ response = nil
39
+ time_to_first_token = Braintrust::Internal::Time.measure do
40
+ response = super(parameters: parameters)
41
+ end
42
+
43
+ set_moderations_output(span, response)
44
+ set_moderations_metrics(span, time_to_first_token)
45
+ finalize_moderations_metadata(span, metadata, response)
46
+
47
+ response
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def build_moderations_metadata(parameters)
54
+ metadata = {
55
+ "provider" => "openai",
56
+ "endpoint" => "/v1/moderations"
57
+ }
58
+
59
+ Moderations::METADATA_FIELDS.each do |field|
60
+ metadata[field.to_s] = parameters[field] if parameters.key?(field)
61
+ end
62
+
63
+ metadata
64
+ end
65
+
66
+ def set_moderations_input(span, parameters)
67
+ return unless parameters[:input]
68
+ Support::OTel.set_json_attr(span, "braintrust.input_json", parameters[:input])
69
+ end
70
+
71
+ def set_moderations_output(span, response)
72
+ results = response["results"] || response[:results]
73
+ return unless results
74
+ Support::OTel.set_json_attr(span, "braintrust.output_json", results)
75
+ end
76
+
77
+ def set_moderations_metrics(span, time_to_first_token)
78
+ metrics = {"time_to_first_token" => time_to_first_token}
79
+ Support::OTel.set_json_attr(span, "braintrust.metrics", metrics)
80
+ end
81
+
82
+ def finalize_moderations_metadata(span, metadata, response)
83
+ %w[id model].each do |field|
84
+ value = response[field] || response[field.to_sym]
85
+ metadata[field] = value if value
86
+ end
87
+ Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -50,7 +50,7 @@ module Braintrust
50
50
  # @return [Array<Class>] The patcher classes
51
51
  def self.patchers
52
52
  require_relative "patcher"
53
- [ChatPatcher, ResponsesPatcher]
53
+ [ChatPatcher, ResponsesPatcher, ModerationsPatcher]
54
54
  end
55
55
  end
56
56
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../patcher"
4
4
  require_relative "instrumentation/chat"
5
5
  require_relative "instrumentation/responses"
6
+ require_relative "instrumentation/moderations"
6
7
 
7
8
  module Braintrust
8
9
  module Contrib
@@ -80,6 +81,40 @@ module Braintrust
80
81
  end
81
82
  end
82
83
  end
84
+
85
+ # Patcher for ruby-openai moderations API.
86
+ # Instruments OpenAI::Client#moderations method.
87
+ class ModerationsPatcher < Braintrust::Contrib::Patcher
88
+ class << self
89
+ def applicable?
90
+ defined?(::OpenAI::Client) && ::OpenAI::Client.method_defined?(:moderations)
91
+ end
92
+
93
+ def patched?(**options)
94
+ target_class = options[:target]&.singleton_class || ::OpenAI::Client
95
+ Instrumentation::Moderations.applied?(target_class)
96
+ end
97
+
98
+ # Perform the actual patching.
99
+ # @param options [Hash] Configuration options passed from integration
100
+ # @option options [Object] :target Optional target instance to patch
101
+ # @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
102
+ # @return [void]
103
+ def perform_patch(**options)
104
+ return unless applicable?
105
+
106
+ if options[:target]
107
+ # Instance-level (for only this client)
108
+ raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
109
+
110
+ options[:target].singleton_class.include(Instrumentation::Moderations)
111
+ else
112
+ # Class-level (for all clients)
113
+ ::OpenAI::Client.include(Instrumentation::Moderations)
114
+ end
115
+ end
116
+ end
117
+ end
83
118
  end
84
119
  end
85
120
  end
@@ -7,11 +7,17 @@ module Braintrust
7
7
  ENV_AUTO_INSTRUMENT = "BRAINTRUST_AUTO_INSTRUMENT"
8
8
  ENV_INSTRUMENT_EXCEPT = "BRAINTRUST_INSTRUMENT_EXCEPT"
9
9
  ENV_INSTRUMENT_ONLY = "BRAINTRUST_INSTRUMENT_ONLY"
10
+ ENV_FLUSH_ON_EXIT = "BRAINTRUST_FLUSH_ON_EXIT"
10
11
 
11
12
  def self.auto_instrument
12
13
  ENV[ENV_AUTO_INSTRUMENT] != "false"
13
14
  end
14
15
 
16
+ # Whether to automatically flush spans on program exit. Default: true
17
+ def self.flush_on_exit
18
+ ENV[ENV_FLUSH_ON_EXIT] != "false"
19
+ end
20
+
15
21
  def self.instrument_except
16
22
  parse_list(ENV_INSTRUMENT_EXCEPT)
17
23
  end
@@ -73,7 +73,7 @@ module Braintrust
73
73
  @org_id = org_id
74
74
  @default_project = default_project
75
75
  @app_url = app_url || "https://www.braintrust.dev"
76
- @api_url = api_url
76
+ @api_url = api_url || "https://api.braintrust.dev"
77
77
  @proxy_url = proxy_url
78
78
  @config = config
79
79
 
@@ -4,10 +4,18 @@ require "opentelemetry/sdk"
4
4
  require "opentelemetry/exporter/otlp"
5
5
  require_relative "trace/span_processor"
6
6
  require_relative "trace/span_filter"
7
+ require_relative "internal/env"
7
8
  require_relative "logger"
8
9
 
9
10
  module Braintrust
10
11
  module Trace
12
+ # Track whether the at_exit hook has been registered
13
+ @exit_hook_registered = false
14
+
15
+ class << self
16
+ attr_accessor :exit_hook_registered
17
+ end
18
+
11
19
  # Set up OpenTelemetry tracing with Braintrust
12
20
  # @param state [State] Braintrust state
13
21
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider
@@ -32,6 +40,9 @@ module Braintrust
32
40
  OpenTelemetry.tracer_provider = tracer_provider
33
41
  Log.debug("Created OpenTelemetry tracer provider")
34
42
  end
43
+
44
+ # Register at_exit hook for global provider (only once)
45
+ register_exit_hook
35
46
  end
36
47
 
37
48
  # Enable Braintrust tracing (adds span processor)
@@ -39,6 +50,36 @@ module Braintrust
39
50
  enable(tracer_provider, state: state, config: config, exporter: exporter)
40
51
  end
41
52
 
53
+ # Register an at_exit hook to flush spans before program exit.
54
+ # This ensures buffered spans in BatchSpanProcessor are exported.
55
+ # Only registers once, and only for the global tracer provider.
56
+ # Controlled by BRAINTRUST_FLUSH_ON_EXIT env var (default: true).
57
+ def self.register_exit_hook
58
+ return if @exit_hook_registered
59
+ return unless Internal::Env.flush_on_exit
60
+
61
+ @exit_hook_registered = true
62
+ at_exit { flush_spans }
63
+ Log.debug("Registered at_exit hook for span flushing")
64
+ end
65
+
66
+ # Flush buffered spans from the global tracer provider.
67
+ # Forces immediate export of any spans buffered by BatchSpanProcessor.
68
+ # @return [Boolean] true if flush succeeded, false otherwise
69
+ def self.flush_spans
70
+ provider = OpenTelemetry.tracer_provider
71
+ return false unless provider.respond_to?(:force_flush)
72
+
73
+ Log.debug("Flushing spans")
74
+ begin
75
+ provider.force_flush
76
+ true
77
+ rescue => e
78
+ Log.debug("Failed to flush spans: #{e.message}")
79
+ false
80
+ end
81
+ end
82
+
42
83
  def self.enable(tracer_provider, state: nil, exporter: nil, config: nil)
43
84
  state ||= Braintrust.current_state
44
85
  raise Error, "No state available" unless state
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
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.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -196,6 +196,7 @@ files:
196
196
  - lib/braintrust/config.rb
197
197
  - lib/braintrust/contrib.rb
198
198
  - lib/braintrust/contrib/anthropic/deprecated.rb
199
+ - lib/braintrust/contrib/anthropic/instrumentation/beta_messages.rb
199
200
  - lib/braintrust/contrib/anthropic/instrumentation/common.rb
200
201
  - lib/braintrust/contrib/anthropic/instrumentation/messages.rb
201
202
  - lib/braintrust/contrib/anthropic/integration.rb
@@ -205,6 +206,7 @@ files:
205
206
  - lib/braintrust/contrib/openai/deprecated.rb
206
207
  - lib/braintrust/contrib/openai/instrumentation/chat.rb
207
208
  - lib/braintrust/contrib/openai/instrumentation/common.rb
209
+ - lib/braintrust/contrib/openai/instrumentation/moderations.rb
208
210
  - lib/braintrust/contrib/openai/instrumentation/responses.rb
209
211
  - lib/braintrust/contrib/openai/integration.rb
210
212
  - lib/braintrust/contrib/openai/patcher.rb
@@ -219,6 +221,7 @@ files:
219
221
  - lib/braintrust/contrib/ruby_openai/deprecated.rb
220
222
  - lib/braintrust/contrib/ruby_openai/instrumentation/chat.rb
221
223
  - lib/braintrust/contrib/ruby_openai/instrumentation/common.rb
224
+ - lib/braintrust/contrib/ruby_openai/instrumentation/moderations.rb
222
225
  - lib/braintrust/contrib/ruby_openai/instrumentation/responses.rb
223
226
  - lib/braintrust/contrib/ruby_openai/integration.rb
224
227
  - lib/braintrust/contrib/ruby_openai/patcher.rb