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..701b80da 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, + 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 @@ -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) @@ -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? @@ -38,8 +48,8 @@ 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", @@ -47,6 +57,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni 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 diff --git a/lib/braintrust/internal/api_key_resolver.rb b/lib/braintrust/internal/api_key_resolver.rb new file mode 100644 index 00000000..abd1b176 --- /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 + + 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 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..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..6252f6c1 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_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) + + 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..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..281c1ec9 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_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_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