From c046eb15072dc7a3d9219e89fd7e2777be6e1e0a Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 26 May 2026 16:54:10 +0200 Subject: [PATCH 1/3] feat: Upwards-recursively read `.env.braintrust` containing `BRAINTRUST_API_KEY` --- lib/braintrust/api/datasets.rb | 2 +- lib/braintrust/api/functions.rb | 2 +- lib/braintrust/api/internal/btql.rb | 2 +- lib/braintrust/api/internal/experiments.rb | 4 +- lib/braintrust/api/internal/projects.rb | 4 +- lib/braintrust/config.rb | 25 ++- lib/braintrust/internal/api_key_resolver.rb | 164 ++++++++++++++++++++ lib/braintrust/setup.rb | 2 +- lib/braintrust/state.rb | 37 ++++- lib/braintrust/trace.rb | 3 +- lib/braintrust/trace/span_exporter.rb | 26 +++- test/braintrust/config_test.rb | 143 +++++++++++++++++ test/braintrust/trace/span_exporter_test.rb | 26 +++- test/braintrust_test.rb | 19 +++ 14 files changed, 435 insertions(+), 24 deletions(-) create mode 100644 lib/braintrust/internal/api_key_resolver.rb diff --git a/lib/braintrust/api/datasets.rb b/lib/braintrust/api/datasets.rb index 39e7639d..a979eb95 100644 --- a/lib/braintrust/api/datasets.rb +++ b/lib/braintrust/api/datasets.rb @@ -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 diff --git a/lib/braintrust/api/functions.rb b/lib/braintrust/api/functions.rb index 45c02b02..98384277 100644 --- a/lib/braintrust/api/functions.rb +++ b/lib/braintrust/api/functions.rb @@ -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 diff --git a/lib/braintrust/api/internal/btql.rb b/lib/braintrust/api/internal/btql.rb index 1df598b9..89ff3717 100644 --- a/lib/braintrust/api/internal/btql.rb +++ b/lib/braintrust/api/internal/btql.rb @@ -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) diff --git a/lib/braintrust/api/internal/experiments.rb b/lib/braintrust/api/internal/experiments.rb index 923a785e..2d7e3870 100644 --- a/lib/braintrust/api/internal/experiments.rb +++ b/lib/braintrust/api/internal/experiments.rb @@ -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) @@ -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) diff --git a/lib/braintrust/api/internal/projects.rb b/lib/braintrust/api/internal/projects.rb index 58ed4938..4cb1b32b 100644 --- a/lib/braintrust/api/internal/projects.rb +++ b/lib/braintrust/api/internal/projects.rb @@ -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) @@ -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) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 336f8459..d267e231 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -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, - :filter_ai_spans, :span_filter_funcs + attr_reader :org_name, :default_project, :app_url, :api_url, + :filter_ai_spans, :span_filter_funcs, :api_key_resolver 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) + filter_ai_spans: nil, span_filter_funcs: nil, api_key_resolver: nil) @api_key = api_key + @api_key_resolver = api_key_resolver @org_name = org_name @default_project = default_project @app_url = app_url @@ -18,6 +21,15 @@ 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 + + def api_key_immediate + @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) @@ -30,6 +42,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? @@ -39,13 +53,14 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni end new( - api_key: api_key || ((ENV["BRAINTRUST_API_KEY"] && ENV["BRAINTRUST_API_KEY"].empty?) ? nil : ENV["BRAINTRUST_API_KEY"]), + 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 + span_filter_funcs: span_filter_funcs, + api_key_resolver: api_key_resolver ) end end diff --git a/lib/braintrust/internal/api_key_resolver.rb b/lib/braintrust/internal/api_key_resolver.rb new file mode 100644 index 00000000..19c16e62 --- /dev/null +++ b/lib/braintrust/internal/api_key_resolver.rb @@ -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 + @search_start_dir = Dir.pwd + + 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 + + @thread = Thread.new do + key = self.class.find_file_api_key(@search_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 diff --git a/lib/braintrust/setup.rb b/lib/braintrust/setup.rb index b947ac21..0c9265b6 100644 --- a/lib/braintrust/setup.rb +++ b/lib/braintrust/setup.rb @@ -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 diff --git a/lib/braintrust/state.rb b/lib/braintrust/state.rb index f8c6275d..a74fdab3 100644 --- a/lib/braintrust/state.rb +++ b/lib/braintrust/state.rb @@ -6,7 +6,10 @@ module Braintrust # State object that holds Braintrust configuration # Thread-safe global state management class State - attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config + class MissingAPIKeyError < ArgumentError; end + + attr_reader :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config, + :api_key_resolver @mutex = Mutex.new @global_state = nil @@ -36,7 +39,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni span_filter_funcs: span_filter_funcs ) new( - api_key: config.api_key, + api_key: config.api_key_immediate, + api_key_resolver: config.api_key_resolver, org_name: config.org_name, default_project: config.default_project, app_url: config.app_url, @@ -63,12 +67,14 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni # @param config [Config, nil] Optional config object # @param exporter [Exporter, nil] Optional exporter for testing # @return [State] the created state - 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) + 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, api_key_resolver: 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_resolver.nil? && (api_key.nil? || api_key.empty?) + raise MissingAPIKeyError, "api_key is required" if api_key&.empty? @api_key = api_key + @api_key_resolver = api_key_resolver @org_name = org_name @org_id = org_id @default_project = default_project @@ -101,6 +107,21 @@ def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, a end end + def api_key + @api_key = @api_key_resolver.api_key if @api_key.nil? && @api_key_resolver + @api_key + end + + def api_key_immediate + @api_key + 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 } @@ -121,9 +142,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 ) @@ -167,6 +189,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 @@ -190,7 +215,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? diff --git a/lib/braintrust/trace.rb b/lib/braintrust/trace.rb index 2c6563fe..c0886b4f 100644 --- a/lib/braintrust/trace.rb +++ b/lib/braintrust/trace.rb @@ -91,7 +91,8 @@ def self.enable(tracer_provider, state: nil, exporter: nil, config: nil) # Create OTLP HTTP exporter unless override provided exporter ||= SpanExporter.new( endpoint: "#{state.api_url}/otel/v1/traces", - api_key: state.api_key + api_key: state.api_key_immediate, + api_key_resolver: state.api_key_resolver ) # Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production diff --git a/lib/braintrust/trace/span_exporter.rb b/lib/braintrust/trace/span_exporter.rb index fc7502fa..fa794da5 100644 --- a/lib/braintrust/trace/span_exporter.rb +++ b/lib/braintrust/trace/span_exporter.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "opentelemetry/exporter/otlp" +require_relative "../logger" +require_relative "../state" module Braintrust module Trace @@ -17,11 +19,17 @@ class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE - def initialize(endpoint:, api_key:) - super(endpoint: endpoint, headers: {"Authorization" => "Bearer #{api_key}"}) + def initialize(endpoint:, api_key:, api_key_resolver: nil) + @api_key = api_key + @api_key_resolver = api_key_resolver + headers = {} + headers["Authorization"] = "Bearer #{api_key}" if api_key + super(endpoint: endpoint, headers: headers) end def export(span_data, timeout: nil) + ensure_authorization_header! + failed = false span_data.group_by { |sd| sd.attributes&.[](PARENT_ATTR_KEY) }.each do |parent_value, spans| @headers[PARENT_HEADER] = parent_value if parent_value @@ -30,6 +38,20 @@ def export(span_data, timeout: nil) @headers.delete(PARENT_HEADER) end failed ? FAILURE : SUCCESS + rescue State::MissingAPIKeyError => e + Log.debug("Trace export skipped: #{e.message}") + FAILURE + end + + private + + def ensure_authorization_header! + return if @headers["Authorization"] + + @api_key = @api_key_resolver.api_key if @api_key.nil? && @api_key_resolver + raise State::MissingAPIKeyError, "api_key is required" if @api_key.nil? || @api_key.empty? + + @headers["Authorization"] = "Bearer #{@api_key}" end end end diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index 0c9edbef..bf5373fe 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "test_helper" +require "fileutils" +require "tmpdir" BRAINTRUST_CONFIG_ENV_VALUES = { "BRAINTRUST_API_KEY" => ENV["BRAINTRUST_API_KEY"], @@ -13,9 +15,15 @@ class Braintrust::ConfigTest < Minitest::Test def setup # Setup a clean state BRAINTRUST_CONFIG_ENV_VALUES.keys.each { |env_var| ENV.delete(env_var) } + @original_cwd = Dir.pwd + @tmpdir = Dir.mktmpdir("braintrust-config-test") + Dir.chdir(@tmpdir) end def teardown + Dir.chdir(@original_cwd) + FileUtils.rm_rf(@tmpdir) + # Restore original env vars BRAINTRUST_CONFIG_ENV_VALUES.each do |env_var, env_value| if env_value @@ -61,4 +69,139 @@ def test_env_vars_override_defaults assert_equal "https://custom.braintrust.dev", config.app_url end + + def test_falls_back_to_env_braintrust_file + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + end + + def test_env_braintrust_lookup_uses_cwd_from_config_creation + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + config = Braintrust::Config.from_env + other_dir = File.join(@tmpdir, "other") + FileUtils.mkdir_p(other_dir) + + Dir.chdir(other_dir) + + assert_equal "file-key", config.api_key + end + + def test_falls_back_to_env_braintrust_when_process_env_is_blank + ENV["BRAINTRUST_API_KEY"] = " " + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + end + + def test_process_env_overrides_env_braintrust + ENV["BRAINTRUST_API_KEY"] = "env-key" + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + + config = Braintrust::Config.from_env + + assert_equal "env-key", config.api_key + end + + def test_explicit_api_key_overrides_env_and_env_braintrust + ENV["BRAINTRUST_API_KEY"] = "env-key" + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + + config = Braintrust::Config.from_env(api_key: "explicit-key") + + assert_equal "explicit-key", config.api_key + end + + def test_finds_nearest_parent_env_braintrust + nested = File.join(@tmpdir, "packages", "app") + FileUtils.mkdir_p(nested) + write_braintrust_env("BRAINTRUST_API_KEY=root-key\n") + write_braintrust_env("BRAINTRUST_API_KEY=package-key\n", dir: File.dirname(nested)) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_equal "package-key", config.api_key + end + + def test_nearest_env_braintrust_without_key_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_env("BRAINTRUST_API_KEY=root-key\n") + write_braintrust_env("OTHER=value\n", dir: package_dir) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_nearest_env_braintrust_with_blank_key_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_env("BRAINTRUST_API_KEY=root-key\n") + write_braintrust_env("BRAINTRUST_API_KEY=\" \"\n", dir: package_dir) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_unreadable_nearest_env_braintrust_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_env("BRAINTRUST_API_KEY=root-key\n") + FileUtils.mkdir_p(File.join(package_dir, ".env.braintrust")) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_searches_cwd_and_at_most_64_parents + segments = Array.new(65) { |i| "d#{i}" } + nested = File.join(@tmpdir, *segments) + FileUtils.mkdir_p(nested) + write_braintrust_env("BRAINTRUST_API_KEY=too-high\n") + + Dir.chdir(nested) + assert_nil Braintrust::Config.from_env.api_key + + write_braintrust_env("BRAINTRUST_API_KEY=boundary-key\n", dir: File.join(@tmpdir, segments.first)) + + assert_equal "boundary-key", Braintrust::Config.from_env.api_key + end + + def test_supports_dotenv_syntax_without_loading_other_variables + write_braintrust_env("export BRAINTRUST_API_KEY=\"quoted-key\" # comment\nOTHER=value\n") + + config = Braintrust::Config.from_env + + assert_equal "quoted-key", config.api_key + assert_nil ENV["OTHER"] + end + + def test_does_not_mutate_process_env + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + assert_nil ENV["BRAINTRUST_API_KEY"] + end + + private + + def write_braintrust_env(contents, dir: @tmpdir) + File.write(File.join(dir, ".env.braintrust"), contents) + end end diff --git a/test/braintrust/trace/span_exporter_test.rb b/test/braintrust/trace/span_exporter_test.rb index 45a5c753..c2a5ee4f 100644 --- a/test/braintrust/trace/span_exporter_test.rb +++ b/test/braintrust/trace/span_exporter_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "opentelemetry/sdk" +require "tmpdir" class Braintrust::Trace::SpanExporterTest < Minitest::Test SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS @@ -30,10 +31,13 @@ def make_span(name, parent: nil) class RecordingExporter < Braintrust::Trace::SpanExporter attr_reader :calls - def initialize + def initialize(api_key: "test-key", api_key_resolver: nil) @calls = [] # Initialize headers directly — skip super to avoid HTTP setup - @headers = {"Authorization" => "Bearer test-key"} + @headers = {} + @headers["Authorization"] = "Bearer #{api_key}" if api_key + @api_key = api_key + @api_key_resolver = api_key_resolver @shutdown = false end @@ -102,6 +106,24 @@ def test_handles_nil_parent refute exporter.calls[0][:headers].key?("x-bt-parent") end + def test_resolves_authorization_header_on_export + original_cwd = Dir.pwd + + Dir.mktmpdir("braintrust-exporter-env") do |dir| + File.write(File.join(dir, ".env.braintrust"), "BRAINTRUST_API_KEY=file-key\n") + Dir.chdir(dir) + resolver = Braintrust::Internal::ApiKeyResolver.new + exporter = RecordingExporter.new(api_key: nil, api_key_resolver: resolver) + + result = exporter.export([make_span("span1")]) + + assert_equal SUCCESS, result + assert_equal "Bearer file-key", exporter.calls[0][:headers]["Authorization"] + ensure + Dir.chdir(original_cwd) + end + end + def test_mixed_nil_and_non_nil_parents exporter = RecordingExporter.new diff --git a/test/braintrust_test.rb b/test/braintrust_test.rb index b557fb7f..55ef912c 100644 --- a/test/braintrust_test.rb +++ b/test/braintrust_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "tmpdir" class BraintrustTest < Minitest::Test def setup @@ -70,6 +71,24 @@ def test_init_merges_options_with_env assert_equal "my-project", state.default_project end + def test_init_uses_env_braintrust_api_key + ENV.delete("BRAINTRUST_API_KEY") + original_cwd = Dir.pwd + + Dir.mktmpdir("braintrust-init-test") do |dir| + File.write(File.join(dir, ".env.braintrust"), "BRAINTRUST_API_KEY=test-api-key\n") + Dir.chdir(dir) + + state = Braintrust.init(set_global: false, exporter: @memory_exporter) + state.wait_for_login(1) + + assert_equal "test-api-key", state.api_key + assert state.logged_in + ensure + Dir.chdir(original_cwd) + end + end + def test_init_with_tracing_true_creates_tracer_provider # Verify we start with the default proxy provider assert_instance_of OpenTelemetry::Internal::ProxyTracerProvider, OpenTelemetry.tracer_provider From 4655877a32883c61c46e1288ee438e05ec3aabc4 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 27 May 2026 00:01:15 +0200 Subject: [PATCH 2/3] fix ci --- test/braintrust/trace/span_exporter_test.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/braintrust/trace/span_exporter_test.rb b/test/braintrust/trace/span_exporter_test.rb index c2a5ee4f..cd3db555 100644 --- a/test/braintrust/trace/span_exporter_test.rb +++ b/test/braintrust/trace/span_exporter_test.rb @@ -108,6 +108,8 @@ def test_handles_nil_parent def test_resolves_authorization_header_on_export original_cwd = Dir.pwd + original_api_key = ENV["BRAINTRUST_API_KEY"] + ENV.delete("BRAINTRUST_API_KEY") Dir.mktmpdir("braintrust-exporter-env") do |dir| File.write(File.join(dir, ".env.braintrust"), "BRAINTRUST_API_KEY=file-key\n") @@ -121,6 +123,11 @@ def test_resolves_authorization_header_on_export assert_equal "Bearer file-key", exporter.calls[0][:headers]["Authorization"] ensure Dir.chdir(original_cwd) + if original_api_key + ENV["BRAINTRUST_API_KEY"] = original_api_key + else + ENV.delete("BRAINTRUST_API_KEY") + end end end From 617136193101641dad3ea48cf0679ceaf9767175 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 27 May 2026 13:19:33 +0200 Subject: [PATCH 3/3] cleanup --- lib/braintrust/config.rb | 17 +++++----- lib/braintrust/internal/api_key_resolver.rb | 6 ++-- lib/braintrust/state.rb | 23 +++----------- lib/braintrust/trace.rb | 3 +- lib/braintrust/trace/span_exporter.rb | 27 +++------------- test/braintrust/config_test.rb | 4 +-- test/braintrust/trace/span_exporter_test.rb | 35 +++++---------------- test/braintrust_test.rb | 17 ++++++++++ 8 files changed, 46 insertions(+), 86 deletions(-) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index d267e231..701b80da 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -7,12 +7,12 @@ module Braintrust # and allows overriding with explicit options class Config attr_reader :org_name, :default_project, :app_url, :api_url, - :filter_ai_spans, :span_filter_funcs, :api_key_resolver + :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_resolver: nil) + filter_ai_spans: nil, span_filter_funcs: nil) @api_key = api_key - @api_key_resolver = api_key_resolver + @api_key_resolver = nil @org_name = org_name @default_project = default_project @app_url = app_url @@ -26,10 +26,6 @@ def api_key @api_key end - def api_key_immediate - @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) @@ -52,16 +48,17 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni filter_ai_spans end - new( + 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, - api_key_resolver: api_key_resolver + span_filter_funcs: span_filter_funcs ) + config.instance_variable_set(:@api_key_resolver, api_key_resolver) + config end end end diff --git a/lib/braintrust/internal/api_key_resolver.rb b/lib/braintrust/internal/api_key_resolver.rb index 19c16e62..abd1b176 100644 --- a/lib/braintrust/internal/api_key_resolver.rb +++ b/lib/braintrust/internal/api_key_resolver.rb @@ -17,7 +17,6 @@ def initialize(explicit_api_key: nil) @resolved = false @api_key = nil @thread = nil - @search_start_dir = Dir.pwd if !explicit_api_key.nil? resolve_immediately(explicit_api_key) @@ -41,8 +40,9 @@ def start return nil if @resolved return @thread if @thread - @thread = Thread.new do - key = self.class.find_file_api_key(@search_start_dir) + 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 diff --git a/lib/braintrust/state.rb b/lib/braintrust/state.rb index a74fdab3..486c7d94 100644 --- a/lib/braintrust/state.rb +++ b/lib/braintrust/state.rb @@ -8,8 +8,7 @@ module Braintrust class State class MissingAPIKeyError < ArgumentError; end - attr_reader :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config, - :api_key_resolver + attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config @mutex = Mutex.new @global_state = nil @@ -39,8 +38,7 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni span_filter_funcs: span_filter_funcs ) new( - api_key: config.api_key_immediate, - api_key_resolver: config.api_key_resolver, + api_key: config.api_key, org_name: config.org_name, default_project: config.default_project, app_url: config.app_url, @@ -67,14 +65,12 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni # @param config [Config, nil] Optional config object # @param exporter [Exporter, nil] Optional exporter for testing # @return [State] the created state - 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, api_key_resolver: nil) + 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 MissingAPIKeyError, "api_key is required" if api_key_resolver.nil? && (api_key.nil? || api_key.empty?) - raise MissingAPIKeyError, "api_key is required" if api_key&.empty? + raise MissingAPIKeyError, "api_key is required" if api_key.nil? || api_key.empty? @api_key = api_key - @api_key_resolver = api_key_resolver @org_name = org_name @org_id = org_id @default_project = default_project @@ -107,17 +103,8 @@ def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, a end end - def api_key - @api_key = @api_key_resolver.api_key if @api_key.nil? && @api_key_resolver - @api_key - end - - def api_key_immediate - @api_key - end - def require_api_key - key = api_key + key = @api_key raise MissingAPIKeyError, "api_key is required" if key.nil? || key.empty? key end diff --git a/lib/braintrust/trace.rb b/lib/braintrust/trace.rb index c0886b4f..2c6563fe 100644 --- a/lib/braintrust/trace.rb +++ b/lib/braintrust/trace.rb @@ -91,8 +91,7 @@ def self.enable(tracer_provider, state: nil, exporter: nil, config: nil) # Create OTLP HTTP exporter unless override provided exporter ||= SpanExporter.new( endpoint: "#{state.api_url}/otel/v1/traces", - api_key: state.api_key_immediate, - api_key_resolver: state.api_key_resolver + api_key: state.api_key ) # Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production diff --git a/lib/braintrust/trace/span_exporter.rb b/lib/braintrust/trace/span_exporter.rb index fa794da5..d0718e6f 100644 --- a/lib/braintrust/trace/span_exporter.rb +++ b/lib/braintrust/trace/span_exporter.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "opentelemetry/exporter/otlp" -require_relative "../logger" require_relative "../state" module Braintrust @@ -19,17 +18,13 @@ class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE - def initialize(endpoint:, api_key:, api_key_resolver: nil) - @api_key = api_key - @api_key_resolver = api_key_resolver - headers = {} - headers["Authorization"] = "Bearer #{api_key}" if api_key - super(endpoint: endpoint, headers: headers) + 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 def export(span_data, timeout: nil) - ensure_authorization_header! - failed = false span_data.group_by { |sd| sd.attributes&.[](PARENT_ATTR_KEY) }.each do |parent_value, spans| @headers[PARENT_HEADER] = parent_value if parent_value @@ -38,20 +33,6 @@ def export(span_data, timeout: nil) @headers.delete(PARENT_HEADER) end failed ? FAILURE : SUCCESS - rescue State::MissingAPIKeyError => e - Log.debug("Trace export skipped: #{e.message}") - FAILURE - end - - private - - def ensure_authorization_header! - return if @headers["Authorization"] - - @api_key = @api_key_resolver.api_key if @api_key.nil? && @api_key_resolver - raise State::MissingAPIKeyError, "api_key is required" if @api_key.nil? || @api_key.empty? - - @headers["Authorization"] = "Bearer #{@api_key}" end end end diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index bf5373fe..6252f6c1 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -78,11 +78,11 @@ def test_falls_back_to_env_braintrust_file assert_equal "file-key", config.api_key end - def test_env_braintrust_lookup_uses_cwd_from_config_creation - write_braintrust_env("BRAINTRUST_API_KEY=file-key\n") + def test_env_braintrust_lookup_uses_cwd_from_api_key_lookup config = Braintrust::Config.from_env other_dir = File.join(@tmpdir, "other") FileUtils.mkdir_p(other_dir) + write_braintrust_env("BRAINTRUST_API_KEY=file-key\n", dir: other_dir) Dir.chdir(other_dir) diff --git a/test/braintrust/trace/span_exporter_test.rb b/test/braintrust/trace/span_exporter_test.rb index cd3db555..bb53a834 100644 --- a/test/braintrust/trace/span_exporter_test.rb +++ b/test/braintrust/trace/span_exporter_test.rb @@ -2,7 +2,6 @@ require "test_helper" require "opentelemetry/sdk" -require "tmpdir" class Braintrust::Trace::SpanExporterTest < Minitest::Test SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS @@ -31,13 +30,10 @@ def make_span(name, parent: nil) class RecordingExporter < Braintrust::Trace::SpanExporter attr_reader :calls - def initialize(api_key: "test-key", api_key_resolver: nil) + def initialize(api_key: "test-key") @calls = [] # Initialize headers directly — skip super to avoid HTTP setup - @headers = {} - @headers["Authorization"] = "Bearer #{api_key}" if api_key - @api_key = api_key - @api_key_resolver = api_key_resolver + @headers = {"Authorization" => "Bearer #{api_key}"} @shutdown = false end @@ -106,29 +102,12 @@ def test_handles_nil_parent refute exporter.calls[0][:headers].key?("x-bt-parent") end - def test_resolves_authorization_header_on_export - original_cwd = Dir.pwd - original_api_key = ENV["BRAINTRUST_API_KEY"] - ENV.delete("BRAINTRUST_API_KEY") - - Dir.mktmpdir("braintrust-exporter-env") do |dir| - File.write(File.join(dir, ".env.braintrust"), "BRAINTRUST_API_KEY=file-key\n") - Dir.chdir(dir) - resolver = Braintrust::Internal::ApiKeyResolver.new - exporter = RecordingExporter.new(api_key: nil, api_key_resolver: resolver) - - result = exporter.export([make_span("span1")]) - - assert_equal SUCCESS, result - assert_equal "Bearer file-key", exporter.calls[0][:headers]["Authorization"] - ensure - Dir.chdir(original_cwd) - if original_api_key - ENV["BRAINTRUST_API_KEY"] = original_api_key - else - ENV.delete("BRAINTRUST_API_KEY") - end + def test_requires_api_key + error = assert_raises(Braintrust::State::MissingAPIKeyError) do + Braintrust::Trace::SpanExporter.new(endpoint: "https://api.example.test/otel/v1/traces", api_key: nil) end + + assert_match(/api_key is required/, error.message) end def test_mixed_nil_and_non_nil_parents diff --git a/test/braintrust_test.rb b/test/braintrust_test.rb index 55ef912c..281c1ec9 100644 --- a/test/braintrust_test.rb +++ b/test/braintrust_test.rb @@ -89,6 +89,23 @@ def test_init_uses_env_braintrust_api_key end end + def test_init_fails_without_api_key_or_env_braintrust + ENV.delete("BRAINTRUST_API_KEY") + original_cwd = Dir.pwd + + Dir.mktmpdir("braintrust-missing-key-test") do |dir| + Dir.chdir(dir) + + error = assert_raises(Braintrust::State::MissingAPIKeyError) do + Braintrust.init(set_global: false, enable_tracing: false) + end + + assert_match(/api_key is required/, error.message) + ensure + Dir.chdir(original_cwd) + end + end + def test_init_with_tracing_true_creates_tracer_provider # Verify we start with the default proxy provider assert_instance_of OpenTelemetry::Internal::ProxyTracerProvider, OpenTelemetry.tracer_provider