Skip to content

Commit c7e3b52

Browse files
authored
feat: otel compatible distributed tracing (#496)
- use otel-friendly IDs by default in the SDK - add inject/extract methods for distributed tracing instead of parent slug methods - implements: https://www.w3.org/TR/trace-context/ OLD WAY: https://www.braintrust.dev/docs/instrument/advanced-tracing#trace-distributed-systems ```python from braintrust import current_span, init_logger, start_span, traced logger = init_logger(project="my-project") # Client: Export the span @Traced def process_request(request): return requests.post( "/api/process", json=request, headers={"X-Trace-ID": current_span().export()}, ) # Server: Resume the trace def handle_request(req): trace_id = req.headers.get("X-Trace-ID") with start_span(parent=trace_id) as span: result = process_data(req.body) span.log(input=req.body, output=result) return result ``` NOTE: the "old way" is still supported and backwards-compatible. Native SDKs can continue to link to each other in the manner, even if one is upgraded and the other is not. However if users with to link to otel sdks, they will have to change their code to use the new apis. NEW WAY: ```python from braintrust import current_span, init_logger, start_span, traced logger = init_logger(project="my-project") # Client: Export the span @Traced def process_request(request): return requests.post( "/api/process", json=request, headers=current_span().inject({}), # <--- new inject api ) # Server: Resume the trace def handle_request(req): with start_span(parent=extract_trace_context(req.headers) as span: # <--- new extract api result = process_data(req.body) span.log(input=req.body, output=result) return result ``` # Other notes - confusing naming: this has the unfortunate effect of the `root_span_id` field essentially becoming the trace id rather than the id of the root span (i.e. `root_span_id` != `rootSpan.id`). This sucks, but it's already the case for otel sdks and py otel compat mode. So at least we're consistent. - legacy id flag: the sdk currently maintains two paths for id generation. it defaults to UUIDs and does hex IDs when we're in in otel compat mode. This changes the default to use hex IDs and has a flag to revert to the old behavior. We could just rip out the old uuid logic entirely, but this approach seems like the least risky - to support passing through tracestate and flags, an additional field was added to SpanImpl (propagated_state) # Examples - https://www.braintrust.dev/app/Braintrust%20SDKs/p/distributed-tracing-test/logs?range=%221h%22&r=021fb6471e30c686a667ffdfbba15ee6&s=67123a9c35899f93 - https://www.braintrust.dev/app/Braintrust%20SDKs/p/distributed-tracing-test/logs?range=%221h%22&r=5be69e7ec10f257b8671417162bcf075&s=050480159793924b
1 parent eb674a6 commit c7e3b52

16 files changed

Lines changed: 1927 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)