Skip to content

Commit 915ea54

Browse files
committed
implement w3c distributed tracing
implements the `distributed-tracing` sdk spec - default span/trace id gen to use otel-friendly 16/8 byte hex IDs - new inject/extract tools for w3c headers
1 parent 0bff32c commit 915ea54

16 files changed

Lines changed: 1658 additions & 154 deletions

py/src/braintrust/env.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import logging
23
import math
34
import os
45
import shlex
@@ -8,6 +9,9 @@
89
from typing import TypeVar, cast
910

1011

12+
_logger = logging.getLogger(__name__)
13+
14+
1115
T = TypeVar("T")
1216
EnvValue = bool | float | int | str
1317
_Parser = Callable[[str], EnvValue | None]
@@ -150,6 +154,47 @@ def _parse_dotenv_contents(self, contents: str) -> EnvValue | None:
150154
return None
151155

152156

157+
_warned_legacy_uuid_conflict = False
158+
159+
160+
def _resolve_use_legacy_uuid_ids() -> bool:
161+
"""Resolve whether the SDK should generate legacy UUID-based span/trace IDs.
162+
163+
The default is OpenTelemetry-compatible hex IDs (16-byte trace id / 8-byte
164+
span id) with V4 span-component export. Setting BRAINTRUST_LEGACY_IDS
165+
opts back into UUID IDs with V3 export.
166+
167+
BRAINTRUST_OTEL_COMPAT (which selects the OpenTelemetry context manager)
168+
requires hex IDs, so it always wins: if both it and BRAINTRUST_LEGACY_IDS
169+
are set, legacy IDs are disabled and a warning is logged (at most once per
170+
process, even though this is re-resolved lazily on each access).
171+
"""
172+
global _warned_legacy_uuid_conflict
173+
174+
legacy = EnvVar("BRAINTRUST_LEGACY_IDS", EnvParser.BOOL).get(False)
175+
if EnvVar("BRAINTRUST_OTEL_COMPAT", EnvParser.BOOL).get(False):
176+
if legacy and not _warned_legacy_uuid_conflict:
177+
_warned_legacy_uuid_conflict = True
178+
_logger.warning(
179+
"BRAINTRUST_LEGACY_IDS is ignored because BRAINTRUST_OTEL_COMPAT "
180+
"requires OpenTelemetry-compatible hex span IDs. Using hex IDs."
181+
)
182+
return False
183+
return legacy
184+
185+
186+
class _LegacyUuidIdsField:
187+
"""Lazy, read-only descriptor for the legacy-UUID-IDs setting.
188+
189+
Like the other entries on BraintrustEnv, this re-reads the environment on
190+
each access rather than caching at import time, so changing the relevant env
191+
vars (e.g. in tests) is reflected immediately.
192+
"""
193+
194+
def __get__(self, instance: object, owner: type | None = None) -> bool:
195+
return _resolve_use_legacy_uuid_ids()
196+
197+
153198
class BraintrustEnv:
154199
API_KEY = EnvVar("BRAINTRUST_API_KEY", EnvParser.STRING)
155200
HTTP_TIMEOUT = EnvVar("BRAINTRUST_HTTP_TIMEOUT", EnvParser.FLOAT)
@@ -163,3 +208,6 @@ class BraintrustEnv:
163208
ALL_PUBLISH_PAYLOADS_DIR = EnvVar("BRAINTRUST_ALL_PUBLISH_PAYLOADS_DIR", EnvParser.STRING)
164209
DISABLE_ATEXIT_FLUSH = EnvVar("BRAINTRUST_DISABLE_ATEXIT_FLUSH", EnvParser.BOOL)
165210
OTEL_COMPAT = EnvVar("BRAINTRUST_OTEL_COMPAT", EnvParser.BOOL)
211+
# Opt out of the default OpenTelemetry-compatible hex span/trace IDs and use
212+
# legacy UUID-based IDs (and V3 span-component export) instead.
213+
LEGACY_IDS = _LegacyUuidIdsField()

py/src/braintrust/id_gen.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ def get_id_generator():
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_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 BraintrustEnv.LEGACY_IDS else OTELIDGenerator()
1618

1719

1820
class IDGenerator(ABC):

py/src/braintrust/integrations/claude_agent_sdk/test_claude_agent_sdk.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,16 @@ async def calculator_handler(args):
210210
assert tool_span["output"] is not None
211211
assert any(parent_id in llm_span_ids for parent_id in tool_span["span_parents"])
212212

213-
root_span_id = task_span["span_id"]
213+
# Descendants share the task's trace (``root_span_id``); direct children
214+
# reference the task's ``span_id`` in ``span_parents``.
215+
task_root_span_id = task_span["root_span_id"]
216+
task_span_id = task_span["span_id"]
214217
for llm_span in llm_spans:
215-
assert llm_span["root_span_id"] == root_span_id
216-
assert root_span_id in llm_span["span_parents"]
218+
assert llm_span["root_span_id"] == task_root_span_id
219+
assert task_span_id in llm_span["span_parents"]
217220

218221
for tool_span in tool_spans:
219-
assert tool_span["root_span_id"] == root_span_id
222+
assert tool_span["root_span_id"] == task_root_span_id
220223
assert any(parent_id in llm_span_ids for parent_id in tool_span["span_parents"])
221224

222225

@@ -454,7 +457,8 @@ async def user_prompt_hook(input_data: Any, tool_use_id: str | None, context: An
454457

455458
hook_span = function_spans[0]
456459
assert task_span["input"] == prompt
457-
assert hook_span["root_span_id"] == task_span["span_id"]
460+
# The hook span is a descendant of the task span, so they share a trace.
461+
assert hook_span["root_span_id"] == task_span["root_span_id"]
458462
assert hook_span["input"]["hook_event_name"] == "UserPromptSubmit"
459463
assert hook_span["input"]["prompt"] == prompt
460464
assert hook_span["output"]["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
@@ -546,7 +550,8 @@ async def post_tool_hook(input_data: Any, tool_use_id: str | None, context: Any)
546550
post_span = hook_span_by_event["PostToolUse"]
547551

548552
for hook_span in (pre_span, post_span):
549-
assert hook_span["root_span_id"] == task_span["span_id"]
553+
# Hook spans are descendants of the task span, so they share a trace.
554+
assert hook_span["root_span_id"] == task_span["root_span_id"]
550555
assert hook_span["input"]["tool_name"] == "Bash"
551556

552557
assert pre_span["output"]["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
@@ -681,7 +686,9 @@ async def test_bundled_subagent_creates_task_span(memory_logger):
681686
assert subagent_spans, "Expected at least one subagent task span"
682687
assert any(s.get("metadata", {}).get("task_id") for s in subagent_spans)
683688
for subagent_span in subagent_spans:
684-
assert subagent_span["root_span_id"] == root_task_span["span_id"]
689+
# Subagent spans are descendants of the root task span, so they share a
690+
# trace; the root task ``span_id`` appears in ``span_parents`` below.
691+
assert subagent_span["root_span_id"] == root_task_span["root_span_id"]
685692
parents = set(subagent_span["span_parents"])
686693
tool_use_id = subagent_span.get("metadata", {}).get("tool_use_id")
687694
matching_tool_span = next(

py/src/braintrust/integrations/huggingface_hub/test_huggingface_hub.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,6 @@ def test_wrap_huggingface_hub_chat_completion_sync(memory_logger):
249249
# With no parent span on the stack, the LLM span is its own root and has
250250
# no ``span_parents``.
251251
assert not span.get("span_parents")
252-
assert span["span_id"] == span["root_span_id"]
253252
# The user's ``provider=`` kwarg overrides the default "huggingface"
254253
# identity so the span reflects the actual routing target.
255254
assert span["metadata"]["provider"] == CHAT_PROVIDER
@@ -317,7 +316,6 @@ def test_wrap_huggingface_hub_chat_completion_streaming(memory_logger):
317316
# when the iterator is exhausted, with no parent on the stack the span is
318317
# still its own root.
319318
assert not span.get("span_parents")
320-
assert span["span_id"] == span["root_span_id"]
321319
assert span["metadata"]["provider"] == CHAT_PROVIDER
322320

323321
# Aggregated output is ``{"choices": [{"index", "message": {...}, "finish_reason"?}]}``.
@@ -476,7 +474,6 @@ async def _run():
476474
span = spans[0]
477475
assert span["span_attributes"]["name"] == "huggingface.chat_completion"
478476
assert not span.get("span_parents")
479-
assert span["span_id"] == span["root_span_id"]
480477
assert span["metadata"]["provider"] == CHAT_PROVIDER
481478

482479

py/src/braintrust/integrations/langchain/test_callbacks.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ def test_llm_calls(logger_memory_logger):
6262
spans = memory_logger.pop()
6363
assert len(spans) == 3
6464

65+
# ``root_span_id`` is the root span's own span_id (the parent reference for
66+
# its children); ``trace_root_id`` is the trace shared by every span.
6567
root_span_id = spans[0]["span_id"]
68+
trace_root_id = spans[0]["root_span_id"]
6669

6770
assert_matches_object(
6871
spans,
@@ -81,7 +84,7 @@ def test_llm_calls(logger_memory_logger):
8184
},
8285
"metadata": {"tags": []},
8386
"span_id": root_span_id,
84-
"root_span_id": root_span_id,
87+
"root_span_id": trace_root_id,
8588
},
8689
{
8790
"span_attributes": {"name": "ChatPromptTemplate"},
@@ -97,7 +100,7 @@ def test_llm_calls(logger_memory_logger):
97100
]
98101
},
99102
"metadata": {"tags": ["seq:step:1"]},
100-
"root_span_id": root_span_id,
103+
"root_span_id": trace_root_id,
101104
"span_parents": [root_span_id],
102105
},
103106
{
@@ -144,7 +147,7 @@ def test_llm_calls(logger_memory_logger):
144147
"tags": ["seq:step:2"],
145148
"model": "gpt-4o-mini-2024-07-18",
146149
},
147-
"root_span_id": root_span_id,
150+
"root_span_id": trace_root_id,
148151
"span_parents": [root_span_id],
149152
},
150153
],
@@ -171,6 +174,7 @@ def test_chain_with_memory(logger_memory_logger):
171174
assert len(spans) == 3
172175

173176
root_span_id = spans[0]["span_id"]
177+
trace_root_id = spans[0]["root_span_id"]
174178

175179
assert_matches_object(
176180
spans,
@@ -189,7 +193,7 @@ def test_chain_with_memory(logger_memory_logger):
189193
},
190194
"metadata": {"tags": ["test"]},
191195
"span_id": root_span_id,
192-
"root_span_id": root_span_id,
196+
"root_span_id": trace_root_id,
193197
},
194198
{
195199
"span_attributes": {"name": "ChatPromptTemplate"},
@@ -205,7 +209,7 @@ def test_chain_with_memory(logger_memory_logger):
205209
]
206210
},
207211
"metadata": {"tags": ["seq:step:1", "test"]},
208-
"root_span_id": root_span_id,
212+
"root_span_id": trace_root_id,
209213
"span_parents": [root_span_id],
210214
},
211215
{
@@ -252,7 +256,7 @@ def test_chain_with_memory(logger_memory_logger):
252256
"tags": ["seq:step:2", "test"],
253257
"model": "gpt-4o-mini-2024-07-18",
254258
},
255-
"root_span_id": root_span_id,
259+
"root_span_id": trace_root_id,
256260
"span_parents": [root_span_id],
257261
},
258262
],
@@ -301,13 +305,14 @@ def calculator(input: CalculatorInput) -> str:
301305

302306
spans = memory_logger.pop()
303307
root_span_id = spans[0]["span_id"]
308+
trace_root_id = spans[0]["root_span_id"]
304309

305310
assert_matches_object(
306311
spans,
307312
[
308313
{
309314
"span_id": root_span_id,
310-
"root_span_id": root_span_id,
315+
"root_span_id": trace_root_id,
311316
"span_attributes": {
312317
"name": "ChatOpenAI",
313318
"type": "llm",
@@ -640,13 +645,13 @@ def test_chain_null_values(logger_memory_logger):
640645
flush()
641646

642647
spans = memory_logger.pop()
643-
root_span_id = spans[0]["span_id"]
648+
trace_root_id = spans[0]["root_span_id"]
644649

645650
assert_matches_object(
646651
spans,
647652
[
648653
{
649-
"root_span_id": root_span_id,
654+
"root_span_id": trace_root_id,
650655
"span_attributes": {
651656
"name": "TestChain",
652657
"type": "task",
@@ -721,7 +726,10 @@ def task_fn(input, hooks):
721726

722727
# Find the root eval span
723728
root_eval_span = [s for s in spans if s.get("span_attributes", {}).get("name") == "test-consecutive-eval"][0]
729+
# ``root_eval_span_id`` is the eval root's own span_id (the parent reference
730+
# for its children); ``trace_root_id`` is the trace shared by every span.
724731
root_eval_span_id = root_eval_span["span_id"]
732+
trace_root_id = root_eval_span["root_span_id"]
725733

726734
# Find the eval dataset record spans (direct children of root eval span)
727735
eval_record_spans = [
@@ -751,7 +759,7 @@ def task_fn(input, hooks):
751759
[
752760
{
753761
"span_id": root_eval_span_id,
754-
"root_span_id": root_eval_span_id,
762+
"root_span_id": trace_root_id,
755763
"span_attributes": {
756764
"name": "test-consecutive-eval",
757765
"type": "eval",
@@ -765,7 +773,7 @@ def task_fn(input, hooks):
765773
[eval_record_1],
766774
[
767775
{
768-
"root_span_id": root_eval_span_id,
776+
"root_span_id": trace_root_id,
769777
"span_parents": [root_eval_span_id],
770778
"span_attributes": {
771779
"name": "eval",
@@ -781,7 +789,7 @@ def task_fn(input, hooks):
781789
[eval_record_2],
782790
[
783791
{
784-
"root_span_id": root_eval_span_id,
792+
"root_span_id": trace_root_id,
785793
"span_parents": [root_eval_span_id],
786794
"span_attributes": {
787795
"name": "eval",
@@ -797,7 +805,7 @@ def task_fn(input, hooks):
797805
[task_1_span],
798806
[
799807
{
800-
"root_span_id": root_eval_span_id,
808+
"root_span_id": trace_root_id,
801809
"span_parents": [eval_record_1["span_id"]],
802810
"span_attributes": {
803811
"name": "task",
@@ -813,7 +821,7 @@ def task_fn(input, hooks):
813821
[task_2_span],
814822
[
815823
{
816-
"root_span_id": root_eval_span_id,
824+
"root_span_id": trace_root_id,
817825
"span_parents": [eval_record_2["span_id"]],
818826
"span_attributes": {
819827
"name": "task",

py/src/braintrust/integrations/langchain/test_context.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ def test_global_handler(logger_memory_logger):
6363
spans = memory_logger.pop()
6464
assert len(spans) > 0
6565

66+
# ``root_span_id`` is the root span's own span_id (the parent reference for
67+
# its children); ``trace_root_id`` is the trace shared by every span.
6668
root_span_id = spans[0]["span_id"]
69+
trace_root_id = spans[0]["root_span_id"]
6770

6871
# Spans would be empty if the handler was not registered, let's make sure it logged what we expect
6972
assert_matches_object(
@@ -83,7 +86,7 @@ def test_global_handler(logger_memory_logger):
8386
},
8487
"metadata": {"tags": []},
8588
"span_id": root_span_id,
86-
"root_span_id": root_span_id,
89+
"root_span_id": trace_root_id,
8790
},
8891
{
8992
"span_attributes": {"name": "ChatPromptTemplate"},
@@ -99,7 +102,7 @@ def test_global_handler(logger_memory_logger):
99102
]
100103
},
101104
"metadata": {"tags": ["seq:step:1"]},
102-
"root_span_id": root_span_id,
105+
"root_span_id": trace_root_id,
103106
"span_parents": [root_span_id],
104107
},
105108
{
@@ -146,7 +149,7 @@ def test_global_handler(logger_memory_logger):
146149
"tags": ["seq:step:2"],
147150
"model": "gpt-4o-mini-2024-07-18",
148151
},
149-
"root_span_id": root_span_id,
152+
"root_span_id": trace_root_id,
150153
"span_parents": [root_span_id],
151154
},
152155
],

0 commit comments

Comments
 (0)