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 +4 -4
- data/README.md +1 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/beta_messages.rb +242 -0
- data/lib/braintrust/contrib/anthropic/integration.rb +2 -2
- data/lib/braintrust/contrib/anthropic/patcher.rb +83 -0
- data/lib/braintrust/contrib/openai/instrumentation/moderations.rb +93 -0
- data/lib/braintrust/contrib/openai/integration.rb +1 -1
- data/lib/braintrust/contrib/openai/patcher.rb +43 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/moderations.rb +94 -0
- data/lib/braintrust/contrib/ruby_openai/integration.rb +1 -1
- data/lib/braintrust/contrib/ruby_openai/patcher.rb +35 -0
- data/lib/braintrust/internal/env.rb +6 -0
- data/lib/braintrust/state.rb +1 -1
- data/lib/braintrust/trace.rb +41 -0
- data/lib/braintrust/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40d5c41ef999d495f9eb7b3a4cd41f1bb9ee7ba831de2074195a7d9e18da2e3a
|
|
4
|
+
data.tar.gz: 8e48aee25cd02a4b936da1264a9964097c6e07744faf0564d81885daa72b87da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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
|
data/lib/braintrust/state.rb
CHANGED
data/lib/braintrust/trace.rb
CHANGED
|
@@ -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
|
data/lib/braintrust/version.rb
CHANGED
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.
|
|
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
|