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.
Files changed (82) hide show
  1. braintrust/_generated_types.py +737 -672
  2. braintrust/audit.py +2 -2
  3. braintrust/bt_json.py +178 -19
  4. braintrust/cli/eval.py +6 -7
  5. braintrust/cli/push.py +11 -11
  6. braintrust/context.py +12 -17
  7. braintrust/contrib/temporal/__init__.py +16 -27
  8. braintrust/contrib/temporal/test_temporal.py +8 -3
  9. braintrust/devserver/auth.py +8 -8
  10. braintrust/devserver/cache.py +3 -4
  11. braintrust/devserver/cors.py +8 -7
  12. braintrust/devserver/dataset.py +3 -5
  13. braintrust/devserver/eval_hooks.py +7 -6
  14. braintrust/devserver/schemas.py +22 -19
  15. braintrust/devserver/server.py +19 -12
  16. braintrust/devserver/test_cached_login.py +4 -4
  17. braintrust/framework.py +139 -142
  18. braintrust/framework2.py +88 -87
  19. braintrust/functions/invoke.py +66 -59
  20. braintrust/functions/stream.py +3 -2
  21. braintrust/generated_types.py +3 -1
  22. braintrust/git_fields.py +11 -11
  23. braintrust/gitutil.py +2 -3
  24. braintrust/graph_util.py +10 -10
  25. braintrust/id_gen.py +2 -2
  26. braintrust/logger.py +373 -471
  27. braintrust/merge_row_batch.py +10 -9
  28. braintrust/oai.py +21 -20
  29. braintrust/otel/__init__.py +49 -49
  30. braintrust/otel/context.py +16 -30
  31. braintrust/otel/test_distributed_tracing.py +14 -11
  32. braintrust/otel/test_otel_bt_integration.py +32 -31
  33. braintrust/parameters.py +8 -8
  34. braintrust/prompt.py +14 -14
  35. braintrust/prompt_cache/disk_cache.py +5 -4
  36. braintrust/prompt_cache/lru_cache.py +3 -2
  37. braintrust/prompt_cache/prompt_cache.py +13 -14
  38. braintrust/queue.py +4 -4
  39. braintrust/score.py +4 -4
  40. braintrust/serializable_data_class.py +4 -4
  41. braintrust/span_identifier_v1.py +1 -2
  42. braintrust/span_identifier_v2.py +3 -4
  43. braintrust/span_identifier_v3.py +23 -20
  44. braintrust/span_identifier_v4.py +34 -25
  45. braintrust/test_bt_json.py +644 -0
  46. braintrust/test_framework.py +72 -6
  47. braintrust/test_helpers.py +5 -5
  48. braintrust/test_id_gen.py +2 -3
  49. braintrust/test_logger.py +211 -107
  50. braintrust/test_otel.py +61 -53
  51. braintrust/test_queue.py +0 -1
  52. braintrust/test_score.py +1 -3
  53. braintrust/test_span_components.py +29 -44
  54. braintrust/util.py +9 -8
  55. braintrust/version.py +2 -2
  56. braintrust/wrappers/_anthropic_utils.py +4 -4
  57. braintrust/wrappers/agno/__init__.py +3 -4
  58. braintrust/wrappers/agno/agent.py +1 -2
  59. braintrust/wrappers/agno/function_call.py +1 -2
  60. braintrust/wrappers/agno/model.py +1 -2
  61. braintrust/wrappers/agno/team.py +1 -2
  62. braintrust/wrappers/agno/utils.py +12 -12
  63. braintrust/wrappers/anthropic.py +7 -8
  64. braintrust/wrappers/claude_agent_sdk/__init__.py +3 -4
  65. braintrust/wrappers/claude_agent_sdk/_wrapper.py +29 -27
  66. braintrust/wrappers/dspy.py +15 -17
  67. braintrust/wrappers/google_genai/__init__.py +17 -30
  68. braintrust/wrappers/langchain.py +22 -24
  69. braintrust/wrappers/litellm.py +4 -3
  70. braintrust/wrappers/openai.py +15 -15
  71. braintrust/wrappers/pydantic_ai.py +225 -110
  72. braintrust/wrappers/test_agno.py +0 -1
  73. braintrust/wrappers/test_dspy.py +0 -1
  74. braintrust/wrappers/test_google_genai.py +64 -4
  75. braintrust/wrappers/test_litellm.py +0 -1
  76. braintrust/wrappers/test_pydantic_ai_integration.py +819 -22
  77. {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/METADATA +3 -2
  78. braintrust-0.4.1.dist-info/RECORD +121 -0
  79. braintrust-0.3.15.dist-info/RECORD +0 -120
  80. {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/WHEEL +0 -0
  81. {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/entry_points.txt +0 -0
  82. {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,11 @@
1
- from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
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 = Tuple[Optional[Any], ...]
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: Dict[str, Any]) -> Dict[str, Any]:
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: Dict[str, Any], skip_fields: Dict[str, Any]):
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[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
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: Dict[_MergedRowKey, Dict[str, Any]] = {}
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=set((k, v) for k, vs in graph.items() for v in vs))
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: List[List[str]], batch_max_num_items: Optional[int] = None, batch_max_num_bytes: Optional[int] = None
152
- ) -> List[List[List[str]]]:
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 typing import Any, Callable, Dict, List, Optional, Union
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: Optional[str] = None) -> Union[Attachment, str]:
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: Optional[Callable[..., Any]], acreate_fn: Optional[Callable[..., Any]]):
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: Dict[str, Any]) -> Dict[str, Any]:
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: List[Dict[str, Any]]) -> Dict[str, Any]:
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: Optional[List[Any]] = None
281
+ tool_calls: list[Any] | None = None
281
282
  finish_reason = None
282
- metrics: Dict[str, float] = {}
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: Optional[Callable[..., Any]], acreate_fn: Optional[Callable[..., Any]], name: str = "openai.responses.create"):
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: Dict[str, Any]) -> Dict[str, Any]:
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: Dict[str, Any]) -> Dict[str, Any]:
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: List[Any]) -> Dict[str, Any]:
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: Optional[Callable[..., Any]], acreate_fn: Optional[Callable[..., Any]], name: str):
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: Dict[str, Any], span: Span):
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: Dict[str, Any]) -> Dict[str, Any]:
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: Optional[Callable[..., Any]], acreate_fn: Optional[Callable[..., Any]]):
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: Dict[str, Any], span: Span):
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: Optional[Callable[..., Any]], acreate_fn: Optional[Callable[..., Any]]):
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) -> Dict[str, 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: Dict[str, Any]) -> Dict[str, Any]:
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) -> Dict[str, 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
@@ -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: Optional[str] = None,
142
- api_key: Optional[str] = None,
143
- parent: Optional[str] = None,
144
- headers: Optional[Dict[str, str]] = None,
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(tracer_provider,
193
- api_key: Optional[str] = None,
194
- parent: Optional[str] = None,
195
- api_url: Optional[str] = None,
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: Optional[Dict[str, str]] = None,
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: Optional[str] = None,
229
- parent: Optional[str] = None,
230
- api_url: Optional[str] = None,
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 = None,
233
- headers: Optional[Dict[str, str]] = None,
234
- SpanProcessor: Optional[type] = None,
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('braintrust.parent', current_context)
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('braintrust.parent', context=current_context)
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('braintrust.parent', parent_context)
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, 'attributes') and current_span.attributes:
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('braintrust.parent', braintrust_parent, context=ctx)
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('braintrust.parent', parent, context=ctx)
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, 'attributes') or not span.attributes:
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('braintrust.parent')
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: Dict[str, str]) -> Optional[str]:
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, 'get_span_context'):
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, '032x')
578
- span_id_hex = format(span_context.span_id, '016x')
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 == '00000000000000000000000000000000':
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 == '0000000000000000':
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('braintrust.parent', context=ctx)
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(f"parent_from_headers: Invalid braintrust.parent format (empty project_id): {braintrust_parent}")
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(f"parent_from_headers: Invalid braintrust.parent format (empty project_name): {braintrust_parent}")
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(f"parent_from_headers: Invalid braintrust.parent format (empty experiment_id): {braintrust_parent}")
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(
@@ -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['SpanInfo']:
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('braintrust_span')
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 (bt_span and isinstance(current_span, trace.NonRecordingSpan)):
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, '032x')
51
- otel_span_id = format(span_context.span_id, '016x')
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, 'get_span_context'):
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('braintrust_span', span)
72
+ ctx = context.set_value("braintrust_span", span)
84
73
  parent_value = span._get_otel_parent()
85
- ctx = context.set_value('braintrust.parent', parent_value, ctx)
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('braintrust_span', None))
99
+ context.attach(context.set_value("braintrust_span", None))
114
100
 
115
- def get_parent_span_ids(self) -> Optional[ParentSpanIds]:
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, 'get_span_context')
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('BRAINTRUST_OTEL_COMPAT', 'BRAINTRUST_API_KEY'):
50
+ with preserve_env_vars("BRAINTRUST_OTEL_COMPAT", "BRAINTRUST_API_KEY"):
51
51
  # Enable OTEL compatibility mode
52
- os.environ['BRAINTRUST_OTEL_COMPAT'] = 'true'
53
- os.environ['BRAINTRUST_API_KEY'] = 'test-api-key-for-fixture'
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, '032x')
129
- service_b_parent_span_id = format(service_b_exported.parent.span_id, '016x') if service_b_exported.parent else None
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
- "OTEL span should have braintrust.parent attribute"
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__":