Skip to content

Commit c8ba21d

Browse files
committed
wip
1 parent 0d6c87d commit c8ba21d

14 files changed

Lines changed: 1102 additions & 97 deletions

py/src/braintrust/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ def is_equal(expected, output):
5151
# Check env var at import time for auto-instrumentation
5252
import os
5353

54+
from .env import validate_id_config as _validate_id_config
55+
56+
57+
# Fail fast on mutually-exclusive ID configuration (e.g. BRAINTRUST_OTEL_COMPAT
58+
# together with BRAINTRUST_LEGACY_UUID_IDS) as early as possible on import.
59+
_validate_id_config()
60+
5461

5562
if os.getenv("BRAINTRUST_INSTRUMENT_THREADS", "").lower() in ("true", "1", "yes"):
5663
try:

py/src/braintrust/context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from dataclasses import dataclass
77
from typing import Any
88

9-
from .env import BraintrustEnv
9+
from .env import BraintrustEnv, validate_id_config
1010

1111

1212
@dataclass
@@ -120,6 +120,10 @@ def get_context_manager() -> ContextManager:
120120
Braintrust-only context manager by default.
121121
"""
122122

123+
# Fail fast if the ID configuration is inconsistent (belt-and-suspenders for
124+
# the import-time check, in case env vars were mutated after import).
125+
validate_id_config()
126+
123127
# Check if OTEL should be explicitly enabled via environment variable
124128
if BraintrustEnv.OTEL_COMPAT.get(False):
125129
try:

py/src/braintrust/env.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,42 @@ class BraintrustEnv:
163163
ALL_PUBLISH_PAYLOADS_DIR = EnvVar("BRAINTRUST_ALL_PUBLISH_PAYLOADS_DIR", EnvParser.STRING)
164164
DISABLE_ATEXIT_FLUSH = EnvVar("BRAINTRUST_DISABLE_ATEXIT_FLUSH", EnvParser.BOOL)
165165
OTEL_COMPAT = EnvVar("BRAINTRUST_OTEL_COMPAT", EnvParser.BOOL)
166+
# Opt out of the default OpenTelemetry-compatible hex span/trace IDs and use
167+
# legacy UUID-based IDs (and V3 span-component export) instead.
168+
LEGACY_UUID_IDS = EnvVar("BRAINTRUST_LEGACY_UUID_IDS", EnvParser.BOOL)
169+
170+
171+
class BraintrustEnvError(Exception):
172+
"""Raised when Braintrust environment variables are configured inconsistently."""
173+
174+
175+
def use_legacy_uuid_ids() -> bool:
176+
"""Return True if the SDK should generate legacy UUID-based span/trace IDs.
177+
178+
The default is OpenTelemetry-compatible hex IDs (16-byte trace id / 8-byte
179+
span id) with V4 span-component export. Setting BRAINTRUST_LEGACY_UUID_IDS
180+
opts back into UUID IDs with V3 export.
181+
182+
Note: BRAINTRUST_OTEL_COMPAT (which selects the OpenTelemetry context
183+
manager) requires hex IDs, so it always forces hex regardless of this
184+
function. The mutually-exclusive combination is rejected by
185+
validate_id_config().
186+
"""
187+
validate_id_config()
188+
if BraintrustEnv.OTEL_COMPAT.get(False):
189+
return False
190+
return BraintrustEnv.LEGACY_UUID_IDS.get(False)
191+
192+
193+
def validate_id_config() -> None:
194+
"""Fail fast on mutually-exclusive ID configuration.
195+
196+
BRAINTRUST_OTEL_COMPAT requires OpenTelemetry-compatible hex IDs, while
197+
BRAINTRUST_LEGACY_UUID_IDS forces legacy UUID IDs. They cannot both be set.
198+
"""
199+
if BraintrustEnv.OTEL_COMPAT.get(False) and BraintrustEnv.LEGACY_UUID_IDS.get(False):
200+
raise BraintrustEnvError(
201+
"BRAINTRUST_OTEL_COMPAT and BRAINTRUST_LEGACY_UUID_IDS are mutually "
202+
"exclusive: OTEL compatibility requires hex span IDs, but legacy mode "
203+
"forces UUID span IDs. Unset one of them."
204+
)

py/src/braintrust/id_gen.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22
import uuid
33
from abc import ABC, abstractmethod
44

5-
from .env import BraintrustEnv
5+
from .env import use_legacy_uuid_ids
66

77

88
def get_id_generator():
99
"""Factory function that creates a new ID generator instance each time.
1010
1111
This eliminates global state and makes tests parallelizable.
1212
Each caller gets their own generator instance.
13+
14+
Defaults to OpenTelemetry-compatible hex IDs. Set BRAINTRUST_LEGACY_UUID_IDS
15+
to opt back into legacy UUID-based IDs.
1316
"""
14-
use_otel = BraintrustEnv.OTEL_COMPAT.get(False)
15-
return OTELIDGenerator() if use_otel else UUIDGenerator()
17+
return UUIDGenerator() if use_legacy_uuid_ids() else OTELIDGenerator()
1618

1719

1820
class IDGenerator(ABC):

py/src/braintrust/logger.py

Lines changed: 235 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
TRANSACTION_ID_FIELD,
5353
VALID_SOURCES,
5454
)
55-
from .env import BraintrustEnv
55+
from .env import BraintrustEnv, use_legacy_uuid_ids
5656
from .generated_types import (
5757
AttachmentReference,
5858
AttachmentStatus,
@@ -71,6 +71,17 @@
7171
from .prompt_cache.lru_cache import LRUCache
7272
from .prompt_cache.parameters_cache import ParametersCache
7373
from .prompt_cache.prompt_cache import PromptCache
74+
from .propagation import (
75+
BAGGAGE_HEADER,
76+
BRAINTRUST_PARENT_KEY,
77+
BT_PARENT_HEADER,
78+
TRACEPARENT_HEADER,
79+
format_baggage,
80+
format_traceparent,
81+
get_header,
82+
parse_baggage,
83+
parse_traceparent,
84+
)
7485
from .queue import DEFAULT_QUEUE_SIZE, LogQueue
7586
from .serializable_data_class import SerializableDataClass
7687
from .span_identifier_v3 import SpanComponentsV3, SpanObjectTypeV3
@@ -146,9 +157,14 @@ class ParametersRef(TypedDict, total=False):
146157

147158

148159
def _get_exporter():
149-
"""Return the active exporter (e.g. the version of SpanComponentsv*)"""
150-
use_v4 = BraintrustEnv.OTEL_COMPAT.get(False)
151-
return SpanComponentsV4 if use_v4 else SpanComponentsV3
160+
"""Return the active exporter (e.g. the version of SpanComponentsv*).
161+
162+
The export version is coupled to the active ID format: hex IDs (the default)
163+
serialize as V4, legacy UUID IDs serialize as V3. These must move together --
164+
serializing hex IDs via V3 would lose the compact encoding and risk
165+
corrupting hex values that happen to parse as UUIDs.
166+
"""
167+
return SpanComponentsV3 if use_legacy_uuid_ids() else SpanComponentsV4
152168

153169

154170
class Exportable(ABC):
@@ -224,6 +240,15 @@ def export(self) -> str:
224240
:returns: Serialized representation of this span's identifiers.
225241
"""
226242

243+
@abstractmethod
244+
def inject(self, carrier: dict | None = None) -> dict:
245+
"""
246+
Inject W3C trace-context headers (`traceparent` and `baggage`) for this span into a carrier dict, for distributed tracing across service boundaries.
247+
248+
:param carrier: Optional existing carrier (e.g. outbound HTTP headers) to mutate. A new dict is created if omitted.
249+
:returns: The carrier dict with propagation headers injected.
250+
"""
251+
227252
@abstractmethod
228253
def link(self) -> str:
229254
"""
@@ -341,6 +366,9 @@ def end(self, end_time: float | None = None) -> float:
341366
def export(self):
342367
return ""
343368

369+
def inject(self, carrier: dict | None = None) -> dict:
370+
return carrier if carrier is not None else {}
371+
344372
def link(self) -> str:
345373
return NOOP_SPAN_PERMALINK
346374

@@ -2388,6 +2416,180 @@ def get_span_parent_object(
23882416
return NOOP_SPAN
23892417

23902418

2419+
def _current_braintrust_parent(state: BraintrustState | None = None) -> str | None:
2420+
"""Return the Braintrust parent string for the current logger/experiment, if any.
2421+
2422+
Used as the fallback Braintrust parent on receive, when an inbound request
2423+
carries trace identity (`traceparent`) but no `braintrust.parent` baggage.
2424+
"""
2425+
if state is None:
2426+
state = _state
2427+
2428+
experiment = current_experiment()
2429+
if experiment:
2430+
try:
2431+
components = SpanComponentsV4.from_str(experiment.export())
2432+
return f"experiment_id:{components.object_id}"
2433+
except Exception:
2434+
return None
2435+
2436+
logger = current_logger()
2437+
if logger:
2438+
try:
2439+
components = SpanComponentsV4.from_str(logger.export())
2440+
if components.object_id:
2441+
return f"project_id:{components.object_id}"
2442+
meta = components.compute_object_metadata_args or {}
2443+
name = meta.get("project_name")
2444+
if name:
2445+
return f"project_name:{name}"
2446+
except Exception:
2447+
return None
2448+
2449+
return None
2450+
2451+
2452+
def _braintrust_parent_to_components(braintrust_parent: str):
2453+
"""Parse a `braintrust.parent` string into (object_type, object_id, compute_args).
2454+
2455+
Accepts `project_id:<id>`, `project_name:<name>`, or `experiment_id:<id>`.
2456+
Returns None if the value is empty or malformed.
2457+
"""
2458+
if not braintrust_parent:
2459+
return None
2460+
if braintrust_parent.startswith("project_id:"):
2461+
object_id = braintrust_parent[len("project_id:") :]
2462+
return (SpanObjectTypeV3.PROJECT_LOGS, object_id, None) if object_id else None
2463+
if braintrust_parent.startswith("project_name:"):
2464+
name = braintrust_parent[len("project_name:") :]
2465+
return (SpanObjectTypeV3.PROJECT_LOGS, None, {"project_name": name}) if name else None
2466+
if braintrust_parent.startswith("experiment_id:"):
2467+
object_id = braintrust_parent[len("experiment_id:") :]
2468+
return (SpanObjectTypeV3.EXPERIMENT, object_id, None) if object_id else None
2469+
return None
2470+
2471+
2472+
def _inject_into_carrier(carrier: dict, trace_id: str, span_id: str, braintrust_parent: str | None) -> None:
2473+
"""Inject W3C trace-context headers into a carrier dict (in place).
2474+
2475+
Emits `traceparent` from the hex trace/span ids, and merges
2476+
`braintrust.parent` into existing `baggage` when known. Pre-existing,
2477+
non-Braintrust baggage entries are preserved. Never emits `x-bt-parent`.
2478+
"""
2479+
traceparent = format_traceparent(trace_id, span_id)
2480+
if traceparent is None:
2481+
# Ids aren't W3C-shaped (e.g. legacy UUID mode); nothing to propagate.
2482+
return
2483+
carrier[TRACEPARENT_HEADER] = traceparent
2484+
2485+
# Merge braintrust.parent into any existing baggage, preserving other keys.
2486+
existing = get_header(carrier, BAGGAGE_HEADER)
2487+
entries = parse_baggage(existing) if existing else {}
2488+
if braintrust_parent:
2489+
entries[BRAINTRUST_PARENT_KEY] = braintrust_parent
2490+
baggage_value = format_baggage(entries)
2491+
if baggage_value is not None:
2492+
carrier[BAGGAGE_HEADER] = baggage_value
2493+
2494+
2495+
def inject_trace_context(carrier: dict | None = None, span: "Span | None" = None) -> dict:
2496+
"""Inject W3C trace-context headers for the current (or given) span into a carrier.
2497+
2498+
This is the free-function form of `Span.inject`, and the send-side
2499+
counterpart of `extract_trace_context`. If no span is provided, the
2500+
currently-active span is used. Propagation is best-effort and never raises.
2501+
2502+
:param carrier: Optional carrier dict (e.g. outbound HTTP headers) to mutate.
2503+
:param span: Optional span to inject. Defaults to the current span.
2504+
:returns: The carrier with propagation headers injected.
2505+
"""
2506+
if carrier is None:
2507+
carrier = {}
2508+
span = span if span is not None else current_span()
2509+
try:
2510+
return span.inject(carrier)
2511+
except Exception as e:
2512+
logging.warning(f"Error injecting trace context: {e}")
2513+
return carrier
2514+
2515+
2516+
def extract_trace_context(headers: dict, default_parent: str | None = None) -> str | None:
2517+
"""Resolve a Braintrust `parent` string from inbound trace-context headers.
2518+
2519+
This is the receive-side counterpart of `Span.inject` /
2520+
`inject_trace_context`. The returned value can be passed as `parent=` to
2521+
`start_span`.
2522+
2523+
Resolution priority (per the distributed-tracing spec):
2524+
2525+
1. A valid W3C `traceparent` establishes trace identity. The Braintrust
2526+
container is read from `braintrust.parent` in `baggage`; if absent, it
2527+
falls back to `default_parent`, then to the SDK's configured current
2528+
logger/experiment.
2529+
2. Otherwise, the deprecated `x-bt-parent` header (a span slug) is decoded.
2530+
3. Otherwise, returns None (caller should start a fresh root span).
2531+
2532+
When both `traceparent` and `x-bt-parent` are present, `traceparent` wins.
2533+
Header lookups are case-insensitive. Malformed headers are treated as absent.
2534+
2535+
:param headers: Inbound carrier (e.g. HTTP request headers).
2536+
:param default_parent: Braintrust parent string to use when trace identity is
2537+
present but no `braintrust.parent` baggage was provided.
2538+
:returns: An opaque parent string for `start_span(parent=...)`, or None.
2539+
"""
2540+
if not headers:
2541+
return None
2542+
2543+
traceparent = get_header(headers, TRACEPARENT_HEADER)
2544+
parsed = parse_traceparent(traceparent) if traceparent else None
2545+
2546+
if parsed is not None:
2547+
trace_id, span_id = parsed
2548+
2549+
# Determine the Braintrust container: baggage -> default_parent -> current.
2550+
braintrust_parent = None
2551+
baggage_value = get_header(headers, BAGGAGE_HEADER)
2552+
if baggage_value:
2553+
braintrust_parent = parse_baggage(baggage_value).get(BRAINTRUST_PARENT_KEY)
2554+
if not braintrust_parent:
2555+
braintrust_parent = default_parent or _current_braintrust_parent()
2556+
if not braintrust_parent:
2557+
logging.warning(
2558+
"Received traceparent without a braintrust.parent and no default parent is "
2559+
"configured; cannot route the trace. Configure a logger/experiment or pass "
2560+
"default_parent."
2561+
)
2562+
return None
2563+
2564+
parsed_parent = _braintrust_parent_to_components(braintrust_parent)
2565+
if parsed_parent is None:
2566+
logging.warning(f"Invalid braintrust.parent: {braintrust_parent!r}")
2567+
return None
2568+
object_type, object_id, compute_args = parsed_parent
2569+
2570+
return SpanComponentsV4(
2571+
object_type=object_type,
2572+
object_id=object_id,
2573+
compute_object_metadata_args=compute_args,
2574+
row_id="bt-propagation", # non-empty to enable span_id/root_span_id
2575+
span_id=span_id,
2576+
root_span_id=trace_id,
2577+
).to_str()
2578+
2579+
# No valid traceparent: fall back to the deprecated x-bt-parent slug.
2580+
bt_parent = get_header(headers, BT_PARENT_HEADER)
2581+
if bt_parent:
2582+
try:
2583+
# Validate it decodes; pass through opaquely if so.
2584+
SpanComponentsV4.from_str(bt_parent)
2585+
return bt_parent
2586+
except Exception:
2587+
logging.warning("Malformed x-bt-parent header; ignoring.")
2588+
return None
2589+
2590+
return None
2591+
2592+
23912593
def _try_log_input(span, f_sig, f_args, f_kwargs):
23922594
if f_sig:
23932595
input_data = f_sig.bind(*f_args, **f_kwargs).arguments
@@ -4374,9 +4576,9 @@ def export(self) -> str:
43744576
object_id = self.parent_object_id.get()
43754577
compute_object_metadata_args = None
43764578

4377-
# Choose SpanComponents version based on BRAINTRUST_OTEL_COMPAT env var
4378-
use_v4 = BraintrustEnv.OTEL_COMPAT.get(False)
4379-
span_components_class = SpanComponentsV4 if use_v4 else SpanComponentsV3
4579+
# Choose SpanComponents version based on the active ID format (hex -> V4,
4580+
# legacy UUID -> V3). Coupled via _get_exporter() so the two never desync.
4581+
span_components_class = _get_exporter()
43804582

43814583
# Disable span cache since remote function spans won't be in the local cache
43824584
self.state.span_cache.disable()
@@ -4391,6 +4593,32 @@ def export(self) -> str:
43914593
propagated_event=self.propagated_event,
43924594
).to_str()
43934595

4596+
def inject(self, carrier: dict | None = None) -> dict:
4597+
"""Inject W3C trace-context headers for this span into a carrier dict.
4598+
4599+
Adds ``traceparent`` (trace identity) and, when this span's Braintrust
4600+
parent is known, a ``baggage`` entry ``braintrust.parent=<parent>``
4601+
(merged with any pre-existing baggage). Propagation is best-effort and
4602+
never raises; if the span's ids are not W3C-shaped hex (e.g. legacy UUID
4603+
mode), ``traceparent`` is omitted.
4604+
4605+
:param carrier: Optional existing carrier (e.g. outbound HTTP headers) to
4606+
mutate and return. A new dict is created if not provided.
4607+
:returns: The carrier dict with propagation headers injected.
4608+
"""
4609+
if carrier is None:
4610+
carrier = {}
4611+
try:
4612+
_inject_into_carrier(
4613+
carrier,
4614+
trace_id=self.root_span_id,
4615+
span_id=self.span_id,
4616+
braintrust_parent=self._get_otel_parent(),
4617+
)
4618+
except Exception as e: # best-effort: never break the caller
4619+
logging.warning(f"Error injecting trace context: {e}")
4620+
return carrier
4621+
43944622
def link(self) -> str:
43954623
parent_type, info = self._get_parent_info()
43964624
if parent_type == SpanObjectTypeV3.PROJECT_LOGS:

0 commit comments

Comments
 (0)