braintrust 0.3.15__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- braintrust/_generated_types.py +737 -672
- braintrust/audit.py +2 -2
- braintrust/bt_json.py +178 -19
- braintrust/cli/eval.py +6 -7
- braintrust/cli/push.py +11 -11
- braintrust/context.py +12 -17
- braintrust/contrib/temporal/__init__.py +16 -27
- braintrust/contrib/temporal/test_temporal.py +8 -3
- braintrust/devserver/auth.py +8 -8
- braintrust/devserver/cache.py +3 -4
- braintrust/devserver/cors.py +8 -7
- braintrust/devserver/dataset.py +3 -5
- braintrust/devserver/eval_hooks.py +7 -6
- braintrust/devserver/schemas.py +22 -19
- braintrust/devserver/server.py +19 -12
- braintrust/devserver/test_cached_login.py +4 -4
- braintrust/framework.py +139 -142
- braintrust/framework2.py +88 -87
- braintrust/functions/invoke.py +66 -59
- braintrust/functions/stream.py +3 -2
- braintrust/generated_types.py +3 -1
- braintrust/git_fields.py +11 -11
- braintrust/gitutil.py +2 -3
- braintrust/graph_util.py +10 -10
- braintrust/id_gen.py +2 -2
- braintrust/logger.py +373 -471
- braintrust/merge_row_batch.py +10 -9
- braintrust/oai.py +21 -20
- braintrust/otel/__init__.py +49 -49
- braintrust/otel/context.py +16 -30
- braintrust/otel/test_distributed_tracing.py +14 -11
- braintrust/otel/test_otel_bt_integration.py +32 -31
- braintrust/parameters.py +8 -8
- braintrust/prompt.py +14 -14
- braintrust/prompt_cache/disk_cache.py +5 -4
- braintrust/prompt_cache/lru_cache.py +3 -2
- braintrust/prompt_cache/prompt_cache.py +13 -14
- braintrust/queue.py +4 -4
- braintrust/score.py +4 -4
- braintrust/serializable_data_class.py +4 -4
- braintrust/span_identifier_v1.py +1 -2
- braintrust/span_identifier_v2.py +3 -4
- braintrust/span_identifier_v3.py +23 -20
- braintrust/span_identifier_v4.py +34 -25
- braintrust/test_bt_json.py +644 -0
- braintrust/test_framework.py +72 -6
- braintrust/test_helpers.py +5 -5
- braintrust/test_id_gen.py +2 -3
- braintrust/test_logger.py +211 -107
- braintrust/test_otel.py +61 -53
- braintrust/test_queue.py +0 -1
- braintrust/test_score.py +1 -3
- braintrust/test_span_components.py +29 -44
- braintrust/util.py +9 -8
- braintrust/version.py +2 -2
- braintrust/wrappers/_anthropic_utils.py +4 -4
- braintrust/wrappers/agno/__init__.py +3 -4
- braintrust/wrappers/agno/agent.py +1 -2
- braintrust/wrappers/agno/function_call.py +1 -2
- braintrust/wrappers/agno/model.py +1 -2
- braintrust/wrappers/agno/team.py +1 -2
- braintrust/wrappers/agno/utils.py +12 -12
- braintrust/wrappers/anthropic.py +7 -8
- braintrust/wrappers/claude_agent_sdk/__init__.py +3 -4
- braintrust/wrappers/claude_agent_sdk/_wrapper.py +29 -27
- braintrust/wrappers/dspy.py +15 -17
- braintrust/wrappers/google_genai/__init__.py +17 -30
- braintrust/wrappers/langchain.py +22 -24
- braintrust/wrappers/litellm.py +4 -3
- braintrust/wrappers/openai.py +15 -15
- braintrust/wrappers/pydantic_ai.py +225 -110
- braintrust/wrappers/test_agno.py +0 -1
- braintrust/wrappers/test_dspy.py +0 -1
- braintrust/wrappers/test_google_genai.py +64 -4
- braintrust/wrappers/test_litellm.py +0 -1
- braintrust/wrappers/test_pydantic_ai_integration.py +819 -22
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/METADATA +3 -2
- braintrust-0.4.1.dist-info/RECORD +121 -0
- braintrust-0.3.15.dist-info/RECORD +0 -120
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/WHEEL +0 -0
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/entry_points.txt +0 -0
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/top_level.txt +0 -0
braintrust/merge_row_batch.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any, Optional
|
|
2
3
|
|
|
3
4
|
from .db_fields import IS_MERGE_FIELD, PARENT_ID_FIELD
|
|
4
5
|
from .graph_util import UndirectedGraph, topological_sort, undirected_connected_components
|
|
5
6
|
from .util import merge_dicts
|
|
6
7
|
|
|
7
|
-
_MergedRowKey =
|
|
8
|
+
_MergedRowKey = tuple[Optional[Any], ...]
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def _generate_merged_row_key(row: Mapping[str, Any], use_parent_id_for_id: bool = False) -> _MergedRowKey:
|
|
@@ -33,7 +34,7 @@ MERGE_ROW_SKIP_FIELDS = [
|
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def _pop_merge_row_skip_fields(row:
|
|
37
|
+
def _pop_merge_row_skip_fields(row: dict[str, Any]) -> dict[str, Any]:
|
|
37
38
|
popped = {}
|
|
38
39
|
for field in MERGE_ROW_SKIP_FIELDS:
|
|
39
40
|
if field in row:
|
|
@@ -41,14 +42,14 @@ def _pop_merge_row_skip_fields(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
41
42
|
return popped
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def _restore_merge_row_skip_fields(row:
|
|
45
|
+
def _restore_merge_row_skip_fields(row: dict[str, Any], skip_fields: dict[str, Any]):
|
|
45
46
|
for field in MERGE_ROW_SKIP_FIELDS:
|
|
46
47
|
row.pop(field, None)
|
|
47
48
|
if field in skip_fields:
|
|
48
49
|
row[field] = skip_fields[field]
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
def merge_row_batch(rows: Sequence[
|
|
52
|
+
def merge_row_batch(rows: Sequence[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
|
52
53
|
"""Given a batch of rows, merges conflicting rows together to end up with a
|
|
53
54
|
set of rows to insert. Returns a set of de-conflicted rows, as a list of
|
|
54
55
|
lists, where separate lists contain "independent" rows which can be
|
|
@@ -98,7 +99,7 @@ def merge_row_batch(rows: Sequence[Dict[str, Any]]) -> List[List[Dict[str, Any]]
|
|
|
98
99
|
"Logged row is missing an id. This is an internal braintrust error. Please contact us at info@braintrust.dev for help"
|
|
99
100
|
)
|
|
100
101
|
|
|
101
|
-
row_groups:
|
|
102
|
+
row_groups: dict[_MergedRowKey, dict[str, Any]] = {}
|
|
102
103
|
for row in rows:
|
|
103
104
|
key = _generate_merged_row_key(row)
|
|
104
105
|
existing_row = row_groups.get(key)
|
|
@@ -138,7 +139,7 @@ def merge_row_batch(rows: Sequence[Dict[str, Any]]) -> List[List[Dict[str, Any]]
|
|
|
138
139
|
# all groups of rows which each row in a group has a PARENT_ID_FIELD
|
|
139
140
|
# relationship with at least one other row in the group.
|
|
140
141
|
connected_components = undirected_connected_components(
|
|
141
|
-
UndirectedGraph(vertices=set(graph.keys()), edges=
|
|
142
|
+
UndirectedGraph(vertices=set(graph.keys()), edges={(k, v) for k, vs in graph.items() for v in vs})
|
|
142
143
|
)
|
|
143
144
|
|
|
144
145
|
# For each connected row group, run topological sort over that subgraph to
|
|
@@ -148,8 +149,8 @@ def merge_row_batch(rows: Sequence[Dict[str, Any]]) -> List[List[Dict[str, Any]]
|
|
|
148
149
|
|
|
149
150
|
|
|
150
151
|
def batch_items(
|
|
151
|
-
items:
|
|
152
|
-
) ->
|
|
152
|
+
items: list[list[str]], batch_max_num_items: int | None = None, batch_max_num_bytes: int | None = None
|
|
153
|
+
) -> list[list[list[str]]]:
|
|
153
154
|
"""Repartition the given list of items into sets of batches which can be
|
|
154
155
|
published in parallel or in sequence.
|
|
155
156
|
|
braintrust/oai.py
CHANGED
|
@@ -2,7 +2,8 @@ import abc
|
|
|
2
2
|
import base64
|
|
3
3
|
import re
|
|
4
4
|
import time
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from .logger import Attachment, Span, start_span
|
|
8
9
|
from .span_types import SpanTypeAttribute
|
|
@@ -70,7 +71,7 @@ def log_headers(response: Any, span: Span):
|
|
|
70
71
|
)
|
|
71
72
|
|
|
72
73
|
|
|
73
|
-
def _convert_data_url_to_attachment(data_url: str, filename:
|
|
74
|
+
def _convert_data_url_to_attachment(data_url: str, filename: str | None = None) -> Attachment | str:
|
|
74
75
|
"""Helper function to convert data URL to an Attachment."""
|
|
75
76
|
data_url_match = re.match(r"^data:([^;]+);base64,(.+)$", data_url)
|
|
76
77
|
if not data_url_match:
|
|
@@ -140,7 +141,7 @@ def _process_attachments_in_input(input_data: Any) -> Any:
|
|
|
140
141
|
|
|
141
142
|
|
|
142
143
|
class ChatCompletionWrapper:
|
|
143
|
-
def __init__(self, create_fn:
|
|
144
|
+
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None):
|
|
144
145
|
self.create_fn = create_fn
|
|
145
146
|
self.acreate_fn = acreate_fn
|
|
146
147
|
|
|
@@ -254,7 +255,7 @@ class ChatCompletionWrapper:
|
|
|
254
255
|
span.end()
|
|
255
256
|
|
|
256
257
|
@classmethod
|
|
257
|
-
def _parse_params(cls, params:
|
|
258
|
+
def _parse_params(cls, params: dict[str, Any]) -> dict[str, Any]:
|
|
258
259
|
# First, destructively remove span_info
|
|
259
260
|
ret = params.pop("span_info", {})
|
|
260
261
|
|
|
@@ -274,12 +275,12 @@ class ChatCompletionWrapper:
|
|
|
274
275
|
)
|
|
275
276
|
|
|
276
277
|
@classmethod
|
|
277
|
-
def _postprocess_streaming_results(cls, all_results:
|
|
278
|
+
def _postprocess_streaming_results(cls, all_results: list[dict[str, Any]]) -> dict[str, Any]:
|
|
278
279
|
role = None
|
|
279
280
|
content = None
|
|
280
|
-
tool_calls:
|
|
281
|
+
tool_calls: list[Any] | None = None
|
|
281
282
|
finish_reason = None
|
|
282
|
-
metrics:
|
|
283
|
+
metrics: dict[str, float] = {}
|
|
283
284
|
for result in all_results:
|
|
284
285
|
usage = result.get("usage")
|
|
285
286
|
if usage:
|
|
@@ -338,7 +339,7 @@ class ChatCompletionWrapper:
|
|
|
338
339
|
|
|
339
340
|
|
|
340
341
|
class ResponseWrapper:
|
|
341
|
-
def __init__(self, create_fn:
|
|
342
|
+
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None, name: str = "openai.responses.create"):
|
|
342
343
|
self.create_fn = create_fn
|
|
343
344
|
self.acreate_fn = acreate_fn
|
|
344
345
|
self.name = name
|
|
@@ -447,7 +448,7 @@ class ResponseWrapper:
|
|
|
447
448
|
span.end()
|
|
448
449
|
|
|
449
450
|
@classmethod
|
|
450
|
-
def _parse_params(cls, params:
|
|
451
|
+
def _parse_params(cls, params: dict[str, Any]) -> dict[str, Any]:
|
|
451
452
|
# First, destructively remove span_info
|
|
452
453
|
ret = params.pop("span_info", {})
|
|
453
454
|
|
|
@@ -467,7 +468,7 @@ class ResponseWrapper:
|
|
|
467
468
|
)
|
|
468
469
|
|
|
469
470
|
@classmethod
|
|
470
|
-
def _parse_event_from_result(cls, result:
|
|
471
|
+
def _parse_event_from_result(cls, result: dict[str, Any]) -> dict[str, Any]:
|
|
471
472
|
"""Parse event from response result"""
|
|
472
473
|
data = {"metrics": {}}
|
|
473
474
|
|
|
@@ -487,7 +488,7 @@ class ResponseWrapper:
|
|
|
487
488
|
return data
|
|
488
489
|
|
|
489
490
|
@classmethod
|
|
490
|
-
def _postprocess_streaming_results(cls, all_results:
|
|
491
|
+
def _postprocess_streaming_results(cls, all_results: list[Any]) -> dict[str, Any]:
|
|
491
492
|
"""Process streaming results - minimal version focused on metrics extraction."""
|
|
492
493
|
metrics = {}
|
|
493
494
|
output = []
|
|
@@ -570,13 +571,13 @@ class ResponseWrapper:
|
|
|
570
571
|
|
|
571
572
|
|
|
572
573
|
class BaseWrapper(abc.ABC):
|
|
573
|
-
def __init__(self, create_fn:
|
|
574
|
+
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None, name: str):
|
|
574
575
|
self._create_fn = create_fn
|
|
575
576
|
self._acreate_fn = acreate_fn
|
|
576
577
|
self._name = name
|
|
577
578
|
|
|
578
579
|
@abc.abstractmethod
|
|
579
|
-
def process_output(self, response:
|
|
580
|
+
def process_output(self, response: dict[str, Any], span: Span):
|
|
580
581
|
"""Process the API response and log relevant information to the span."""
|
|
581
582
|
pass
|
|
582
583
|
|
|
@@ -614,7 +615,7 @@ class BaseWrapper(abc.ABC):
|
|
|
614
615
|
return raw_response
|
|
615
616
|
|
|
616
617
|
@classmethod
|
|
617
|
-
def _parse_params(cls, params:
|
|
618
|
+
def _parse_params(cls, params: dict[str, Any]) -> dict[str, Any]:
|
|
618
619
|
# First, destructively remove span_info
|
|
619
620
|
ret = params.pop("span_info", {})
|
|
620
621
|
|
|
@@ -634,10 +635,10 @@ class BaseWrapper(abc.ABC):
|
|
|
634
635
|
|
|
635
636
|
|
|
636
637
|
class EmbeddingWrapper(BaseWrapper):
|
|
637
|
-
def __init__(self, create_fn:
|
|
638
|
+
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None):
|
|
638
639
|
super().__init__(create_fn, acreate_fn, "Embedding")
|
|
639
640
|
|
|
640
|
-
def process_output(self, response:
|
|
641
|
+
def process_output(self, response: dict[str, Any], span: Span):
|
|
641
642
|
usage = response.get("usage")
|
|
642
643
|
metrics = _parse_metrics_from_usage(usage)
|
|
643
644
|
span.log(
|
|
@@ -649,7 +650,7 @@ class EmbeddingWrapper(BaseWrapper):
|
|
|
649
650
|
|
|
650
651
|
|
|
651
652
|
class ModerationWrapper(BaseWrapper):
|
|
652
|
-
def __init__(self, create_fn:
|
|
653
|
+
def __init__(self, create_fn: Callable[..., Any] | None, acreate_fn: Callable[..., Any] | None):
|
|
653
654
|
super().__init__(create_fn, acreate_fn, "Moderation")
|
|
654
655
|
|
|
655
656
|
def process_output(self, response: Any, span: Span):
|
|
@@ -896,7 +897,7 @@ TOKEN_PREFIX_MAP = {
|
|
|
896
897
|
}
|
|
897
898
|
|
|
898
899
|
|
|
899
|
-
def _parse_metrics_from_usage(usage: Any) ->
|
|
900
|
+
def _parse_metrics_from_usage(usage: Any) -> dict[str, Any]:
|
|
900
901
|
# For simplicity, this function handles all the different APIs
|
|
901
902
|
metrics = {}
|
|
902
903
|
|
|
@@ -930,7 +931,7 @@ def _is_numeric(v):
|
|
|
930
931
|
return isinstance(v, (int, float, complex)) and not isinstance(v, bool)
|
|
931
932
|
|
|
932
933
|
|
|
933
|
-
def prettify_params(params:
|
|
934
|
+
def prettify_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
934
935
|
# Filter out NOT_GIVEN parameters
|
|
935
936
|
# https://linear.app/braintrustdata/issue/BRA-2467
|
|
936
937
|
ret = {k: v for k, v in params.items() if not _is_not_given(v)}
|
|
@@ -940,7 +941,7 @@ def prettify_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
940
941
|
return ret
|
|
941
942
|
|
|
942
943
|
|
|
943
|
-
def _try_to_dict(obj: Any) ->
|
|
944
|
+
def _try_to_dict(obj: Any) -> dict[str, Any]:
|
|
944
945
|
if isinstance(obj, dict):
|
|
945
946
|
return obj
|
|
946
947
|
# convert a pydantic object to a dict
|
braintrust/otel/__init__.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import warnings
|
|
4
|
-
from typing import Dict, Optional
|
|
5
4
|
from urllib.parse import urljoin
|
|
6
5
|
|
|
7
6
|
INSTALL_ERR_MSG = (
|
|
@@ -138,10 +137,10 @@ class OtelExporter(OTLPSpanExporter):
|
|
|
138
137
|
|
|
139
138
|
def __init__(
|
|
140
139
|
self,
|
|
141
|
-
url:
|
|
142
|
-
api_key:
|
|
143
|
-
parent:
|
|
144
|
-
headers:
|
|
140
|
+
url: str | None = None,
|
|
141
|
+
api_key: str | None = None,
|
|
142
|
+
parent: str | None = None,
|
|
143
|
+
headers: dict[str, str] | None = None,
|
|
145
144
|
**kwargs,
|
|
146
145
|
):
|
|
147
146
|
"""
|
|
@@ -189,13 +188,14 @@ class OtelExporter(OTLPSpanExporter):
|
|
|
189
188
|
super().__init__(endpoint=endpoint, headers=exporter_headers, **kwargs)
|
|
190
189
|
|
|
191
190
|
|
|
192
|
-
def add_braintrust_span_processor(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
def add_braintrust_span_processor(
|
|
192
|
+
tracer_provider,
|
|
193
|
+
api_key: str | None = None,
|
|
194
|
+
parent: str | None = None,
|
|
195
|
+
api_url: str | None = None,
|
|
196
196
|
filter_ai_spans: bool = False,
|
|
197
197
|
custom_filter=None,
|
|
198
|
-
headers:
|
|
198
|
+
headers: dict[str, str] | None = None,
|
|
199
199
|
):
|
|
200
200
|
processor = BraintrustSpanProcessor(
|
|
201
201
|
api_key=api_key,
|
|
@@ -225,13 +225,13 @@ class BraintrustSpanProcessor:
|
|
|
225
225
|
|
|
226
226
|
def __init__(
|
|
227
227
|
self,
|
|
228
|
-
api_key:
|
|
229
|
-
parent:
|
|
230
|
-
api_url:
|
|
228
|
+
api_key: str | None = None,
|
|
229
|
+
parent: str | None = None,
|
|
230
|
+
api_url: str | None = None,
|
|
231
231
|
filter_ai_spans: bool = False,
|
|
232
|
-
custom_filter
|
|
233
|
-
headers:
|
|
234
|
-
SpanProcessor:
|
|
232
|
+
custom_filter=None,
|
|
233
|
+
headers: dict[str, str] | None = None,
|
|
234
|
+
SpanProcessor: type | None = None,
|
|
235
235
|
):
|
|
236
236
|
"""
|
|
237
237
|
Initialize the BraintrustSpanProcessor.
|
|
@@ -279,16 +279,17 @@ class BraintrustSpanProcessor:
|
|
|
279
279
|
|
|
280
280
|
# Priority 1: Check if braintrust.parent is in current OTEL context
|
|
281
281
|
from opentelemetry import baggage, context
|
|
282
|
+
|
|
282
283
|
current_context = context.get_current()
|
|
283
|
-
parent_value = context.get_value(
|
|
284
|
+
parent_value = context.get_value("braintrust.parent", current_context)
|
|
284
285
|
|
|
285
286
|
# Priority 2: Check OTEL baggage (propagates automatically across contexts)
|
|
286
287
|
if not parent_value:
|
|
287
|
-
parent_value = baggage.get_baggage(
|
|
288
|
+
parent_value = baggage.get_baggage("braintrust.parent", context=current_context)
|
|
288
289
|
|
|
289
290
|
# Priority 3: Check if parent_context has braintrust.parent (backup)
|
|
290
291
|
if not parent_value and parent_context:
|
|
291
|
-
parent_value = context.get_value(
|
|
292
|
+
parent_value = context.get_value("braintrust.parent", parent_context)
|
|
292
293
|
|
|
293
294
|
# Priority 4: Check if parent OTEL span has braintrust.parent attribute
|
|
294
295
|
if not parent_value and parent_context:
|
|
@@ -304,7 +305,6 @@ class BraintrustSpanProcessor:
|
|
|
304
305
|
|
|
305
306
|
self._processor.on_start(span, parent_context)
|
|
306
307
|
|
|
307
|
-
|
|
308
308
|
def _get_parent_otel_braintrust_parent(self, parent_context):
|
|
309
309
|
"""Get braintrust.parent attribute from parent OTEL span if it exists."""
|
|
310
310
|
try:
|
|
@@ -313,7 +313,7 @@ class BraintrustSpanProcessor:
|
|
|
313
313
|
# Get the current span from the parent context
|
|
314
314
|
current_span = trace.get_current_span(parent_context)
|
|
315
315
|
|
|
316
|
-
if current_span and hasattr(current_span,
|
|
316
|
+
if current_span and hasattr(current_span, "attributes") and current_span.attributes:
|
|
317
317
|
# Check if parent span has braintrust.parent attribute
|
|
318
318
|
attributes = dict(current_span.attributes)
|
|
319
319
|
return attributes.get("braintrust.parent")
|
|
@@ -346,11 +346,7 @@ class BraintrustSpanProcessor:
|
|
|
346
346
|
return self._processor
|
|
347
347
|
|
|
348
348
|
|
|
349
|
-
def _get_braintrust_parent(
|
|
350
|
-
object_type,
|
|
351
|
-
object_id: Optional[str] = None,
|
|
352
|
-
compute_args: Optional[Dict] = None
|
|
353
|
-
) -> Optional[str]:
|
|
349
|
+
def _get_braintrust_parent(object_type, object_id: str | None = None, compute_args: dict | None = None) -> str | None:
|
|
354
350
|
"""
|
|
355
351
|
Construct a braintrust.parent identifier string from span components.
|
|
356
352
|
|
|
@@ -405,11 +401,10 @@ def context_from_span_export(export_str: str):
|
|
|
405
401
|
if not OTEL_AVAILABLE:
|
|
406
402
|
raise ImportError(INSTALL_ERR_MSG)
|
|
407
403
|
|
|
404
|
+
from braintrust.span_identifier_v4 import SpanComponentsV4
|
|
408
405
|
from opentelemetry import baggage, trace
|
|
409
406
|
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
|
|
410
407
|
|
|
411
|
-
from braintrust.span_identifier_v4 import SpanComponentsV4
|
|
412
|
-
|
|
413
408
|
# Parse the export string (handles V3/V4 automatically)
|
|
414
409
|
components = SpanComponentsV4.from_str(export_str)
|
|
415
410
|
|
|
@@ -417,7 +412,7 @@ def context_from_span_export(export_str: str):
|
|
|
417
412
|
braintrust_parent = _get_braintrust_parent(
|
|
418
413
|
object_type=components.object_type,
|
|
419
414
|
object_id=components.object_id,
|
|
420
|
-
compute_args=components.compute_object_metadata_args
|
|
415
|
+
compute_args=components.compute_object_metadata_args,
|
|
421
416
|
)
|
|
422
417
|
|
|
423
418
|
# Convert hex strings to OTEL integers
|
|
@@ -429,7 +424,7 @@ def context_from_span_export(export_str: str):
|
|
|
429
424
|
trace_id=trace_id_int,
|
|
430
425
|
span_id=span_id_int,
|
|
431
426
|
is_remote=True, # Critical: mark as remote for distributed tracing
|
|
432
|
-
trace_flags=TraceFlags(TraceFlags.SAMPLED)
|
|
427
|
+
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
433
428
|
)
|
|
434
429
|
|
|
435
430
|
# Create NonRecordingSpan and set in context
|
|
@@ -438,7 +433,7 @@ def context_from_span_export(export_str: str):
|
|
|
438
433
|
|
|
439
434
|
# Set braintrust.parent in OTEL baggage so it propagates automatically
|
|
440
435
|
if braintrust_parent:
|
|
441
|
-
ctx = baggage.set_baggage(
|
|
436
|
+
ctx = baggage.set_baggage("braintrust.parent", braintrust_parent, context=ctx)
|
|
442
437
|
|
|
443
438
|
return ctx
|
|
444
439
|
|
|
@@ -475,7 +470,7 @@ def add_parent_to_baggage(parent: str, ctx=None):
|
|
|
475
470
|
from opentelemetry import baggage, context
|
|
476
471
|
|
|
477
472
|
# Set in baggage so it propagates via inject()
|
|
478
|
-
new_ctx = baggage.set_baggage(
|
|
473
|
+
new_ctx = baggage.set_baggage("braintrust.parent", parent, context=ctx)
|
|
479
474
|
token = context.attach(new_ctx)
|
|
480
475
|
return token
|
|
481
476
|
|
|
@@ -511,11 +506,11 @@ def add_span_parent_to_baggage(span, ctx=None):
|
|
|
511
506
|
raise ImportError(INSTALL_ERR_MSG)
|
|
512
507
|
|
|
513
508
|
# Get braintrust.parent from span attributes
|
|
514
|
-
if not span or not hasattr(span,
|
|
509
|
+
if not span or not hasattr(span, "attributes") or not span.attributes:
|
|
515
510
|
logging.warning("add_span_parent_to_baggage: span has no attributes")
|
|
516
511
|
return None
|
|
517
512
|
|
|
518
|
-
parent_value = span.attributes.get(
|
|
513
|
+
parent_value = span.attributes.get("braintrust.parent")
|
|
519
514
|
if not parent_value:
|
|
520
515
|
logging.warning(
|
|
521
516
|
"add_span_parent_to_baggage: braintrust.parent attribute not found. "
|
|
@@ -527,7 +522,7 @@ def add_span_parent_to_baggage(span, ctx=None):
|
|
|
527
522
|
return add_parent_to_baggage(parent_value, ctx=ctx)
|
|
528
523
|
|
|
529
524
|
|
|
530
|
-
def parent_from_headers(headers:
|
|
525
|
+
def parent_from_headers(headers: dict[str, str]) -> str | None:
|
|
531
526
|
"""
|
|
532
527
|
Extract a Braintrust-compatible parent string from W3C Trace Context headers.
|
|
533
528
|
|
|
@@ -554,17 +549,16 @@ def parent_from_headers(headers: Dict[str, str]) -> Optional[str]:
|
|
|
554
549
|
if not OTEL_AVAILABLE:
|
|
555
550
|
raise ImportError(INSTALL_ERR_MSG)
|
|
556
551
|
|
|
552
|
+
from braintrust.span_identifier_v4 import SpanComponentsV4
|
|
557
553
|
from opentelemetry import baggage, trace
|
|
558
554
|
from opentelemetry.propagate import extract
|
|
559
555
|
|
|
560
|
-
from braintrust.span_identifier_v4 import SpanComponentsV4
|
|
561
|
-
|
|
562
556
|
# Extract context from headers using W3C Trace Context propagator
|
|
563
557
|
ctx = extract(headers)
|
|
564
558
|
|
|
565
559
|
# Get span from context
|
|
566
560
|
span = trace.get_current_span(ctx)
|
|
567
|
-
if not span or not hasattr(span,
|
|
561
|
+
if not span or not hasattr(span, "get_span_context"):
|
|
568
562
|
logging.error("parent_from_headers: No valid span found in headers")
|
|
569
563
|
return None
|
|
570
564
|
|
|
@@ -574,19 +568,19 @@ def parent_from_headers(headers: Dict[str, str]) -> Optional[str]:
|
|
|
574
568
|
return None
|
|
575
569
|
|
|
576
570
|
# Convert OTEL IDs to hex strings
|
|
577
|
-
trace_id_hex = format(span_context.trace_id,
|
|
578
|
-
span_id_hex = format(span_context.span_id,
|
|
571
|
+
trace_id_hex = format(span_context.trace_id, "032x")
|
|
572
|
+
span_id_hex = format(span_context.span_id, "016x")
|
|
579
573
|
|
|
580
574
|
# Validate trace_id and span_id are not all zeros
|
|
581
|
-
if trace_id_hex ==
|
|
575
|
+
if trace_id_hex == "00000000000000000000000000000000":
|
|
582
576
|
logging.error("parent_from_headers: Invalid trace_id (all zeros)")
|
|
583
577
|
return None
|
|
584
|
-
if span_id_hex ==
|
|
578
|
+
if span_id_hex == "0000000000000000":
|
|
585
579
|
logging.error("parent_from_headers: Invalid span_id (all zeros)")
|
|
586
580
|
return None
|
|
587
581
|
|
|
588
582
|
# Get braintrust.parent from baggage if present
|
|
589
|
-
braintrust_parent = baggage.get_baggage(
|
|
583
|
+
braintrust_parent = baggage.get_baggage("braintrust.parent", context=ctx)
|
|
590
584
|
|
|
591
585
|
# Parse braintrust.parent to extract object_type and object_id
|
|
592
586
|
object_type = None
|
|
@@ -607,22 +601,28 @@ def parent_from_headers(headers: Dict[str, str]) -> Optional[str]:
|
|
|
607
601
|
# Parse braintrust.parent format: "project_id:abc", "project_name:xyz", or "experiment_id:123"
|
|
608
602
|
if braintrust_parent.startswith("project_id:"):
|
|
609
603
|
object_type = SpanObjectTypeV3.PROJECT_LOGS
|
|
610
|
-
object_id = braintrust_parent[len("project_id:"):]
|
|
604
|
+
object_id = braintrust_parent[len("project_id:") :]
|
|
611
605
|
if not object_id:
|
|
612
|
-
logging.error(
|
|
606
|
+
logging.error(
|
|
607
|
+
f"parent_from_headers: Invalid braintrust.parent format (empty project_id): {braintrust_parent}"
|
|
608
|
+
)
|
|
613
609
|
return None
|
|
614
610
|
elif braintrust_parent.startswith("project_name:"):
|
|
615
611
|
object_type = SpanObjectTypeV3.PROJECT_LOGS
|
|
616
|
-
project_name = braintrust_parent[len("project_name:"):]
|
|
612
|
+
project_name = braintrust_parent[len("project_name:") :]
|
|
617
613
|
if not project_name:
|
|
618
|
-
logging.error(
|
|
614
|
+
logging.error(
|
|
615
|
+
f"parent_from_headers: Invalid braintrust.parent format (empty project_name): {braintrust_parent}"
|
|
616
|
+
)
|
|
619
617
|
return None
|
|
620
618
|
compute_args = {"project_name": project_name}
|
|
621
619
|
elif braintrust_parent.startswith("experiment_id:"):
|
|
622
620
|
object_type = SpanObjectTypeV3.EXPERIMENT
|
|
623
|
-
object_id = braintrust_parent[len("experiment_id:"):]
|
|
621
|
+
object_id = braintrust_parent[len("experiment_id:") :]
|
|
624
622
|
if not object_id:
|
|
625
|
-
logging.error(
|
|
623
|
+
logging.error(
|
|
624
|
+
f"parent_from_headers: Invalid braintrust.parent format (empty experiment_id): {braintrust_parent}"
|
|
625
|
+
)
|
|
626
626
|
return None
|
|
627
627
|
else:
|
|
628
628
|
logging.error(
|
braintrust/otel/context.py
CHANGED
|
@@ -3,23 +3,21 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Any, Optional
|
|
5
5
|
|
|
6
|
-
from opentelemetry import context, trace
|
|
7
|
-
from opentelemetry.trace import SpanContext, TraceFlags
|
|
8
|
-
|
|
9
6
|
from braintrust.context import ParentSpanIds, SpanInfo
|
|
10
7
|
from braintrust.logger import Span
|
|
8
|
+
from opentelemetry import context, trace
|
|
9
|
+
from opentelemetry.trace import SpanContext, TraceFlags
|
|
11
10
|
|
|
12
11
|
log = logging.getLogger(__name__)
|
|
13
12
|
|
|
14
13
|
|
|
15
|
-
|
|
16
14
|
class ContextManager:
|
|
17
15
|
"""Context manager that uses OTEL's built-in context as single storage."""
|
|
18
16
|
|
|
19
17
|
def __init__(self):
|
|
20
18
|
pass
|
|
21
19
|
|
|
22
|
-
def get_current_span_info(self) -> Optional[
|
|
20
|
+
def get_current_span_info(self) -> Optional["SpanInfo"]:
|
|
23
21
|
"""Get information about the currently active span from OTEL context."""
|
|
24
22
|
|
|
25
23
|
# Get the current span from OTEL context
|
|
@@ -35,25 +33,17 @@ class ContextManager:
|
|
|
35
33
|
if span_context and span_context.span_id != 0:
|
|
36
34
|
# Always prioritize the actual current OTEL span over stored BT span
|
|
37
35
|
# Only use stored BT span if the current OTEL span IS the BT span wrapper
|
|
38
|
-
bt_span = context.get_value(
|
|
36
|
+
bt_span = context.get_value("braintrust_span")
|
|
39
37
|
|
|
40
38
|
# If there's a BT span stored AND the current OTEL span is a NonRecordingSpan
|
|
41
39
|
# (which means it's our BT->OTEL wrapper), then return BT span info
|
|
42
|
-
if
|
|
43
|
-
return SpanInfo(
|
|
44
|
-
trace_id=bt_span.root_span_id,
|
|
45
|
-
span_id=bt_span.span_id,
|
|
46
|
-
span_object=bt_span
|
|
47
|
-
)
|
|
40
|
+
if bt_span and isinstance(current_span, trace.NonRecordingSpan):
|
|
41
|
+
return SpanInfo(trace_id=bt_span.root_span_id, span_id=bt_span.span_id, span_object=bt_span)
|
|
48
42
|
else:
|
|
49
43
|
# Return OTEL span info - this is a real OTEL span, not our wrapper
|
|
50
|
-
otel_trace_id = format(span_context.trace_id,
|
|
51
|
-
otel_span_id = format(span_context.span_id,
|
|
52
|
-
return SpanInfo(
|
|
53
|
-
trace_id=otel_trace_id,
|
|
54
|
-
span_id=otel_span_id,
|
|
55
|
-
span_object=current_span
|
|
56
|
-
)
|
|
44
|
+
otel_trace_id = format(span_context.trace_id, "032x")
|
|
45
|
+
otel_span_id = format(span_context.span_id, "016x")
|
|
46
|
+
return SpanInfo(trace_id=otel_trace_id, span_id=otel_span_id, span_object=current_span)
|
|
57
47
|
|
|
58
48
|
return None
|
|
59
49
|
|
|
@@ -61,11 +51,10 @@ class ContextManager:
|
|
|
61
51
|
"""Set the current active span in OTEL context."""
|
|
62
52
|
from opentelemetry import context, trace
|
|
63
53
|
|
|
64
|
-
if hasattr(span,
|
|
54
|
+
if hasattr(span, "get_span_context"):
|
|
65
55
|
# This is an OTEL span - it will manage its own context
|
|
66
56
|
return None
|
|
67
57
|
else:
|
|
68
|
-
|
|
69
58
|
try:
|
|
70
59
|
trace_id_int = int(span.root_span_id, 16)
|
|
71
60
|
except ValueError:
|
|
@@ -80,15 +69,12 @@ class ContextManager:
|
|
|
80
69
|
|
|
81
70
|
# This is a BT span - store it in OTEL context AND set as current OTEL span
|
|
82
71
|
# First store the BT span
|
|
83
|
-
ctx = context.set_value(
|
|
72
|
+
ctx = context.set_value("braintrust_span", span)
|
|
84
73
|
parent_value = span._get_otel_parent()
|
|
85
|
-
ctx = context.set_value(
|
|
74
|
+
ctx = context.set_value("braintrust.parent", parent_value, ctx)
|
|
86
75
|
|
|
87
76
|
otel_span_context = SpanContext(
|
|
88
|
-
trace_id=trace_id_int,
|
|
89
|
-
span_id=span_id_int,
|
|
90
|
-
is_remote=False,
|
|
91
|
-
trace_flags=TraceFlags(TraceFlags.SAMPLED)
|
|
77
|
+
trace_id=trace_id_int, span_id=span_id_int, is_remote=False, trace_flags=TraceFlags(TraceFlags.SAMPLED)
|
|
92
78
|
)
|
|
93
79
|
|
|
94
80
|
# Create a non-recording span to represent the BT span in OTEL context
|
|
@@ -110,9 +96,9 @@ class ContextManager:
|
|
|
110
96
|
else:
|
|
111
97
|
# No token means we need to explicitly clear the span
|
|
112
98
|
# This shouldn't normally happen, but handle it gracefully
|
|
113
|
-
context.attach(context.set_value(
|
|
99
|
+
context.attach(context.set_value("braintrust_span", None))
|
|
114
100
|
|
|
115
|
-
def get_parent_span_ids(self) ->
|
|
101
|
+
def get_parent_span_ids(self) -> ParentSpanIds | None:
|
|
116
102
|
"""Get parent information for creating a new BT span."""
|
|
117
103
|
span_info = self.get_current_span_info()
|
|
118
104
|
if not span_info:
|
|
@@ -125,4 +111,4 @@ class ContextManager:
|
|
|
125
111
|
|
|
126
112
|
def _is_otel_span(span: Any) -> bool:
|
|
127
113
|
"""Check if the span object is an OTEL span."""
|
|
128
|
-
return hasattr(span,
|
|
114
|
+
return hasattr(span, "get_span_context")
|
|
@@ -8,7 +8,6 @@ is exported from one service and imported in another service.
|
|
|
8
8
|
import os
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
|
-
|
|
12
11
|
from braintrust.logger import _internal_with_memory_background_logger
|
|
13
12
|
from braintrust.otel import BraintrustSpanProcessor, context_from_span_export
|
|
14
13
|
from braintrust.test_helpers import init_test_logger, preserve_env_vars
|
|
@@ -19,6 +18,7 @@ try:
|
|
|
19
18
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
20
19
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
21
20
|
except ImportError:
|
|
21
|
+
|
|
22
22
|
class InMemorySpanExporter:
|
|
23
23
|
def __init__(self):
|
|
24
24
|
pass
|
|
@@ -47,10 +47,10 @@ def otel_fixture():
|
|
|
47
47
|
if not OTEL_AVAILABLE:
|
|
48
48
|
pytest.skip("OpenTelemetry not installed")
|
|
49
49
|
|
|
50
|
-
with preserve_env_vars(
|
|
50
|
+
with preserve_env_vars("BRAINTRUST_OTEL_COMPAT", "BRAINTRUST_API_KEY"):
|
|
51
51
|
# Enable OTEL compatibility mode
|
|
52
|
-
os.environ[
|
|
53
|
-
os.environ[
|
|
52
|
+
os.environ["BRAINTRUST_OTEL_COMPAT"] = "true"
|
|
53
|
+
os.environ["BRAINTRUST_API_KEY"] = "test-api-key-for-fixture"
|
|
54
54
|
|
|
55
55
|
# Set up memory logger for BT spans
|
|
56
56
|
with _internal_with_memory_background_logger() as memory_logger:
|
|
@@ -103,6 +103,7 @@ def test_bt_to_otel_simple_distributed_trace(otel_fixture):
|
|
|
103
103
|
# ===== Service B: Import context and create OTEL child span =====
|
|
104
104
|
# Simulate receiving exported_context over network (e.g., in HTTP header)
|
|
105
105
|
from opentelemetry import context as otel_context
|
|
106
|
+
|
|
106
107
|
ctx = context_from_span_export(exported_context)
|
|
107
108
|
|
|
108
109
|
# Attach the context to make it current, then create the span
|
|
@@ -125,22 +126,24 @@ def test_bt_to_otel_simple_distributed_trace(otel_fixture):
|
|
|
125
126
|
service_b_exported = otel_spans[0]
|
|
126
127
|
|
|
127
128
|
# Convert OTEL IDs to hex for comparison
|
|
128
|
-
service_b_trace_id = format(service_b_exported.context.trace_id,
|
|
129
|
-
service_b_parent_span_id = format(service_b_exported.parent.span_id,
|
|
129
|
+
service_b_trace_id = format(service_b_exported.context.trace_id, "032x")
|
|
130
|
+
service_b_parent_span_id = format(service_b_exported.parent.span_id, "016x") if service_b_exported.parent else None
|
|
130
131
|
|
|
131
132
|
# Assert unified trace ID
|
|
132
|
-
assert service_a_trace_id == service_b_trace_id,
|
|
133
|
+
assert service_a_trace_id == service_b_trace_id, (
|
|
133
134
|
f"Trace IDs should match: {service_a_trace_id} != {service_b_trace_id}"
|
|
135
|
+
)
|
|
134
136
|
|
|
135
137
|
# Assert Service B span has Service A span as parent
|
|
136
|
-
assert service_b_parent_span_id == service_a_span_id,
|
|
138
|
+
assert service_b_parent_span_id == service_a_span_id, (
|
|
137
139
|
f"Service B parent should be Service A span: {service_b_parent_span_id} != {service_a_span_id}"
|
|
140
|
+
)
|
|
138
141
|
|
|
139
142
|
# Assert braintrust.parent attribute is set on OTEL span
|
|
140
|
-
assert "braintrust.parent" in service_b_exported.attributes,
|
|
141
|
-
|
|
142
|
-
assert service_b_exported.attributes["braintrust.parent"] == f"project_name:{project_name}", \
|
|
143
|
+
assert "braintrust.parent" in service_b_exported.attributes, "OTEL span should have braintrust.parent attribute"
|
|
144
|
+
assert service_b_exported.attributes["braintrust.parent"] == f"project_name:{project_name}", (
|
|
143
145
|
f"braintrust.parent should be 'project_name:{project_name}'"
|
|
146
|
+
)
|
|
144
147
|
|
|
145
148
|
|
|
146
149
|
if __name__ == "__main__":
|