braintrust 0.0.1.alpha.2

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+
6
+ module Braintrust
7
+ module Trace
8
+ module OpenAI
9
+ # Wrap an OpenAI::Client to automatically create spans for chat completions
10
+ # @param client [OpenAI::Client] the OpenAI client to wrap
11
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
12
+ def self.wrap(client, tracer_provider: nil)
13
+ tracer_provider ||= ::OpenTelemetry.tracer_provider
14
+
15
+ # Create a wrapper module that intercepts chat.completions.create
16
+ wrapper = Module.new do
17
+ define_method(:create) do |**params|
18
+ tracer = tracer_provider.tracer("braintrust")
19
+
20
+ tracer.in_span("openai.chat.completions.create") do |span|
21
+ # Initialize metadata hash
22
+ metadata = {
23
+ "provider" => "openai",
24
+ "endpoint" => "/v1/chat/completions"
25
+ }
26
+
27
+ # Capture request metadata fields
28
+ metadata_fields = %i[
29
+ model frequency_penalty logit_bias logprobs max_tokens n
30
+ presence_penalty response_format seed service_tier stop
31
+ stream stream_options temperature top_p top_logprobs
32
+ tools tool_choice parallel_tool_calls user functions function_call
33
+ ]
34
+
35
+ metadata_fields.each do |field|
36
+ metadata[field.to_s] = params[field] if params.key?(field)
37
+ end
38
+
39
+ # Set input messages as JSON
40
+ if params[:messages]
41
+ messages_array = params[:messages].map do |msg|
42
+ {role: msg[:role].to_s, content: msg[:content]}
43
+ end
44
+ span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
45
+ end
46
+
47
+ # Call the original method
48
+ response = super(**params)
49
+
50
+ # Set output (choices) as JSON
51
+ # Use to_h to get the raw structure with all fields (including tool_calls)
52
+ if response.respond_to?(:choices) && response.choices&.any?
53
+ choices_array = response.choices.map(&:to_h)
54
+ span.set_attribute("braintrust.output_json", JSON.generate(choices_array))
55
+ end
56
+
57
+ # Set metrics (token usage)
58
+ if response.respond_to?(:usage) && response.usage
59
+ metrics = {}
60
+ metrics["prompt_tokens"] = response.usage.prompt_tokens if response.usage.prompt_tokens
61
+ metrics["completion_tokens"] = response.usage.completion_tokens if response.usage.completion_tokens
62
+ metrics["tokens"] = response.usage.total_tokens if response.usage.total_tokens
63
+ span.set_attribute("braintrust.metrics", JSON.generate(metrics))
64
+ end
65
+
66
+ # Add response metadata fields
67
+ metadata["id"] = response.id if response.respond_to?(:id) && response.id
68
+ metadata["created"] = response.created if response.respond_to?(:created) && response.created
69
+ metadata["system_fingerprint"] = response.system_fingerprint if response.respond_to?(:system_fingerprint) && response.system_fingerprint
70
+ metadata["service_tier"] = response.service_tier if response.respond_to?(:service_tier) && response.service_tier
71
+
72
+ # Set metadata ONCE at the end with complete hash
73
+ span.set_attribute("braintrust.metadata", JSON.generate(metadata))
74
+
75
+ response
76
+ end
77
+ end
78
+ end
79
+
80
+ # Prepend the wrapper to the completions resource
81
+ client.chat.completions.singleton_class.prepend(wrapper)
82
+
83
+ client
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ module Braintrust
6
+ module Trace
7
+ # Custom span processor that adds Braintrust-specific attributes to spans
8
+ class SpanProcessor
9
+ PARENT_ATTR_KEY = "braintrust.parent"
10
+ ORG_ATTR_KEY = "braintrust.org"
11
+ APP_URL_ATTR_KEY = "braintrust.app_url"
12
+
13
+ def initialize(wrapped_processor, state)
14
+ @wrapped = wrapped_processor
15
+ @state = state
16
+ end
17
+
18
+ def on_start(span, parent_context)
19
+ # Add default parent if span doesn't already have one
20
+ has_parent = span.respond_to?(:attributes) && span.attributes&.key?(PARENT_ATTR_KEY)
21
+
22
+ unless has_parent
23
+ # Try to inherit parent from parent span in context
24
+ parent_value = get_parent_from_context(parent_context) || default_parent
25
+ span.set_attribute(PARENT_ATTR_KEY, parent_value)
26
+ end
27
+
28
+ # Always add org and app_url
29
+ span.set_attribute(ORG_ATTR_KEY, @state.org_name) if @state.org_name
30
+ span.set_attribute(APP_URL_ATTR_KEY, @state.app_url) if @state.app_url
31
+
32
+ # Delegate to wrapped processor
33
+ @wrapped.on_start(span, parent_context)
34
+ end
35
+
36
+ # Called when a span ends
37
+ def on_finish(span)
38
+ @wrapped.on_finish(span)
39
+ end
40
+
41
+ # Shutdown the processor
42
+ def shutdown(timeout: nil)
43
+ @wrapped.shutdown(timeout: timeout)
44
+ end
45
+
46
+ # Force flush any buffered spans
47
+ def force_flush(timeout: nil)
48
+ @wrapped.force_flush(timeout: timeout)
49
+ end
50
+
51
+ private
52
+
53
+ def default_parent
54
+ @state.default_parent || "project_name:ruby-sdk-default-project"
55
+ end
56
+
57
+ # Get parent attribute from parent span in context
58
+ def get_parent_from_context(parent_context)
59
+ return nil unless parent_context
60
+
61
+ # Get the current span from the context (the parent span)
62
+ parent_span = OpenTelemetry::Trace.current_span(parent_context)
63
+ return nil unless parent_span
64
+ return nil unless parent_span.respond_to?(:attributes)
65
+
66
+ # Return the parent attribute from the parent span
67
+ parent_span.attributes&.[](PARENT_ATTR_KEY)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "opentelemetry/exporter/otlp"
5
+ require_relative "trace/span_processor"
6
+ require_relative "trace/openai"
7
+ require_relative "logger"
8
+
9
+ module Braintrust
10
+ module Trace
11
+ def self.enable(tracer_provider, state: nil, exporter: nil)
12
+ state ||= Braintrust.current_state
13
+ raise Error, "No state available" unless state
14
+
15
+ # Create OTLP HTTP exporter unless override provided
16
+ exporter ||= OpenTelemetry::Exporter::OTLP::Exporter.new(
17
+ endpoint: "#{state.api_url}/otel/v1/traces",
18
+ headers: {
19
+ "Authorization" => "Bearer #{state.api_key}"
20
+ }
21
+ )
22
+
23
+ # Wrap in batch processor
24
+ batch_processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
25
+
26
+ # Wrap batch processor in our custom span processor to add Braintrust attributes
27
+ processor = SpanProcessor.new(batch_processor, state)
28
+
29
+ # Register with tracer provider
30
+ tracer_provider.add_span_processor(processor)
31
+
32
+ # Console debug if enabled
33
+ if ENV["BRAINTRUST_ENABLE_TRACE_CONSOLE_LOG"]
34
+ console_exporter = OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new
35
+ console_processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(console_exporter)
36
+ tracer_provider.add_span_processor(console_processor)
37
+ end
38
+
39
+ self
40
+ end
41
+
42
+ # Generate a permalink URL for a span to view in the Braintrust UI
43
+ # Returns an empty string if the permalink cannot be generated
44
+ # @param span [OpenTelemetry::Trace::Span] The span to generate a permalink for
45
+ # @return [String] The permalink URL, or empty string if an error occurs
46
+ def self.permalink(span)
47
+ return "" if span.nil?
48
+
49
+ # Extract required attributes from span
50
+ span_context = span.context
51
+ trace_id = span_context.hex_trace_id
52
+ span_id = span_context.hex_span_id
53
+
54
+ # Get Braintrust attributes
55
+ attributes = span.attributes if span.respond_to?(:attributes)
56
+ unless attributes
57
+ Log.error("Span does not support attributes")
58
+ return ""
59
+ end
60
+
61
+ app_url = attributes[SpanProcessor::APP_URL_ATTR_KEY]
62
+ org_name = attributes[SpanProcessor::ORG_ATTR_KEY]
63
+ parent = attributes[SpanProcessor::PARENT_ATTR_KEY]
64
+
65
+ # Validate required attributes
66
+ unless app_url
67
+ Log.error("Missing required attribute: #{SpanProcessor::APP_URL_ATTR_KEY}")
68
+ return ""
69
+ end
70
+
71
+ unless org_name
72
+ Log.error("Missing required attribute: #{SpanProcessor::ORG_ATTR_KEY}")
73
+ return ""
74
+ end
75
+
76
+ unless parent
77
+ Log.error("Missing required attribute: #{SpanProcessor::PARENT_ATTR_KEY}")
78
+ return ""
79
+ end
80
+
81
+ # Parse parent to determine URL format
82
+ parent_type, parent_id = parent.split(":", 2)
83
+ unless parent_type && parent_id
84
+ Log.error("Invalid parent format: #{parent}")
85
+ return ""
86
+ end
87
+
88
+ # Build the permalink URL based on parent type
89
+ if parent_type == "experiment_id"
90
+ # For experiments: {app_url}/app/{org}/p/{project}/experiments/{experiment_id}?r={trace_id}&s={span_id}
91
+ project_name, experiment_id = parent_id.split("/", 2)
92
+ unless project_name && experiment_id
93
+ Log.error("Invalid experiment parent format: #{parent_id}")
94
+ return ""
95
+ end
96
+
97
+ "#{app_url}/app/#{org_name}/p/#{project_name}/experiments/#{experiment_id}?r=#{trace_id}&s=#{span_id}"
98
+ else
99
+ # For projects: {app_url}/app/{org}/p/{project}/logs?r={trace_id}&s={span_id}
100
+ # parent_type is typically "project_name"
101
+ "#{app_url}/app/#{org_name}/p/#{parent_id}/logs?r=#{trace_id}&s=#{span_id}"
102
+ end
103
+ rescue => e
104
+ Log.error("Failed to generate permalink: #{e.message}")
105
+ ""
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ VERSION = "0.0.1.alpha.2"
5
+ end
data/lib/braintrust.rb ADDED
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "braintrust/version"
4
+ require_relative "braintrust/config"
5
+ require_relative "braintrust/state"
6
+ require_relative "braintrust/trace"
7
+ require_relative "braintrust/api"
8
+ require_relative "braintrust/internal/experiments"
9
+ require_relative "braintrust/eval"
10
+
11
+ # Braintrust Ruby SDK
12
+ #
13
+ # OpenTelemetry-based SDK for Braintrust with tracing, OpenAI integration, and evals.
14
+ #
15
+ # @example Initialize with global state
16
+ # Braintrust.init(
17
+ # api_key: ENV['BRAINTRUST_API_KEY'],
18
+ # project: "my-project"
19
+ # )
20
+ #
21
+ # @example Initialize with explicit state
22
+ # state = Braintrust.init(
23
+ # api_key: ENV['BRAINTRUST_API_KEY'],
24
+ # set_global: false
25
+ # )
26
+ module Braintrust
27
+ class Error < StandardError; end
28
+
29
+ # Initialize Braintrust SDK
30
+ # Creates a State from config (ENV + options) and optionally sets it as global
31
+ #
32
+ # By default, kicks off an async background login that retries indefinitely.
33
+ # Use blocking_login: true to login synchronously before returning.
34
+ #
35
+ # @param set_global [Boolean] whether to set as global state (default: true)
36
+ # @param blocking_login [Boolean] whether to block and login synchronously (default: false, which starts async login)
37
+ # @param tracing [Boolean] whether to enable OpenTelemetry tracing (default: true)
38
+ # @param tracer_provider [TracerProvider, nil] Optional tracer provider to use instead of creating one
39
+ # @param api_key [String, nil] Braintrust API key (overrides BRAINTRUST_API_KEY env var)
40
+ # @param org_name [String, nil] Organization name (overrides BRAINTRUST_ORG_NAME env var)
41
+ # @param default_parent [String, nil] Default parent for spans (overrides BRAINTRUST_DEFAULT_PROJECT env var, format: "project_name:my-project" or "project_id:uuid")
42
+ # @param app_url [String, nil] App URL (overrides BRAINTRUST_APP_URL env var, default: https://www.braintrust.dev)
43
+ # @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var, default: https://api.braintrust.dev)
44
+ # @return [State] the created state
45
+ def self.init(set_global: true, blocking_login: false, tracing: true, tracer_provider: nil, **options)
46
+ config = Config.from_env(**options)
47
+ state = State.new(
48
+ api_key: config.api_key,
49
+ org_name: config.org_name,
50
+ default_parent: config.default_parent,
51
+ app_url: config.app_url,
52
+ api_url: config.api_url
53
+ )
54
+
55
+ State.global = state if set_global
56
+
57
+ # Login: either blocking (synchronous) or async (background thread with retries)
58
+ if blocking_login
59
+ state.login
60
+ else
61
+ state.login_in_thread # Default: async background login
62
+ end
63
+
64
+ setup_tracing(state, tracer_provider) if tracing
65
+
66
+ state
67
+ end
68
+
69
+ # Get the current global state
70
+ # @return [State, nil] the global state, or nil if not set
71
+ def self.current_state
72
+ State.global
73
+ end
74
+
75
+ class << self
76
+ private
77
+
78
+ # Set up OpenTelemetry tracing with Braintrust
79
+ # @param state [State] Braintrust state
80
+ # @param explicit_provider [TracerProvider, nil] Optional explicit tracer provider
81
+ # @return [void]
82
+ def setup_tracing(state, explicit_provider = nil)
83
+ require "opentelemetry/sdk"
84
+
85
+ if explicit_provider
86
+ # Use the explicitly provided tracer provider
87
+ # DO NOT set as global - user is managing it themselves
88
+ Log.debug("Using explicitly provided OpenTelemetry tracer provider")
89
+ tracer_provider = explicit_provider
90
+ else
91
+ # Check if global tracer provider is already a real TracerProvider
92
+ current_provider = OpenTelemetry.tracer_provider
93
+
94
+ if current_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
95
+ # Use existing provider
96
+ Log.debug("Using existing OpenTelemetry tracer provider")
97
+ tracer_provider = current_provider
98
+ else
99
+ # Create new provider and set as global
100
+ tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
101
+ OpenTelemetry.tracer_provider = tracer_provider
102
+ Log.debug("Created OpenTelemetry tracer provider")
103
+ end
104
+ end
105
+
106
+ # Enable Braintrust tracing (adds span processor)
107
+ Trace.enable(tracer_provider, state: state)
108
+ end
109
+ end
110
+ end
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: braintrust
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha.2
5
+ platform: ruby
6
+ authors:
7
+ - Braintrust
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: opentelemetry-sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: opentelemetry-exporter-otlp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.28'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.28'
40
+ - !ruby/object:Gem::Dependency
41
+ name: openssl
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 3.3.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 3.3.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: standard
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: simplecov
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.22'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.22'
110
+ - !ruby/object:Gem::Dependency
111
+ name: openai
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.34'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.34'
124
+ description: OpenTelemetry-based SDK for Braintrust with tracing, OpenAI integration,
125
+ and evals
126
+ email:
127
+ - info@braintrust.dev
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - README.md
133
+ - lib/braintrust.rb
134
+ - lib/braintrust/api.rb
135
+ - lib/braintrust/api/datasets.rb
136
+ - lib/braintrust/api/functions.rb
137
+ - lib/braintrust/api/internal/auth.rb
138
+ - lib/braintrust/config.rb
139
+ - lib/braintrust/eval.rb
140
+ - lib/braintrust/eval/case.rb
141
+ - lib/braintrust/eval/cases.rb
142
+ - lib/braintrust/eval/functions.rb
143
+ - lib/braintrust/eval/result.rb
144
+ - lib/braintrust/eval/scorer.rb
145
+ - lib/braintrust/internal/experiments.rb
146
+ - lib/braintrust/logger.rb
147
+ - lib/braintrust/state.rb
148
+ - lib/braintrust/trace.rb
149
+ - lib/braintrust/trace/openai.rb
150
+ - lib/braintrust/trace/span_processor.rb
151
+ - lib/braintrust/version.rb
152
+ homepage: https://github.com/braintrustdata/braintrust-sdk-ruby
153
+ licenses:
154
+ - Apache-2.0
155
+ metadata:
156
+ homepage_uri: https://github.com/braintrustdata/braintrust-sdk-ruby
157
+ source_code_uri: https://github.com/braintrustdata/braintrust-sdk-ruby
158
+ changelog_uri: https://github.com/braintrustdata/braintrust-sdk-ruby/blob/main/CHANGELOG.md
159
+ rdoc_options: []
160
+ require_paths:
161
+ - lib
162
+ required_ruby_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: 3.2.0
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ requirements: []
173
+ rubygems_version: 3.6.9
174
+ specification_version: 4
175
+ summary: Ruby SDK for Braintrust
176
+ test_files: []