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.
- checksums.yaml +4 -4
- data/README.md +213 -180
- data/exe/braintrust +143 -0
- data/lib/braintrust/contrib/anthropic/deprecated.rb +24 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/common.rb +53 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/messages.rb +232 -0
- data/lib/braintrust/contrib/anthropic/integration.rb +53 -0
- data/lib/braintrust/contrib/anthropic/patcher.rb +62 -0
- data/lib/braintrust/contrib/context.rb +56 -0
- data/lib/braintrust/contrib/integration.rb +160 -0
- data/lib/braintrust/contrib/openai/deprecated.rb +22 -0
- data/lib/braintrust/contrib/openai/instrumentation/chat.rb +298 -0
- data/lib/braintrust/contrib/openai/instrumentation/common.rb +134 -0
- data/lib/braintrust/contrib/openai/instrumentation/responses.rb +187 -0
- data/lib/braintrust/contrib/openai/integration.rb +58 -0
- data/lib/braintrust/contrib/openai/patcher.rb +130 -0
- data/lib/braintrust/contrib/patcher.rb +76 -0
- data/lib/braintrust/contrib/rails/railtie.rb +16 -0
- data/lib/braintrust/contrib/registry.rb +107 -0
- data/lib/braintrust/contrib/ruby_llm/deprecated.rb +45 -0
- data/lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb +464 -0
- data/lib/braintrust/contrib/ruby_llm/instrumentation/common.rb +58 -0
- data/lib/braintrust/contrib/ruby_llm/integration.rb +54 -0
- data/lib/braintrust/contrib/ruby_llm/patcher.rb +44 -0
- data/lib/braintrust/contrib/ruby_openai/deprecated.rb +24 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/chat.rb +149 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/common.rb +138 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/responses.rb +146 -0
- data/lib/braintrust/contrib/ruby_openai/integration.rb +58 -0
- data/lib/braintrust/contrib/ruby_openai/patcher.rb +85 -0
- data/lib/braintrust/contrib/setup.rb +168 -0
- data/lib/braintrust/contrib/support/openai.rb +72 -0
- data/lib/braintrust/contrib/support/otel.rb +23 -0
- data/lib/braintrust/contrib.rb +205 -0
- data/lib/braintrust/internal/env.rb +33 -0
- data/lib/braintrust/internal/time.rb +44 -0
- data/lib/braintrust/setup.rb +50 -0
- data/lib/braintrust/state.rb +5 -0
- data/lib/braintrust/trace.rb +0 -51
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +10 -1
- metadata +38 -7
- data/lib/braintrust/trace/contrib/anthropic.rb +0 -316
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +0 -377
- data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +0 -631
- data/lib/braintrust/trace/contrib/openai.rb +0 -611
- data/lib/braintrust/trace/tokens.rb +0 -109
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patcher"
|
|
4
|
+
require_relative "instrumentation/chat"
|
|
5
|
+
require_relative "instrumentation/responses"
|
|
6
|
+
|
|
7
|
+
module Braintrust
|
|
8
|
+
module Contrib
|
|
9
|
+
module OpenAI
|
|
10
|
+
# Patcher for OpenAI integration - implements class-level patching.
|
|
11
|
+
# All new OpenAI::Client instances created after patch! will be automatically instrumented.
|
|
12
|
+
class ChatPatcher < Braintrust::Contrib::Patcher
|
|
13
|
+
class << self
|
|
14
|
+
def applicable?
|
|
15
|
+
defined?(::OpenAI::Client)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def patched?(**options)
|
|
19
|
+
# Use the target's singleton class if provided, otherwise check the base class.
|
|
20
|
+
target_class = get_singleton_class(options[:target]) || ::OpenAI::Resources::Chat::Completions
|
|
21
|
+
|
|
22
|
+
Instrumentation::Chat::Completions.applied?(target_class)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Perform the actual patching.
|
|
26
|
+
# @param options [Hash] Configuration options passed from integration
|
|
27
|
+
# @option options [Object] :target Optional target instance to patch
|
|
28
|
+
# @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
|
|
29
|
+
# @return [void]
|
|
30
|
+
def perform_patch(**options)
|
|
31
|
+
return unless applicable?
|
|
32
|
+
|
|
33
|
+
# Stream classes are shared across all clients, patch at class level.
|
|
34
|
+
# The instrumentation short-circuits when no context is present,
|
|
35
|
+
# so uninstrumented clients' streams pass through unaffected.
|
|
36
|
+
patch_stream_classes
|
|
37
|
+
|
|
38
|
+
if options[:target]
|
|
39
|
+
# Instance-level (for only this client)
|
|
40
|
+
raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
|
|
41
|
+
|
|
42
|
+
get_singleton_class(options[:target]).include(Instrumentation::Chat::Completions)
|
|
43
|
+
else
|
|
44
|
+
# Class-level (for all clients)
|
|
45
|
+
::OpenAI::Resources::Chat::Completions.include(Instrumentation::Chat::Completions)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def patch_stream_classes
|
|
50
|
+
# Patch ChatCompletionStream for stream() method
|
|
51
|
+
if defined?(::OpenAI::Helpers::Streaming::ChatCompletionStream)
|
|
52
|
+
unless Instrumentation::Chat::ChatCompletionStream.applied?(::OpenAI::Helpers::Streaming::ChatCompletionStream)
|
|
53
|
+
::OpenAI::Helpers::Streaming::ChatCompletionStream.include(Instrumentation::Chat::ChatCompletionStream)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Patch Internal::Stream for stream_raw() method
|
|
58
|
+
if defined?(::OpenAI::Internal::Stream)
|
|
59
|
+
unless Instrumentation::Chat::InternalStream.applied?(::OpenAI::Internal::Stream)
|
|
60
|
+
::OpenAI::Internal::Stream.include(Instrumentation::Chat::InternalStream)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def get_singleton_class(client)
|
|
68
|
+
client&.chat&.completions&.singleton_class
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Patcher for OpenAI integration - implements class-level patching.
|
|
74
|
+
# All new OpenAI::Client instances created after patch! will be automatically instrumented.
|
|
75
|
+
class ResponsesPatcher < Braintrust::Contrib::Patcher
|
|
76
|
+
class << self
|
|
77
|
+
def applicable?
|
|
78
|
+
defined?(::OpenAI::Client) && ::OpenAI::Client.instance_methods.include?(:responses)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def patched?(**options)
|
|
82
|
+
# Use the target's singleton class if provided, otherwise check the base class.
|
|
83
|
+
target_class = get_singleton_class(options[:target]) || ::OpenAI::Resources::Responses
|
|
84
|
+
|
|
85
|
+
Instrumentation::Responses.applied?(target_class)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Perform the actual patching.
|
|
89
|
+
# @param options [Hash] Configuration options passed from integration
|
|
90
|
+
# @option options [Object] :target Optional target instance to patch
|
|
91
|
+
# @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
|
|
92
|
+
# @return [void]
|
|
93
|
+
def perform_patch(**options)
|
|
94
|
+
return unless applicable?
|
|
95
|
+
|
|
96
|
+
# Stream class is shared across all clients, patch at class level.
|
|
97
|
+
# The instrumentation short-circuits when no context is present,
|
|
98
|
+
# so uninstrumented clients' streams pass through unaffected.
|
|
99
|
+
patch_response_stream
|
|
100
|
+
|
|
101
|
+
if options[:target]
|
|
102
|
+
# Instance-level (for only this client)
|
|
103
|
+
raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
|
|
104
|
+
|
|
105
|
+
get_singleton_class(options[:target]).include(Instrumentation::Responses)
|
|
106
|
+
else
|
|
107
|
+
# Class-level (for all clients)
|
|
108
|
+
::OpenAI::Resources::Responses.include(Instrumentation::Responses)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def patch_response_stream
|
|
113
|
+
# Patch ResponseStream for stream() method
|
|
114
|
+
if defined?(::OpenAI::Helpers::Streaming::ResponseStream)
|
|
115
|
+
unless Instrumentation::ResponseStream.applied?(::OpenAI::Helpers::Streaming::ResponseStream)
|
|
116
|
+
::OpenAI::Helpers::Streaming::ResponseStream.include(Instrumentation::ResponseStream)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def get_singleton_class(client)
|
|
124
|
+
client&.responses&.singleton_class
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Braintrust
|
|
4
|
+
module Contrib
|
|
5
|
+
# Base class for all patchers.
|
|
6
|
+
# Provides thread-safe, idempotent patching with error handling.
|
|
7
|
+
class Patcher
|
|
8
|
+
# For thread-safety, a mutex is used to wrap patching.
|
|
9
|
+
# Each instance of Patcher should have its own copy of the mutex.,
|
|
10
|
+
# allowing each Patcher to work in parallel while protecting the
|
|
11
|
+
# critical patch section.
|
|
12
|
+
@patch_mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
def self.inherited(subclass)
|
|
15
|
+
subclass.instance_variable_set(:@patch_mutex, Mutex.new)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Has this patcher already been applied?
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def patched?(**options)
|
|
22
|
+
@patched == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Override in subclasses to check if patcher should apply.
|
|
26
|
+
# Called after patcher loads but before perform_patch.
|
|
27
|
+
# @return [Boolean] true if this patcher should be applied
|
|
28
|
+
def applicable?
|
|
29
|
+
true # Default: always applicable
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Apply the patch (thread-safe and idempotent).
|
|
33
|
+
# @param options [Hash] Configuration options passed from integration
|
|
34
|
+
# @option options [Object] :target Optional target instance to patch
|
|
35
|
+
# @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
|
|
36
|
+
# @return [Boolean] true if patching succeeded or was already done
|
|
37
|
+
def patch!(**options)
|
|
38
|
+
return false unless applicable?
|
|
39
|
+
return true if patched?(**options) # Fast path
|
|
40
|
+
|
|
41
|
+
@patch_mutex.synchronize do
|
|
42
|
+
unless applicable?
|
|
43
|
+
Braintrust::Log.debug("Skipping #{name} - not applicable")
|
|
44
|
+
return false
|
|
45
|
+
end
|
|
46
|
+
return true if patched?(**options) # Double-check under lock
|
|
47
|
+
|
|
48
|
+
perform_patch(**options)
|
|
49
|
+
@patched = true
|
|
50
|
+
end
|
|
51
|
+
Braintrust::Log.debug("Patched #{name}")
|
|
52
|
+
true
|
|
53
|
+
rescue => e
|
|
54
|
+
Braintrust::Log.error("Failed to patch #{name}: #{e.message}")
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Subclasses implement this to perform the actual patching.
|
|
59
|
+
# This method is called under lock after applicable? returns true.
|
|
60
|
+
#
|
|
61
|
+
# @param options [Hash] Configuration options passed from integration
|
|
62
|
+
# @option options [Object] :target Optional target instance to patch
|
|
63
|
+
# @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
|
|
64
|
+
# @return [void]
|
|
65
|
+
def perform_patch(**options)
|
|
66
|
+
raise NotImplementedError, "#{self} must implement perform_patch"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reset patched state (primarily for testing).
|
|
70
|
+
def reset!
|
|
71
|
+
@patched = false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Braintrust
|
|
4
|
+
module Contrib
|
|
5
|
+
module Rails
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
Braintrust.auto_instrument!(
|
|
9
|
+
only: Braintrust::Internal::Env.instrument_only,
|
|
10
|
+
except: Braintrust::Internal::Env.instrument_except
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module Braintrust
|
|
6
|
+
module Contrib
|
|
7
|
+
# Thread-safe singleton registry for integrations.
|
|
8
|
+
# Provides registration, lookup, and require-path mapping for auto-instrumentation.
|
|
9
|
+
class Registry
|
|
10
|
+
include Singleton
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@integrations = {}
|
|
14
|
+
@require_path_map = nil # Lazy cache
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register an integration class with the registry.
|
|
19
|
+
# @param integration_class [Class] The integration class to register
|
|
20
|
+
def register(integration_class)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@integrations[integration_class.integration_name] = integration_class
|
|
23
|
+
@require_path_map = nil # Invalidate cache
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Look up an integration by name.
|
|
28
|
+
# @param name [Symbol, String] The integration name
|
|
29
|
+
# @return [Class, nil] The integration class, or nil if not found
|
|
30
|
+
def [](name)
|
|
31
|
+
@integrations[name.to_sym]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get all registered integrations.
|
|
35
|
+
# @return [Array<Class>] All registered integration classes
|
|
36
|
+
def all
|
|
37
|
+
@integrations.values
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get all available integrations (target library is loaded).
|
|
41
|
+
# @return [Array<Class>] Available integration classes
|
|
42
|
+
def available
|
|
43
|
+
@integrations.values.select(&:available?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Iterate over all registered integrations.
|
|
47
|
+
# @yield [Class] Each registered integration class
|
|
48
|
+
def each(&block)
|
|
49
|
+
@integrations.values.each(&block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns integrations associated with a require path.
|
|
53
|
+
# Thread-safe with double-checked locking for performance.
|
|
54
|
+
# @param path [String] The require path (e.g., "openai", "anthropic")
|
|
55
|
+
# @return [Array<Class>] Integrations matching the require path
|
|
56
|
+
def integrations_for_require_path(path)
|
|
57
|
+
map = @require_path_map
|
|
58
|
+
if map.nil?
|
|
59
|
+
map = @mutex.synchronize do
|
|
60
|
+
@require_path_map ||= build_require_path_map
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
path_str = path.to_s
|
|
65
|
+
basename = File.basename(path_str, ".rb")
|
|
66
|
+
|
|
67
|
+
# Quick check: is this basename even in our map?
|
|
68
|
+
return EMPTY_ARRAY unless map.key?(basename)
|
|
69
|
+
|
|
70
|
+
# Only match top-level requires or gem entry points.
|
|
71
|
+
# Avoid matching internal subpaths (e.g., ruby_llm/providers/anthropic).
|
|
72
|
+
return EMPTY_ARRAY unless gem_entry_point?(path_str, basename)
|
|
73
|
+
|
|
74
|
+
map.fetch(basename, EMPTY_ARRAY)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
EMPTY_ARRAY = [].freeze
|
|
80
|
+
|
|
81
|
+
# Check if this is a gem entry point require.
|
|
82
|
+
# @param path [String] Full require path
|
|
83
|
+
# @param basename [String] File basename without extension
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def gem_entry_point?(path, basename)
|
|
86
|
+
# Direct require like `require 'anthropic'` - no directory separators
|
|
87
|
+
return true unless path.include?("/")
|
|
88
|
+
|
|
89
|
+
# Full path to gem entry point: /gems/anthropic-1.0.0/lib/anthropic.rb
|
|
90
|
+
# The basename appears in both the gem directory name AND as the final file
|
|
91
|
+
path.match?(%r{/#{Regexp.escape(basename)}[^/]*/lib/#{Regexp.escape(basename)}(\.rb)?$})
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_require_path_map
|
|
95
|
+
map = {}
|
|
96
|
+
@integrations.each_value do |integration|
|
|
97
|
+
integration.require_paths.each do |req|
|
|
98
|
+
map[req] ||= []
|
|
99
|
+
map[req] << integration
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
map.each_value(&:freeze)
|
|
103
|
+
map.freeze
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Backward compatibility shim for the old ruby_llm integration API.
|
|
4
|
+
# This file now just delegates to the new API.
|
|
5
|
+
|
|
6
|
+
module Braintrust
|
|
7
|
+
module Trace
|
|
8
|
+
module Contrib
|
|
9
|
+
module Github
|
|
10
|
+
module Crmne
|
|
11
|
+
module RubyLLM
|
|
12
|
+
# Wrap RubyLLM to automatically create spans for chat requests.
|
|
13
|
+
# This is the legacy API - delegates to the new contrib framework.
|
|
14
|
+
#
|
|
15
|
+
# @param chat [RubyLLM::Chat, nil] the chat instance to wrap (if nil, wraps the class)
|
|
16
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
17
|
+
# @return [RubyLLM::Chat, nil] the wrapped chat instance
|
|
18
|
+
def self.wrap(chat = nil, tracer_provider: nil)
|
|
19
|
+
Log.warn("Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap() is deprecated and will be removed in a future version: use Braintrust.instrument!() instead.")
|
|
20
|
+
Braintrust.instrument!(:ruby_llm, target: chat, tracer_provider: tracer_provider)
|
|
21
|
+
chat
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Unwrap RubyLLM to disable Braintrust tracing.
|
|
25
|
+
# This is the legacy API - uses the Context pattern to disable tracing.
|
|
26
|
+
#
|
|
27
|
+
# Note: Prepended modules cannot be truly removed in Ruby.
|
|
28
|
+
# This method sets `enabled: false` in the Context, which the
|
|
29
|
+
# instrumentation checks before creating spans.
|
|
30
|
+
#
|
|
31
|
+
# @param chat [RubyLLM::Chat, nil] the chat instance to unwrap (if nil, unwraps the class)
|
|
32
|
+
# @return [RubyLLM::Chat, nil] the chat instance
|
|
33
|
+
def self.unwrap(chat = nil)
|
|
34
|
+
Log.warn("Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.unwrap() is deprecated and will be removed in a future version.")
|
|
35
|
+
|
|
36
|
+
target = chat || ::RubyLLM::Chat
|
|
37
|
+
Braintrust::Contrib::Context.set!(target, enabled: false)
|
|
38
|
+
chat
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|