Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/braintrust/api/datasets.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def http_request(method, path, params: {}, payload: nil, base_url: nil, parse_js
raise ArgumentError, "Unsupported HTTP method: #{method}"
end

request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"

# Execute request with timing
start_time = Time.now
Expand Down
2 changes: 1 addition & 1 deletion lib/braintrust/api/functions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def http_request(method, path, params: {}, payload: nil, parse_json: true)
raise ArgumentError, "Unsupported HTTP method: #{method}"
end

request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"

# Execute request with timing
start_time = Time.now
Expand Down
2 changes: 1 addition & 1 deletion lib/braintrust/api/internal/btql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def execute_query(payload)

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"
request["Accept"] = "application/x-jsonlines"
request.body = JSON.dump(payload)

Expand Down
4 changes: 2 additions & 2 deletions lib/braintrust/api/internal/experiments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def create(name:, project_id:, ensure_new: true, tags: nil, metadata: nil,

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"
request.body = JSON.dump(payload)

response = Braintrust::Internal::Http.with_redirects(uri, request)
Expand All @@ -59,7 +59,7 @@ def delete(id:)
uri = URI("#{@state.api_url}/v1/experiment/#{id}")

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"

response = Braintrust::Internal::Http.with_redirects(uri, request)

Expand Down
4 changes: 2 additions & 2 deletions lib/braintrust/api/internal/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def create(name:)

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"
request.body = JSON.dump({name: name})

response = Braintrust::Internal::Http.with_redirects(uri, request)
Expand All @@ -44,7 +44,7 @@ def delete(id:)
uri = URI("#{@state.api_url}/v1/project/#{id}")

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.require_api_key}"

response = Braintrust::Internal::Http.with_redirects(uri, request)

Expand Down
18 changes: 15 additions & 3 deletions lib/braintrust/config.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# frozen_string_literal: true

require_relative "internal/api_key_resolver"

module Braintrust
# Configuration object that reads from environment variables
# and allows overriding with explicit options
class Config
attr_reader :api_key, :org_name, :default_project, :app_url, :api_url,
attr_reader :org_name, :default_project, :app_url, :api_url,
:filter_ai_spans, :span_filter_funcs

def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
filter_ai_spans: nil, span_filter_funcs: nil)
@api_key = api_key
@api_key_resolver = nil
@org_name = org_name
@default_project = default_project
@app_url = app_url
Expand All @@ -18,6 +21,11 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil,
@span_filter_funcs = span_filter_funcs || []
end

def api_key
@api_key = @api_key_resolver.api_key if @api_key.nil? && @api_key_resolver
@api_key
end

# Create a Config from environment variables, with option overrides
# Passed-in options take priority over ENV vars
# @param api_key [String, nil] Braintrust API key (overrides BRAINTRUST_API_KEY env var)
Expand All @@ -30,6 +38,8 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil,
# @return [Config] the created config
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
filter_ai_spans: nil, span_filter_funcs: nil)
api_key_resolver = Internal::ApiKeyResolver.new(explicit_api_key: api_key)

# Parse filter_ai_spans from ENV if not explicitly provided
env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"]
filter_ai_spans_value = if filter_ai_spans.nil?
Expand All @@ -38,15 +48,17 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
filter_ai_spans
end

new(
api_key: api_key || ((ENV["BRAINTRUST_API_KEY"] && ENV["BRAINTRUST_API_KEY"].empty?) ? nil : ENV["BRAINTRUST_API_KEY"]),
config = new(
api_key: api_key_resolver.immediate_api_key,
org_name: org_name || ENV["BRAINTRUST_ORG_NAME"],
default_project: default_project || ENV["BRAINTRUST_DEFAULT_PROJECT"],
app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev",
filter_ai_spans: filter_ai_spans_value,
span_filter_funcs: span_filter_funcs
)
config.instance_variable_set(:@api_key_resolver, api_key_resolver)
config
end
end
end
164 changes: 164 additions & 0 deletions lib/braintrust/internal/api_key_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# frozen_string_literal: true

module Braintrust
module Internal
# Resolves the Braintrust API key from explicit options, ENV, or the nearest
# .env.braintrust file without mutating the process environment.
class ApiKeyResolver
ENV_KEY = "BRAINTRUST_API_KEY"
ENV_FILE = ".env.braintrust"
SEARCH_PARENT_LIMIT = 64
ASSIGNMENT_REGEXP = /\A#{Regexp.escape(ENV_KEY)}\s*=\s*(.*)\z/o

attr_reader :immediate_api_key

def initialize(explicit_api_key: nil)
@mutex = Mutex.new
@resolved = false
@api_key = nil
@thread = nil

if !explicit_api_key.nil?
resolve_immediately(explicit_api_key)
else
env_api_key = ENV[ENV_KEY]
resolve_immediately(env_api_key) if env_api_key && !env_api_key.strip.empty?
end

@immediate_api_key = @api_key
end

def api_key
thread = start
thread&.join

@mutex.synchronize { @api_key }
end

def start
@mutex.synchronize do
return nil if @resolved
return @thread if @thread

search_start_dir = Dir.pwd
@thread = Thread.new(search_start_dir) do |start_dir|
key = self.class.find_file_api_key(start_dir)
@mutex.synchronize do
@api_key = key
@resolved = true
end
rescue
@mutex.synchronize do
@api_key = nil
@resolved = true
end
end
@thread.report_on_exception = false
@thread
end
end

def self.find_file_api_key(start_dir = Dir.pwd)
dir = start_dir

0.upto(SEARCH_PARENT_LIMIT) do
env_path = File.join(dir, ENV_FILE)

begin
contents = File.read(env_path)
rescue Errno::ENOENT, Errno::ENOTDIR
# Missing candidates are not boundaries; keep walking upward.
rescue
return nil
else
return parse_api_key(contents)
end

parent = File.dirname(dir)
break if parent == dir
dir = parent
end

nil
rescue
nil
end

def self.parse_api_key(contents)
value = nil

contents.each_line do |line|
found, parsed_value = parse_assignment(line)
value = parsed_value if found
end

(value && !value.strip.empty?) ? value : nil
rescue
nil
end

def self.parse_assignment(line)
stripped = line.delete_suffix("\n").delete_suffix("\r").lstrip
return [false, nil] if stripped.empty? || stripped.start_with?("#")

stripped = stripped.sub(/\Aexport\s+/, "")
match = stripped.match(ASSIGNMENT_REGEXP)
return [false, nil] unless match

[true, parse_value(match[1])]
end

def self.parse_value(raw_value)
value = raw_value.lstrip

case value[0]
when '"'
parse_double_quoted_value(value[1..])
when "'"
parse_single_quoted_value(value[1..])
else
value.sub(/\s+#.*\z/, "").strip
end
end

def self.parse_double_quoted_value(value)
parsed = +""
escaped = false

value.each_char do |char|
if escaped
parsed << case char
when "n" then "\n"
when "r" then "\r"
when "t" then "\t"
else char
end
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
return parsed
else
parsed << char
end
end

parsed
end

def self.parse_single_quoted_value(value)
quote_index = value.index("'")
quote_index ? value[0...quote_index] : value
end

private_class_method :parse_assignment, :parse_value, :parse_double_quoted_value, :parse_single_quoted_value

private

def resolve_immediately(api_key)
@api_key = api_key
@resolved = true
end
end
end
end
2 changes: 1 addition & 1 deletion lib/braintrust/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# require "braintrust/setup"
#
# Environment variables:
# BRAINTRUST_API_KEY - Required for tracing to work
# BRAINTRUST_API_KEY - Required for tracing to work; falls back to .env.braintrust
# BRAINTRUST_AUTO_INSTRUMENT - Set to "false" to disable (default: true)
# BRAINTRUST_INSTRUMENT_ONLY - Comma-separated whitelist
# BRAINTRUST_INSTRUMENT_EXCEPT - Comma-separated blacklist
Expand Down
18 changes: 15 additions & 3 deletions lib/braintrust/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Braintrust
# State object that holds Braintrust configuration
# Thread-safe global state management
class State
class MissingAPIKeyError < ArgumentError; end

attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config

@mutex = Mutex.new
Expand Down Expand Up @@ -66,7 +68,7 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, config: nil, exporter: nil)
# Instance-level mutex for thread-safe login
@login_mutex = Mutex.new
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
raise MissingAPIKeyError, "api_key is required" if api_key.nil? || api_key.empty?

@api_key = api_key
@org_name = org_name
Expand Down Expand Up @@ -101,6 +103,12 @@ def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, a
end
end

def require_api_key
key = @api_key
raise MissingAPIKeyError, "api_key is required" if key.nil? || key.empty?
key
end

# Thread-safe global state getter
def self.global
@mutex.synchronize { @global_state }
Expand All @@ -121,9 +129,10 @@ def login
@login_mutex.synchronize do
# Return early if already logged in
return self if @logged_in
api_key = require_api_key

result = API::Internal::Auth.login(
api_key: @api_key,
api_key: api_key,
app_url: @app_url,
org_name: @org_name
)
Expand Down Expand Up @@ -167,6 +176,9 @@ def login_in_thread
login
Log.debug("Background login succeeded")
break
rescue MissingAPIKeyError => e
Log.debug("Background login skipped: #{e.message}")
break
rescue => e
retry_count += 1
delay = [0.001 * 2**(retry_count - 1), max_delay].min
Expand All @@ -190,7 +202,7 @@ def wait_for_login(timeout = nil)
# Raises ArgumentError if state is invalid
# @return [self]
def validate
raise ArgumentError, "api_key is required" if @api_key.nil? || @api_key.empty?
require_api_key
raise ArgumentError, "api_url is required" if @api_url.nil? || @api_url.empty?
raise ArgumentError, "app_url is required" if @app_url.nil? || @app_url.empty?

Expand Down
3 changes: 3 additions & 0 deletions lib/braintrust/trace/span_exporter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "opentelemetry/exporter/otlp"
require_relative "../state"

module Braintrust
module Trace
Expand All @@ -18,6 +19,8 @@ class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter
FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE

def initialize(endpoint:, api_key:)
raise State::MissingAPIKeyError, "api_key is required" if api_key.nil? || api_key.empty?

super(endpoint: endpoint, headers: {"Authorization" => "Bearer #{api_key}"})
end

Expand Down
Loading
Loading