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..48a7f803 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -1,5 +1,7 @@ # 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 @@ -39,7 +41,7 @@ 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: Internal::ApiKeyResolver.resolve(explicit_api_key: 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", diff --git a/lib/braintrust/internal/api_key_resolver.rb b/lib/braintrust/internal/api_key_resolver.rb new file mode 100644 index 00000000..0fee2e09 --- /dev/null +++ b/lib/braintrust/internal/api_key_resolver.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "json" + +module Braintrust + module Internal + # Resolves the Braintrust API key from explicit options, ENV, or the nearest + # .braintrust.json file without mutating the process environment. + class ApiKeyResolver + ENV_KEY = "BRAINTRUST_API_KEY" + CONFIG_FILE = ".braintrust.json" + SEARCH_PARENT_LIMIT = 64 + + def self.resolve(explicit_api_key: nil, start_dir: Dir.pwd) + return explicit_api_key unless explicit_api_key.nil? + + env_api_key = ENV[ENV_KEY] + return env_api_key if env_api_key && !env_api_key.strip.empty? + + find_file_api_key(start_dir) + end + + def self.find_file_api_key(start_dir = Dir.pwd) + dir = start_dir + + 0.upto(SEARCH_PARENT_LIMIT) do + config_path = File.join(dir, CONFIG_FILE) + + begin + contents = File.read(config_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) + config = JSON.parse(contents) + return nil unless config.is_a?(Hash) + + value = config[ENV_KEY] + (value.is_a?(String) && !value.strip.empty?) ? value : nil + rescue JSON::ParserError, TypeError + nil + end + + private_class_method :find_file_api_key, :parse_api_key + end + end +end diff --git a/lib/braintrust/setup.rb b/lib/braintrust/setup.rb index b947ac21..442fe791 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 .braintrust.json # 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..486c7d94 100644 --- a/lib/braintrust/state.rb +++ b/lib/braintrust/state.rb @@ -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 @@ -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 @@ -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 } @@ -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 ) @@ -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 @@ -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? diff --git a/lib/braintrust/trace/span_exporter.rb b/lib/braintrust/trace/span_exporter.rb index fc7502fa..d0718e6f 100644 --- a/lib/braintrust/trace/span_exporter.rb +++ b/lib/braintrust/trace/span_exporter.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "opentelemetry/exporter/otlp" +require_relative "../state" module Braintrust module Trace @@ -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 diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index 0c9edbef..c222e822 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,152 @@ def test_env_vars_override_defaults assert_equal "https://custom.braintrust.dev", config.app_url end + + def test_falls_back_to_braintrust_json_file + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + end + + def test_braintrust_json_lookup_uses_cwd_from_config_creation + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + 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_braintrust_json_when_process_env_is_blank + ENV["BRAINTRUST_API_KEY"] = " " + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + end + + def test_process_env_overrides_braintrust_json + ENV["BRAINTRUST_API_KEY"] = "env-key" + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + + config = Braintrust::Config.from_env + + assert_equal "env-key", config.api_key + end + + def test_explicit_api_key_overrides_env_and_braintrust_json + ENV["BRAINTRUST_API_KEY"] = "env-key" + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + + config = Braintrust::Config.from_env(api_key: "explicit-key") + + assert_equal "explicit-key", config.api_key + end + + def test_finds_nearest_parent_braintrust_json + nested = File.join(@tmpdir, "packages", "app") + FileUtils.mkdir_p(nested) + write_braintrust_config({"BRAINTRUST_API_KEY" => "root-key"}) + write_braintrust_config({"BRAINTRUST_API_KEY" => "package-key"}, dir: File.dirname(nested)) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_equal "package-key", config.api_key + end + + def test_nearest_braintrust_json_without_key_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_config({"BRAINTRUST_API_KEY" => "root-key"}) + write_braintrust_config({"OTHER" => "value"}, dir: package_dir) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_nearest_braintrust_json_with_blank_key_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_config({"BRAINTRUST_API_KEY" => "root-key"}) + write_braintrust_config({"BRAINTRUST_API_KEY" => " "}, dir: package_dir) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_unreadable_nearest_braintrust_json_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_config({"BRAINTRUST_API_KEY" => "root-key"}) + FileUtils.mkdir_p(File.join(package_dir, ".braintrust.json")) + + Dir.chdir(nested) + config = Braintrust::Config.from_env + + assert_nil config.api_key + end + + def test_invalid_nearest_braintrust_json_is_boundary + nested = File.join(@tmpdir, "packages", "app") + package_dir = File.dirname(nested) + FileUtils.mkdir_p(nested) + write_braintrust_config({"BRAINTRUST_API_KEY" => "root-key"}) + File.write(File.join(package_dir, ".braintrust.json"), "{") + + 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_config({"BRAINTRUST_API_KEY" => "too-high"}) + + Dir.chdir(nested) + assert_nil Braintrust::Config.from_env.api_key + + write_braintrust_config({"BRAINTRUST_API_KEY" => "boundary-key"}, dir: File.join(@tmpdir, segments.first)) + + assert_equal "boundary-key", Braintrust::Config.from_env.api_key + end + + def test_reads_only_braintrust_api_key_from_json + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key", "OTHER" => "value"}) + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + assert_nil ENV["OTHER"] + end + + def test_does_not_mutate_process_env + write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) + + config = Braintrust::Config.from_env + + assert_equal "file-key", config.api_key + assert_nil ENV["BRAINTRUST_API_KEY"] + end + + private + + def write_braintrust_config(contents, dir: @tmpdir) + File.write(File.join(dir, ".braintrust.json"), JSON.generate(contents)) + end end diff --git a/test/braintrust/trace/span_exporter_test.rb b/test/braintrust/trace/span_exporter_test.rb index 45a5c753..bb53a834 100644 --- a/test/braintrust/trace/span_exporter_test.rb +++ b/test/braintrust/trace/span_exporter_test.rb @@ -30,10 +30,10 @@ def make_span(name, parent: nil) class RecordingExporter < Braintrust::Trace::SpanExporter attr_reader :calls - def initialize + def initialize(api_key: "test-key") @calls = [] # Initialize headers directly — skip super to avoid HTTP setup - @headers = {"Authorization" => "Bearer test-key"} + @headers = {"Authorization" => "Bearer #{api_key}"} @shutdown = false end @@ -102,6 +102,14 @@ def test_handles_nil_parent refute exporter.calls[0][:headers].key?("x-bt-parent") 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 exporter = RecordingExporter.new diff --git a/test/braintrust_test.rb b/test/braintrust_test.rb index b557fb7f..846ee826 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,41 @@ def test_init_merges_options_with_env assert_equal "my-project", state.default_project end + def test_init_uses_braintrust_json_api_key + ENV.delete("BRAINTRUST_API_KEY") + original_cwd = Dir.pwd + + Dir.mktmpdir("braintrust-init-test") do |dir| + File.write(File.join(dir, ".braintrust.json"), JSON.generate("BRAINTRUST_API_KEY" => "test-api-key")) + 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_fails_without_api_key_or_braintrust_json + 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