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
5 changes: 3 additions & 2 deletions py/src/braintrust/btx/span_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any

import requests
from braintrust.util import get_braintrust_api_key


_BACKOFF_SECONDS = 30
Expand Down Expand Up @@ -157,9 +158,9 @@ def _fetch_once(root_span_id: str, project_id: str, num_expected: int) -> list[d


def _require_api_key() -> str:
key = os.environ.get("BRAINTRUST_API_KEY")
key = get_braintrust_api_key()
if not key:
raise ValueError("BRAINTRUST_API_KEY environment variable is not set")
raise ValueError("BRAINTRUST_API_KEY is not set in the environment or nearest .env.braintrust file")
return key


Expand Down
9 changes: 6 additions & 3 deletions py/src/braintrust/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
coalesce,
encode_uri_component,
eprint,
get_braintrust_api_key,
get_caller_location,
get_signature,
mask_api_key,
Expand Down Expand Up @@ -2224,8 +2225,7 @@ def login_to_state(

app_public_url = os.environ.get("BRAINTRUST_APP_PUBLIC_URL", app_url)

if api_key is None:
api_key = os.environ.get("BRAINTRUST_API_KEY")
api_key = get_braintrust_api_key(api_key)

org_name = _get_org_name(org_name)

Expand Down Expand Up @@ -2274,7 +2274,10 @@ def login_to_state(
conn.set_token(api_key)

if not conn:
raise ValueError("Could not login to Braintrust. You may need to set BRAINTRUST_API_KEY in your environment.")
raise ValueError(
"Could not login to Braintrust. You may need to set BRAINTRUST_API_KEY in your environment "
"or nearest .env.braintrust file."
)

# make_long_lived() allows the connection to retry if it breaks, which we're okay with after
# this point because we know the connection _can_ successfully ping.
Expand Down
73 changes: 60 additions & 13 deletions py/src/braintrust/otel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import warnings
from urllib.parse import urljoin

from braintrust.util import get_braintrust_api_key


INSTALL_ERR_MSG = (
"OpenTelemetry packages are not installed. "
Expand All @@ -29,6 +31,12 @@ class OTLPSpanExporter:
def __init__(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)

def export(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)

def force_flush(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)

class BatchSpanProcessor:
def __init__(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)
Expand Down Expand Up @@ -145,7 +153,7 @@ class OtelExporter(OTLPSpanExporter):
a more convenient all-in-one interface.

Environment Variables:
- BRAINTRUST_API_KEY: Your Braintrust API key.
- BRAINTRUST_API_KEY: Your Braintrust API key. If unset, the nearest .env.braintrust file is used.
- BRAINTRUST_PARENT: Parent identifier (e.g., "project_name:test").
- BRAINTRUST_API_URL: Base URL for Braintrust API (defaults to https://api.braintrust.dev).
"""
Expand All @@ -163,7 +171,7 @@ def __init__(

Args:
url: OTLP endpoint URL. Defaults to {BRAINTRUST_API_URL}/otel/v1/traces.
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var.
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var, then .env.braintrust.
parent: Parent identifier (e.g., "project_name:test"). Defaults to BRAINTRUST_PARENT env var.
headers: Additional headers to include in requests.
**kwargs: Additional arguments passed to OTLPSpanExporter.
Expand All @@ -173,15 +181,13 @@ def __init__(
if not base_url.endswith("/"):
base_url += "/"
endpoint = url or urljoin(base_url, "otel/v1/traces")
api_key = api_key or os.environ.get("BRAINTRUST_API_KEY")
api_key_arg = api_key
env_api_key = os.environ.get("BRAINTRUST_API_KEY")
if api_key is None:
api_key = env_api_key if env_api_key and env_api_key.strip() else None
parent = parent or os.environ.get("BRAINTRUST_PARENT")
headers = headers or {}

if not api_key:
raise ValueError(
"API key is required. Provide it via api_key parameter or BRAINTRUST_API_KEY environment variable."
)

# Default parent if not provided
if not parent:
parent = "project_name:default-otel-project"
Expand All @@ -190,10 +196,14 @@ def __init__(
"Configure with BRAINTRUST_PARENT environment variable or parent parameter."
)

exporter_headers = {
"Authorization": f"Bearer {api_key}",
**headers,
}
self._braintrust_api_key_arg = api_key_arg
self._braintrust_headers_override_authorization = "Authorization" in headers
self._braintrust_has_api_key = bool(api_key and api_key.strip())

exporter_headers = {}
if self._braintrust_has_api_key:
exporter_headers["Authorization"] = f"Bearer {api_key}"
exporter_headers.update(headers)

if parent:
exporter_headers["x-bt-parent"] = parent
Expand All @@ -202,6 +212,41 @@ def __init__(

super().__init__(endpoint=endpoint, headers=exporter_headers, **kwargs)

def _set_api_key_header(self, api_key: str) -> None:
if not self._braintrust_headers_override_authorization:
authorization = {"Authorization": f"Bearer {api_key}"}
exporter_headers = getattr(self, "_headers", None)
if isinstance(exporter_headers, dict):
exporter_headers.update(authorization)
else:
self._headers = {**dict(exporter_headers or {}), **authorization}

session = getattr(self, "_session", None)
if session is not None:
session.headers.update(authorization)
self._braintrust_has_api_key = True

def _ensure_api_key(self) -> None:
if self._braintrust_has_api_key:
return
api_key = get_braintrust_api_key(self._braintrust_api_key_arg)
if not api_key or not api_key.strip():
raise ValueError(
"API key is required. Provide it via api_key parameter, BRAINTRUST_API_KEY environment variable, or the nearest .env.braintrust file."
)
self._set_api_key_header(api_key)

def initialize(self) -> None:
self._ensure_api_key()

def export(self, spans):
self._ensure_api_key()
return super().export(spans)

def force_flush(self, timeout_millis=30000):
self._ensure_api_key()
return super().force_flush(timeout_millis)


def add_braintrust_span_processor(
tracer_provider,
Expand Down Expand Up @@ -252,7 +297,7 @@ def __init__(
Initialize the BraintrustSpanProcessor.

Args:
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var.
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var, then .env.braintrust.
parent: Parent identifier (e.g., "project_name:test"). Defaults to BRAINTRUST_PARENT env var.
api_url: Base URL for Braintrust API. Defaults to BRAINTRUST_API_URL env var or https://api.braintrust.dev.
filter_ai_spans: Whether to enable AI span filtering. Defaults to False.
Expand Down Expand Up @@ -340,6 +385,7 @@ def _get_parent_otel_braintrust_parent(self, parent_context):

def on_end(self, span):
"""Forward span end events to the inner processor."""
self._exporter.initialize()
self._processor.on_end(span)

def _on_ending(self, span):
Expand All @@ -352,6 +398,7 @@ def shutdown(self):

def force_flush(self, timeout_millis=30000):
"""Force flush the inner processor."""
self._exporter.initialize()
return self._processor.force_flush(timeout_millis)

@property
Expand Down
11 changes: 11 additions & 0 deletions py/src/braintrust/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@
)


def test_login_to_state_uses_env_braintrust_api_key(tmp_path, monkeypatch):
(tmp_path / ".env.braintrust").write_text(f"BRAINTRUST_API_KEY={logger.TEST_API_KEY}\n")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)

state = logger.login_to_state(org_name="test-org-name")

assert state.login_token == logger.TEST_API_KEY
assert state.logged_in is True


class TestInit(TestCase):
def test_init_validation(self):
with self.assertRaises(ValueError) as cm:
Expand Down
49 changes: 47 additions & 2 deletions py/src/braintrust/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def test_otel_import_behavior():
assert hasattr(OtelExporter, "__init__")


def test_otel_exporter_creation():
def test_otel_exporter_creation(tmp_path):
"""Test OtelExporter creation with and without full OpenTelemetry SDK."""
from braintrust.otel import OtelExporter

Expand All @@ -58,9 +58,12 @@ def test_otel_exporter_creation():
with pytest.MonkeyPatch.context() as m:
m.delenv("BRAINTRUST_API_KEY", raising=False)
m.delenv("BRAINTRUST_PARENT", raising=False)
m.chdir(tmp_path)
(tmp_path / ".env.braintrust").write_text("")

exporter = OtelExporter()
with pytest.raises(ValueError, match="API key is required"):
OtelExporter()
exporter.force_flush()
else:
# When SDK is not fully installed, instantiation should raise ImportError
with pytest.raises(ImportError, match="OpenTelemetry packages are not installed"):
Expand Down Expand Up @@ -92,6 +95,48 @@ def test_otel_exporter_with_explicit_params():
assert exporter._headers == expected_headers


def test_otel_exporter_uses_env_braintrust_api_key(tmp_path):
if not _check_otel_installed():
pytest.skip("OpenTelemetry SDK not fully installed, skipping test")

from braintrust.otel import OtelExporter

with pytest.MonkeyPatch.context() as m:
m.delenv("BRAINTRUST_API_KEY", raising=False)
m.chdir(tmp_path)
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=file-api-key\n")

exporter = OtelExporter(parent="project_name:test")
exporter.force_flush()

assert exporter._headers["Authorization"] == "Bearer file-api-key"


def test_braintrust_span_processor_missing_key_raises_on_span_end(tmp_path):
if not _check_otel_installed():
pytest.skip("OpenTelemetry SDK not fully installed, skipping test")

from braintrust.otel import BraintrustSpanProcessor
from opentelemetry.sdk.trace import TracerProvider

with pytest.MonkeyPatch.context() as m:
m.delenv("BRAINTRUST_API_KEY", raising=False)
m.chdir(tmp_path)
(tmp_path / ".env.braintrust").write_text("")

provider = TracerProvider()
processor = BraintrustSpanProcessor()
provider.add_span_processor(processor)
tracer = provider.get_tracer("test_tracer")

try:
with pytest.raises(ValueError, match="API key is required"):
with tracer.start_as_current_span("test_span"):
pass
finally:
provider.shutdown()


def test_otel_exporter_no_parent(caplog):
if not _check_otel_installed():
pytest.skip("OpenTelemetry SDK not fully installed, skipping test")
Expand Down
80 changes: 79 additions & 1 deletion py/src/braintrust/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from .util import LazyValue, mask_api_key, merge_dicts_with_paths, parse_env_var_float
from .util import LazyValue, get_braintrust_api_key, mask_api_key, merge_dicts_with_paths, parse_env_var_float


class TestParseEnvVarFloat:
Expand Down Expand Up @@ -62,6 +62,84 @@ def test_allows_negative_values(self):
del os.environ["TEST_FLOAT"]


class TestBraintrustApiKeyLookup:
def test_explicit_api_key_wins(self, tmp_path, monkeypatch):
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=file-key\n")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("BRAINTRUST_API_KEY", "env-key")

assert get_braintrust_api_key("explicit-key") == "explicit-key"

def test_nonblank_environment_wins(self, tmp_path, monkeypatch):
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=file-key\n")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("BRAINTRUST_API_KEY", "env-key")

assert get_braintrust_api_key() == "env-key"

def test_blank_environment_falls_back_to_file(self, tmp_path, monkeypatch):
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=file-key\n")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("BRAINTRUST_API_KEY", " ")

assert get_braintrust_api_key() == "file-key"

def test_uses_nearest_parent_file(self, tmp_path, monkeypatch):
nested = tmp_path / "packages" / "app"
nested.mkdir(parents=True)
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=root-key\n")
(tmp_path / "packages" / ".env.braintrust").write_text("BRAINTRUST_API_KEY=package-key\n")
monkeypatch.chdir(nested)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)

assert get_braintrust_api_key() == "package-key"

@pytest.mark.parametrize("contents", ["OTHER=value\n", 'BRAINTRUST_API_KEY=" "\n'])
def test_nearest_file_is_boundary_without_nonblank_key(self, tmp_path, monkeypatch, contents):
nested = tmp_path / "packages" / "app"
nested.mkdir(parents=True)
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=root-key\n")
(tmp_path / "packages" / ".env.braintrust").write_text(contents)
monkeypatch.chdir(nested)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)

assert get_braintrust_api_key() is None

def test_unreadable_nearest_file_is_boundary(self, tmp_path, monkeypatch):
nested = tmp_path / "packages" / "app"
nested.mkdir(parents=True)
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=root-key\n")
(tmp_path / "packages" / ".env.braintrust").mkdir()
monkeypatch.chdir(nested)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)

assert get_braintrust_api_key() is None

def test_searches_cwd_plus_64_parents(self, tmp_path, monkeypatch):
segments = [f"d{i}" for i in range(65)]
nested = tmp_path.joinpath(*segments)
nested.mkdir(parents=True)
(tmp_path / ".env.braintrust").write_text("BRAINTRUST_API_KEY=too-high\n")
monkeypatch.chdir(nested)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)

assert get_braintrust_api_key() is None

(tmp_path / segments[0] / ".env.braintrust").write_text("BRAINTRUST_API_KEY=boundary-key\n")

assert get_braintrust_api_key() == "boundary-key"

def test_supports_dotenv_syntax_and_does_not_mutate_environment(self, tmp_path, monkeypatch):
(tmp_path / ".env.braintrust").write_text('export BRAINTRUST_API_KEY="quoted-key" # comment\nOTHER=value\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BRAINTRUST_API_KEY", raising=False)
monkeypatch.delenv("OTHER", raising=False)

assert get_braintrust_api_key() == "quoted-key"
assert os.environ.get("BRAINTRUST_API_KEY") is None
assert os.environ.get("OTHER") is None


class TestLazyValue(unittest.TestCase):
def test_evaluates_exactly_once(self):
call_count = 0
Expand Down
Loading
Loading