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,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