Skip to content

Commit 3eaf5e0

Browse files
lforstAbhiPrasad
andauthored
feat: Upwards-recursively read .env.braintrust containing BRAINTRUST_API_KEY (#466)
Direct port of braintrustdata/braintrust-sdk-javascript#2049 Prompt used + some cleanup: ``` Implement `.env.braintrust` API key discovery for this Braintrust SDK. Context: The Braintrust instrumentation wizard will generate a file named `.env.braintrust` in the user’s current working directory. That file contains a dotenv-style `BRAINTRUST_API_KEY=...` entry. The goal is that after running the wizard, users can immediately run or verify local Braintrust instrumentation without manually exporting `BRAINTRUST_API_KEY`. `.env.braintrust` is not a general dotenv loader. It is only a credential fallback for Braintrust SDK API-key lookup. Required behavior: - Preserve precedence: 1. Explicit caller-provided API key wins. 2. Nonblank `BRAINTRUST_API_KEY` from the process environment wins. 3. Otherwise, read `BRAINTRUST_API_KEY` from `.env.braintrust`. - Treat missing, empty, or whitespace-only environment values as unset. - Look for `.env.braintrust` starting at the current working directory at lookup time, then walk upward. - Cap lookup at cwd plus 64 parent directories. - The nearest `.env.braintrust` wins. - The nearest `.env.braintrust` is a boundary: if it exists but has no nonblank `BRAINTRUST_API_KEY`, do not check higher parents. - If the nearest `.env.braintrust` cannot be read, return “not found” and do not check higher parents. Do not throw from credential discovery. - Read only `BRAINTRUST_API_KEY`; do not load or expose other variables from the file. - Do not mutate the process environment. - Support normal dotenv syntax if the SDK/runtime has a standard parser: quotes, comments, and `export BRAINTRUST_API_KEY=...`. Implementation guidance: - Prefer lazy, nonblocking lookup. - If possible, start candidate file reads in parallel, but preserve nearest-wins semantics: a higher parent may only win after all closer candidates are known absent. - If a constructor/setup path is synchronous, do not add blocking file IO there. - For telemetry/exporter integrations, make export/flush wait for API-key discovery when needed. Look at the reference implementation PR: braintrustdata/braintrust-sdk-javascript#2049 ``` --------- Co-authored-by: Abhijeet Prasad <abhijeet@braintrustdata.com>
1 parent dfed6b1 commit 3eaf5e0

7 files changed

Lines changed: 279 additions & 29 deletions

File tree

py/src/braintrust/btx/span_fetcher.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Any
1313

1414
import requests
15+
from braintrust.env import BraintrustEnv
1516

1617

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

158159

159160
def _require_api_key() -> str:
160-
key = os.environ.get("BRAINTRUST_API_KEY")
161+
key = BraintrustEnv.API_KEY.get(None, use_dotenv=True)
161162
if not key:
162-
raise ValueError("BRAINTRUST_API_KEY environment variable is not set")
163+
raise ValueError("BRAINTRUST_API_KEY is not set in the environment or nearest .env.braintrust file")
163164
return key
164165

165166

py/src/braintrust/env.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import io
12
import math
23
import os
4+
import shlex
35
from collections.abc import Callable
46
from dataclasses import dataclass
57
from enum import Enum
@@ -9,6 +11,8 @@
911
T = TypeVar("T")
1012
EnvValue = bool | float | int | str
1113
_Parser = Callable[[str], EnvValue | None]
14+
BRAINTRUST_ENV_FILE = ".env.braintrust"
15+
BRAINTRUST_ENV_SEARCH_PARENT_LIMIT = 64
1216

1317

1418
def parse_float(value: str) -> float | None:
@@ -48,9 +52,9 @@ def parse_bool(value: str) -> bool | None:
4852
def parse_string(value: str) -> str | None:
4953
"""Parse a string environment variable.
5054
51-
Empty strings are treated as unset so callers fall back to their default.
55+
Empty or whitespace-only strings are treated as unset so callers fall back to their default.
5256
"""
53-
return value or None
57+
return value if value.strip() else None
5458

5559

5660
class EnvParser(Enum):
@@ -68,18 +72,86 @@ class EnvVar:
6872
name: str
6973
parser: EnvParser
7074

71-
def get(self, default: T) -> T:
72-
value = os.environ.get(self.name)
75+
def get(self, default: T, *, use_dotenv: bool = False) -> T:
76+
parsed = self._parse_value(os.environ.get(self.name))
77+
if parsed is not None:
78+
return cast(T, parsed)
79+
80+
if use_dotenv:
81+
parsed = self._get_dotenv_value()
82+
if parsed is not None:
83+
return cast(T, parsed)
84+
85+
return default
86+
87+
def _parse_value(self, value: str | None) -> EnvValue | None:
7388
if value is None:
74-
return default
89+
return None
90+
return self.parser.parser(value)
91+
92+
def _get_dotenv_value(self) -> EnvValue | None:
93+
try:
94+
directory = os.getcwd()
95+
except OSError:
96+
return None
97+
98+
for _ in range(BRAINTRUST_ENV_SEARCH_PARENT_LIMIT + 1):
99+
env_path = os.path.join(directory, BRAINTRUST_ENV_FILE)
100+
try:
101+
with open(env_path, encoding="utf-8") as f:
102+
return self._parse_dotenv_contents(f.read())
103+
except FileNotFoundError:
104+
pass
105+
except OSError:
106+
return None
107+
108+
parent = os.path.dirname(directory)
109+
if parent == directory:
110+
break
111+
directory = parent
75112

76-
parsed = self.parser.parser(value)
77-
if parsed is None:
78-
return default
79-
return cast(T, parsed)
113+
return None
114+
115+
def _parse_dotenv_contents(self, contents: str) -> EnvValue | None:
116+
try:
117+
from dotenv import dotenv_values
118+
119+
parsed = dotenv_values(stream=io.StringIO(contents), interpolate=False)
120+
return self._parse_value(parsed.get(self.name))
121+
except ImportError:
122+
pass
123+
except Exception:
124+
return None
125+
126+
for line in contents.splitlines():
127+
stripped = line.lstrip()
128+
if not stripped or stripped.startswith("#"):
129+
continue
130+
if stripped.startswith("export "):
131+
stripped = stripped[len("export ") :].lstrip()
132+
if "=" not in stripped:
133+
continue
134+
135+
key, value = stripped.split("=", 1)
136+
if key.strip() != self.name:
137+
continue
138+
139+
lexer = shlex.shlex(value.lstrip(), posix=True)
140+
lexer.whitespace_split = True
141+
lexer.commenters = "#"
142+
try:
143+
parts = list(lexer)
144+
except ValueError:
145+
return None
146+
if not parts:
147+
return None
148+
return self._parse_value(parts[0])
149+
150+
return None
80151

81152

82153
class BraintrustEnv:
154+
API_KEY = EnvVar("BRAINTRUST_API_KEY", EnvParser.STRING)
83155
HTTP_TIMEOUT = EnvVar("BRAINTRUST_HTTP_TIMEOUT", EnvParser.FLOAT)
84156
SYNC_FLUSH = EnvVar("BRAINTRUST_SYNC_FLUSH", EnvParser.BOOL)
85157
MAX_REQUEST_SIZE = EnvVar("BRAINTRUST_MAX_REQUEST_SIZE", EnvParser.INT)

py/src/braintrust/logger.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,8 +2190,7 @@ def login_to_state(
21902190

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

2193-
if api_key is None:
2194-
api_key = os.environ.get("BRAINTRUST_API_KEY")
2193+
api_key = api_key or BraintrustEnv.API_KEY.get(None, use_dotenv=True)
21952194

21962195
org_name = _get_org_name(org_name)
21972196

@@ -2240,7 +2239,10 @@ def login_to_state(
22402239
conn.set_token(api_key)
22412240

22422241
if not conn:
2243-
raise ValueError("Could not login to Braintrust. You may need to set BRAINTRUST_API_KEY in your environment.")
2242+
raise ValueError(
2243+
"Could not login to Braintrust. You may need to set BRAINTRUST_API_KEY in your environment "
2244+
"or nearest .env.braintrust file."
2245+
)
22442246

22452247
# make_long_lived() allows the connection to retry if it breaks, which we're okay with after
22462248
# this point because we know the connection _can_ successfully ping.

py/src/braintrust/otel/__init__.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import warnings
44
from urllib.parse import urljoin
55

6+
from braintrust.env import BraintrustEnv
7+
68

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

34+
def export(self, *args, **kwargs):
35+
raise ImportError(INSTALL_ERR_MSG)
36+
37+
def force_flush(self, *args, **kwargs):
38+
raise ImportError(INSTALL_ERR_MSG)
39+
3240
class BatchSpanProcessor:
3341
def __init__(self, *args, **kwargs):
3442
raise ImportError(INSTALL_ERR_MSG)
@@ -145,7 +153,7 @@ class OtelExporter(OTLPSpanExporter):
145153
a more convenient all-in-one interface.
146154
147155
Environment Variables:
148-
- BRAINTRUST_API_KEY: Your Braintrust API key.
156+
- BRAINTRUST_API_KEY: Your Braintrust API key. If unset, the nearest .env.braintrust file is used.
149157
- BRAINTRUST_PARENT: Parent identifier (e.g., "project_name:test").
150158
- BRAINTRUST_API_URL: Base URL for Braintrust API (defaults to https://api.braintrust.dev).
151159
"""
@@ -163,7 +171,7 @@ def __init__(
163171
164172
Args:
165173
url: OTLP endpoint URL. Defaults to {BRAINTRUST_API_URL}/otel/v1/traces.
166-
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var.
174+
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var, then .env.braintrust.
167175
parent: Parent identifier (e.g., "project_name:test"). Defaults to BRAINTRUST_PARENT env var.
168176
headers: Additional headers to include in requests.
169177
**kwargs: Additional arguments passed to OTLPSpanExporter.
@@ -173,15 +181,13 @@ def __init__(
173181
if not base_url.endswith("/"):
174182
base_url += "/"
175183
endpoint = url or urljoin(base_url, "otel/v1/traces")
176-
api_key = api_key or os.environ.get("BRAINTRUST_API_KEY")
184+
api_key_arg = api_key
185+
env_api_key = os.environ.get("BRAINTRUST_API_KEY")
186+
if api_key is None:
187+
api_key = env_api_key if env_api_key and env_api_key.strip() else None
177188
parent = parent or os.environ.get("BRAINTRUST_PARENT")
178189
headers = headers or {}
179190

180-
if not api_key:
181-
raise ValueError(
182-
"API key is required. Provide it via api_key parameter or BRAINTRUST_API_KEY environment variable."
183-
)
184-
185191
# Default parent if not provided
186192
if not parent:
187193
parent = "project_name:default-otel-project"
@@ -190,10 +196,14 @@ def __init__(
190196
"Configure with BRAINTRUST_PARENT environment variable or parent parameter."
191197
)
192198

193-
exporter_headers = {
194-
"Authorization": f"Bearer {api_key}",
195-
**headers,
196-
}
199+
self._braintrust_api_key_arg = api_key_arg
200+
self._braintrust_headers_override_authorization = "Authorization" in headers
201+
self._braintrust_has_api_key = bool(api_key and api_key.strip())
202+
203+
exporter_headers = {}
204+
if self._braintrust_has_api_key:
205+
exporter_headers["Authorization"] = f"Bearer {api_key}"
206+
exporter_headers.update(headers)
197207

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

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

215+
def _set_api_key_header(self, api_key: str) -> None:
216+
if not self._braintrust_headers_override_authorization:
217+
authorization = {"Authorization": f"Bearer {api_key}"}
218+
exporter_headers = getattr(self, "_headers", None)
219+
if isinstance(exporter_headers, dict):
220+
exporter_headers.update(authorization)
221+
else:
222+
self._headers = {**dict(exporter_headers or {}), **authorization}
223+
224+
session = getattr(self, "_session", None)
225+
if session is not None:
226+
session.headers.update(authorization)
227+
self._braintrust_has_api_key = True
228+
229+
def _ensure_api_key(self) -> None:
230+
if self._braintrust_has_api_key:
231+
return
232+
api_key = self._braintrust_api_key_arg or BraintrustEnv.API_KEY.get(None, use_dotenv=True)
233+
if not api_key or not api_key.strip():
234+
raise ValueError(
235+
"API key is required. Provide it via api_key parameter, BRAINTRUST_API_KEY environment variable, or the nearest .env.braintrust file."
236+
)
237+
self._set_api_key_header(api_key)
238+
239+
def initialize(self) -> None:
240+
self._ensure_api_key()
241+
242+
def export(self, spans):
243+
self._ensure_api_key()
244+
return super().export(spans)
245+
246+
def force_flush(self, timeout_millis=30000):
247+
self._ensure_api_key()
248+
return super().force_flush(timeout_millis)
249+
205250

206251
def add_braintrust_span_processor(
207252
tracer_provider,
@@ -252,7 +297,7 @@ def __init__(
252297
Initialize the BraintrustSpanProcessor.
253298
254299
Args:
255-
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var.
300+
api_key: Braintrust API key. Defaults to BRAINTRUST_API_KEY env var, then .env.braintrust.
256301
parent: Parent identifier (e.g., "project_name:test"). Defaults to BRAINTRUST_PARENT env var.
257302
api_url: Base URL for Braintrust API. Defaults to BRAINTRUST_API_URL env var or https://api.braintrust.dev.
258303
filter_ai_spans: Whether to enable AI span filtering. Defaults to False.
@@ -340,6 +385,7 @@ def _get_parent_otel_braintrust_parent(self, parent_context):
340385

341386
def on_end(self, span):
342387
"""Forward span end events to the inner processor."""
388+
self._exporter.initialize()
343389
self._processor.on_end(span)
344390

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

353399
def force_flush(self, timeout_millis=30000):
354400
"""Force flush the inner processor."""
401+
self._exporter.initialize()
355402
return self._processor.force_flush(timeout_millis)
356403

357404
@property

0 commit comments

Comments
 (0)