5252 TRANSACTION_ID_FIELD ,
5353 VALID_SOURCES ,
5454)
55- from .env import BraintrustEnv
55+ from .env import BraintrustEnv , use_legacy_uuid_ids
5656from .generated_types import (
5757 AttachmentReference ,
5858 AttachmentStatus ,
7171from .prompt_cache .lru_cache import LRUCache
7272from .prompt_cache .parameters_cache import ParametersCache
7373from .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+ )
7485from .queue import DEFAULT_QUEUE_SIZE , LogQueue
7586from .serializable_data_class import SerializableDataClass
7687from .span_identifier_v3 import SpanComponentsV3 , SpanObjectTypeV3
@@ -146,9 +157,14 @@ class ParametersRef(TypedDict, total=False):
146157
147158
148159def _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
154170class 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+
23912593def _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