braintrust 0.4.1__tar.gz → 0.4.2__tar.gz

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 (127) hide show
  1. {braintrust-0.4.1 → braintrust-0.4.2}/PKG-INFO +1 -1
  2. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/db_fields.py +1 -0
  3. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/framework.py +2 -2
  4. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/logger.py +0 -3
  5. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/otel/__init__.py +24 -15
  6. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_framework.py +25 -0
  7. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_logger.py +34 -0
  8. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_otel.py +118 -26
  9. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_util.py +51 -1
  10. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/util.py +24 -3
  11. braintrust-0.4.2/src/braintrust/version.py +4 -0
  12. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/litellm.py +43 -0
  13. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_litellm.py +73 -0
  14. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/PKG-INFO +1 -1
  15. braintrust-0.4.1/src/braintrust/version.py +0 -4
  16. {braintrust-0.4.1 → braintrust-0.4.2}/README.md +0 -0
  17. {braintrust-0.4.1 → braintrust-0.4.2}/setup.cfg +0 -0
  18. {braintrust-0.4.1 → braintrust-0.4.2}/setup.py +0 -0
  19. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/__init__.py +0 -0
  20. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/_generated_types.py +0 -0
  21. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/audit.py +0 -0
  22. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/aws.py +0 -0
  23. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/bt_json.py +0 -0
  24. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/__init__.py +0 -0
  25. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/__main__.py +0 -0
  26. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/eval.py +0 -0
  27. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/__init__.py +0 -0
  28. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/api.py +0 -0
  29. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/bump_versions.py +0 -0
  30. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/logs.py +0 -0
  31. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/redshift.py +0 -0
  32. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/install/run_migrations.py +0 -0
  33. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/cli/push.py +0 -0
  34. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/conftest.py +0 -0
  35. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/context.py +0 -0
  36. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/contrib/__init__.py +0 -0
  37. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/contrib/temporal/__init__.py +0 -0
  38. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/contrib/temporal/test_temporal.py +0 -0
  39. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/__init__.py +0 -0
  40. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/auth.py +0 -0
  41. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/cache.py +0 -0
  42. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/cors.py +0 -0
  43. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/dataset.py +0 -0
  44. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/eval_hooks.py +0 -0
  45. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/schemas.py +0 -0
  46. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/server.py +0 -0
  47. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/test_cached_login.py +0 -0
  48. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/test_lru_cache.py +0 -0
  49. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/devserver/test_server_integration.py +0 -0
  50. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/framework2.py +0 -0
  51. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/functions/__init__.py +0 -0
  52. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/functions/constants.py +0 -0
  53. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/functions/invoke.py +0 -0
  54. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/functions/stream.py +0 -0
  55. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/generated_types.py +0 -0
  56. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/git_fields.py +0 -0
  57. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/gitutil.py +0 -0
  58. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/graph_util.py +0 -0
  59. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/http_headers.py +0 -0
  60. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/id_gen.py +0 -0
  61. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/merge_row_batch.py +0 -0
  62. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/oai.py +0 -0
  63. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/object.py +0 -0
  64. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/otel/context.py +0 -0
  65. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/otel/test_distributed_tracing.py +0 -0
  66. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/otel/test_otel_bt_integration.py +0 -0
  67. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/parameters.py +0 -0
  68. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt.py +0 -0
  69. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/__init__.py +0 -0
  70. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/disk_cache.py +0 -0
  71. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/lru_cache.py +0 -0
  72. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/prompt_cache.py +0 -0
  73. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/test_disk_cache.py +0 -0
  74. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/test_lru_cache.py +0 -0
  75. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/prompt_cache/test_prompt_cache.py +0 -0
  76. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/py.typed +0 -0
  77. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/queue.py +0 -0
  78. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/resource_manager.py +0 -0
  79. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/score.py +0 -0
  80. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/serializable_data_class.py +0 -0
  81. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/span_identifier_v1.py +0 -0
  82. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/span_identifier_v2.py +0 -0
  83. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/span_identifier_v3.py +0 -0
  84. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/span_identifier_v4.py +0 -0
  85. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/span_types.py +0 -0
  86. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_bt_json.py +0 -0
  87. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_framework2.py +0 -0
  88. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_helpers.py +0 -0
  89. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_id_gen.py +0 -0
  90. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_queue.py +0 -0
  91. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_score.py +0 -0
  92. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_serializable_data_class.py +0 -0
  93. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_span_components.py +0 -0
  94. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/test_version.py +0 -0
  95. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/__init__.py +0 -0
  96. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/_anthropic_utils.py +0 -0
  97. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/__init__.py +0 -0
  98. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/agent.py +0 -0
  99. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/function_call.py +0 -0
  100. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/model.py +0 -0
  101. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/team.py +0 -0
  102. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/agno/utils.py +0 -0
  103. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/anthropic.py +0 -0
  104. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/claude_agent_sdk/__init__.py +0 -0
  105. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/claude_agent_sdk/_wrapper.py +0 -0
  106. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/claude_agent_sdk/test_wrapper.py +0 -0
  107. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/dspy.py +0 -0
  108. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/google_genai/__init__.py +0 -0
  109. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/langchain.py +0 -0
  110. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/openai.py +0 -0
  111. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/pydantic_ai.py +0 -0
  112. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_agno.py +0 -0
  113. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_anthropic.py +0 -0
  114. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_dspy.py +0 -0
  115. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_google_genai.py +0 -0
  116. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_oai_attachments.py +0 -0
  117. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_openai.py +0 -0
  118. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_openrouter.py +0 -0
  119. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_pydantic_ai_integration.py +0 -0
  120. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_pydantic_ai_wrap_openai.py +0 -0
  121. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/wrappers/test_utils.py +0 -0
  122. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust/xact_ids.py +0 -0
  123. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/SOURCES.txt +0 -0
  124. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/dependency_links.txt +0 -0
  125. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/entry_points.txt +0 -0
  126. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/requires.txt +0 -0
  127. {braintrust-0.4.1 → braintrust-0.4.2}/src/braintrust.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: braintrust
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: SDK for integrating Braintrust
5
5
  Home-page: https://www.braintrust.dev
6
6
  Author: Braintrust
@@ -5,6 +5,7 @@ ID_FIELD = "id"
5
5
 
6
6
  IS_MERGE_FIELD = "_is_merge"
7
7
  MERGE_PATHS_FIELD = "_merge_paths"
8
+ ARRAY_DELETE_FIELD = "_array_delete"
8
9
 
9
10
  AUDIT_SOURCE_FIELD = "_audit_source"
10
11
  AUDIT_METADATA_FIELD = "_audit_metadata"
@@ -1559,9 +1559,9 @@ def build_local_summary(
1559
1559
  scores_by_name = defaultdict(lambda: (0, 0))
1560
1560
  for result in results:
1561
1561
  for name, score in result.scores.items():
1562
- curr = scores_by_name[name]
1563
- if curr is None:
1562
+ if score is None:
1564
1563
  continue
1564
+ curr = scores_by_name[name]
1565
1565
  scores_by_name[name] = (curr[0] + score, curr[1] + 1)
1566
1566
  longest_score_name = max(len(name) for name in scores_by_name) if scores_by_name else 0
1567
1567
  avg_scores = {
@@ -3856,9 +3856,6 @@ class SpanImpl(Span):
3856
3856
  if serializable_partial_record.get("metrics", {}).get("end") is not None:
3857
3857
  self._logged_end_time = serializable_partial_record["metrics"]["end"]
3858
3858
 
3859
- if len(serializable_partial_record.get("tags", [])) > 0 and self.span_parents:
3860
- raise Exception("Tags can only be logged to the root span")
3861
-
3862
3859
  def compute_record() -> dict[str, Any]:
3863
3860
  exporter = _get_exporter()
3864
3861
  return dict(
@@ -90,18 +90,13 @@ class AISpanProcessor:
90
90
  def _should_keep_filtered_span(self, span):
91
91
  """
92
92
  Keep spans if:
93
- 1. It's a root span (no parent)
94
- 2. Custom filter returns True/False (if provided)
95
- 3. Span name starts with 'gen_ai.', 'braintrust.', 'llm.', 'ai.', or 'traceloop.'
96
- 4. Any attribute name starts with those prefixes
93
+ 1. Custom filter returns True/False (if provided)
94
+ 2. Span name starts with 'gen_ai.', 'braintrust.', 'llm.', 'ai.', or 'traceloop.'
95
+ 3. Any attribute name starts with those prefixes
97
96
  """
98
97
  if not span:
99
98
  return False
100
99
 
101
- # Braintrust requires root spans, so always keep them
102
- if span.parent is None:
103
- return True
104
-
105
100
  # Apply custom filter if provided
106
101
  if self._custom_filter:
107
102
  custom_result = self._custom_filter(span)
@@ -384,6 +379,9 @@ def _get_braintrust_parent(object_type, object_id: str | None = None, compute_ar
384
379
 
385
380
  return None
386
381
 
382
+ def is_root_span(span) -> bool:
383
+ """Returns True if the span is a root span (no parent span)."""
384
+ return getattr(span, "parent", None) is None
387
385
 
388
386
  def context_from_span_export(export_str: str):
389
387
  """
@@ -522,15 +520,17 @@ def add_span_parent_to_baggage(span, ctx=None):
522
520
  return add_parent_to_baggage(parent_value, ctx=ctx)
523
521
 
524
522
 
525
- def parent_from_headers(headers: dict[str, str]) -> str | None:
523
+ def parent_from_headers(headers: dict[str, str], propagator=None) -> str | None:
526
524
  """
527
- Extract a Braintrust-compatible parent string from W3C Trace Context headers.
525
+ Extract a Braintrust-compatible parent string from trace context headers.
528
526
 
529
- This converts OTEL trace context headers (traceparent/baggage) into a format
530
- that can be passed as the 'parent' parameter to Braintrust's start_span() method.
527
+ This converts OTEL trace context headers into a format that can be passed
528
+ as the 'parent' parameter to Braintrust's start_span() method.
531
529
 
532
530
  Args:
533
- headers: Dictionary with 'traceparent' and optionally 'baggage' keys
531
+ headers: Dictionary with trace context headers (e.g., 'traceparent'/'baggage' for W3C)
532
+ propagator: Optional custom TextMapPropagator. If not provided, uses the
533
+ globally registered propagator (W3C TraceContext by default).
534
534
 
535
535
  Returns:
536
536
  Braintrust V4 export string that can be used as parent parameter,
@@ -545,6 +545,12 @@ def parent_from_headers(headers: dict[str, str]) -> str | None:
545
545
  >>> parent = parent_from_headers(headers)
546
546
  >>> with project.start_span(name="service_c", parent=parent) as span:
547
547
  >>> span.log(input="BT span as child of OTEL parent")
548
+
549
+ >>> # Using a custom propagator (e.g., B3 format)
550
+ >>> from opentelemetry.propagators.b3 import B3MultiFormat
551
+ >>> propagator = B3MultiFormat()
552
+ >>> headers = {'X-B3-TraceId': '...', 'X-B3-SpanId': '...', 'baggage': '...'}
553
+ >>> parent = parent_from_headers(headers, propagator=propagator)
548
554
  """
549
555
  if not OTEL_AVAILABLE:
550
556
  raise ImportError(INSTALL_ERR_MSG)
@@ -553,8 +559,11 @@ def parent_from_headers(headers: dict[str, str]) -> str | None:
553
559
  from opentelemetry import baggage, trace
554
560
  from opentelemetry.propagate import extract
555
561
 
556
- # Extract context from headers using W3C Trace Context propagator
557
- ctx = extract(headers)
562
+ # Extract context from headers using provided propagator or global propagator
563
+ if propagator is not None:
564
+ ctx = propagator.extract(headers)
565
+ else:
566
+ ctx = extract(headers)
558
567
 
559
568
  # Get span from context
560
569
  span = trace.get_current_span(ctx)
@@ -343,6 +343,31 @@ async def test_eval_no_send_logs_true(with_memory_logger, simple_scorer):
343
343
  assert len(logs) == 0
344
344
 
345
345
 
346
+ @pytest.mark.asyncio
347
+ async def test_eval_no_send_logs_with_none_score(with_memory_logger):
348
+ """Test that scorers returning None don't crash local mode."""
349
+
350
+ def sometimes_none_scorer(input, output, expected):
351
+ # Return None for first input, score for second
352
+ if input == "hello":
353
+ return {"name": "conditional", "score": None}
354
+ return {"name": "conditional", "score": 1.0}
355
+
356
+ result = await Eval(
357
+ "test-none-score",
358
+ data=[
359
+ {"input": "hello", "expected": "hello world"},
360
+ {"input": "test", "expected": "test world"},
361
+ ],
362
+ task=lambda input_val: input_val + " world",
363
+ scores=[sometimes_none_scorer],
364
+ no_send_logs=True,
365
+ )
366
+
367
+ # Should not crash and should calculate average from non-None scores only
368
+ assert result.summary.scores["conditional"].score == 1.0 # Only the second score counts
369
+
370
+
346
371
  @pytest.mark.asyncio
347
372
  async def test_hooks_tags_append(with_memory_logger, with_simulate_login, simple_scorer):
348
373
  """Test that hooks.tags can be appended to and logged."""
@@ -849,6 +849,40 @@ def test_span_link_with_unresolved_experiment(with_simulate_login, with_memory_l
849
849
  assert link == "https://www.braintrust.dev/error-generating-link?msg=resolve-experiment-id"
850
850
 
851
851
 
852
+ def test_experiment_span_link_uses_env_vars_when_logged_out(with_memory_logger):
853
+ """Verify EXPERIMENT spans use BRAINTRUST_ORG_NAME env var when not logged in."""
854
+ simulate_logout()
855
+ assert_logged_out()
856
+
857
+ keys = ["BRAINTRUST_APP_URL", "BRAINTRUST_ORG_NAME"]
858
+ originals = {k: os.environ.get(k) for k in keys}
859
+ try:
860
+ os.environ["BRAINTRUST_APP_URL"] = "https://test-app.example.com"
861
+ os.environ["BRAINTRUST_ORG_NAME"] = "env-org-name"
862
+
863
+ experiment = braintrust.init(
864
+ project="test-project",
865
+ experiment="test-experiment",
866
+ )
867
+
868
+ # Create span with resolved experiment ID
869
+ span = experiment.start_span(name="test-span")
870
+ span.parent_object_id = LazyValue(lambda: "test-exp-id", use_mutex=False)
871
+ span.end()
872
+
873
+ link = span.link()
874
+
875
+ # Should use env var org name and app url
876
+ assert "env-org-name" in link
877
+ assert "test-app.example.com" in link
878
+ assert "test-exp-id" in link
879
+ finally:
880
+ for k, v in originals.items():
881
+ os.environ.pop(k, None)
882
+ if v:
883
+ os.environ[k] = v
884
+
885
+
852
886
  def test_permalink_with_valid_span_logged_in(with_simulate_login, with_memory_logger):
853
887
  logger = init_logger(
854
888
  project="test-project",
@@ -294,13 +294,12 @@ class TestSpanFiltering:
294
294
  self.provider.shutdown()
295
295
  self.memory_exporter.clear()
296
296
 
297
- def test_keeps_root_spans(self):
297
+ def test_filters_out_root_spans(self):
298
298
  with self.tracer.start_as_current_span("root_operation"):
299
299
  pass
300
300
 
301
301
  spans = self.memory_exporter.get_finished_spans()
302
- assert len(spans) == 1
303
- assert spans[0].name == "root_operation"
302
+ assert len(spans) == 0
304
303
 
305
304
  def test_keeps_gen_ai_spans(self):
306
305
  with self.tracer.start_as_current_span("root"):
@@ -312,7 +311,7 @@ class TestSpanFiltering:
312
311
  spans = self.memory_exporter.get_finished_spans()
313
312
  span_names = [span.name for span in spans]
314
313
 
315
- assert "root" in span_names
314
+ assert "root" not in span_names
316
315
  assert "gen_ai.completion" in span_names
317
316
  assert "regular_operation" not in span_names
318
317
 
@@ -329,35 +328,37 @@ class TestSpanFiltering:
329
328
  assert "braintrust.eval" in span_names
330
329
  assert "database_query" not in span_names
331
330
 
332
- def test_keeps_llm_spans(self):
331
+ def test_keeps_traceloop_spans(self):
333
332
  with self.tracer.start_as_current_span("root"):
334
- with self.tracer.start_as_current_span("llm.generate"):
333
+ with self.tracer.start_as_current_span("traceloop.agent"):
334
+ pass
335
+ with self.tracer.start_as_current_span("traceloop.workflow.step"):
335
336
  pass
336
337
 
337
338
  spans = self.memory_exporter.get_finished_spans()
338
339
  span_names = [span.name for span in spans]
339
- assert "llm.generate" in span_names
340
+ assert "root" not in span_names
341
+ assert "traceloop.agent" in span_names
342
+ assert "traceloop.workflow.step" in span_names
340
343
 
341
- def test_keeps_ai_spans(self):
344
+ def test_keeps_llm_spans(self):
342
345
  with self.tracer.start_as_current_span("root"):
343
- with self.tracer.start_as_current_span("ai.model_call"):
346
+ with self.tracer.start_as_current_span("llm.generate"):
344
347
  pass
345
348
 
346
349
  spans = self.memory_exporter.get_finished_spans()
347
350
  span_names = [span.name for span in spans]
348
- assert "ai.model_call" in span_names
351
+ assert "llm.generate" in span_names
349
352
 
350
- def test_keeps_traceloop_spans(self):
353
+ def test_keeps_ai_spans(self):
351
354
  with self.tracer.start_as_current_span("root"):
352
- with self.tracer.start_as_current_span("traceloop.agent"):
353
- pass
354
- with self.tracer.start_as_current_span("traceloop.workflow.step"):
355
+ with self.tracer.start_as_current_span("ai.model_call"):
355
356
  pass
356
357
 
357
358
  spans = self.memory_exporter.get_finished_spans()
358
359
  span_names = [span.name for span in spans]
359
- assert "traceloop.agent" in span_names
360
- assert "traceloop.workflow.step" in span_names
360
+ assert "root" not in span_names
361
+ assert "ai.model_call" in span_names
361
362
 
362
363
  def test_keeps_spans_with_llm_attributes(self):
363
364
  with self.tracer.start_as_current_span("root"):
@@ -374,7 +375,7 @@ class TestSpanFiltering:
374
375
  spans = self.memory_exporter.get_finished_spans()
375
376
  span_names = [span.name for span in spans]
376
377
 
377
- assert "root" in span_names
378
+ assert "root" not in span_names
378
379
  assert "some_operation" in span_names # has gen_ai.model attribute
379
380
  assert "another_operation" in span_names # has llm.tokens attribute
380
381
  assert "traceloop_operation" in span_names # has traceloop.agent_id attribute
@@ -390,10 +391,7 @@ class TestSpanFiltering:
390
391
  pass
391
392
 
392
393
  spans = self.memory_exporter.get_finished_spans()
393
-
394
- # Only root should be kept
395
- assert len(spans) == 1
396
- assert spans[0].name == "root"
394
+ assert len(spans) == 0
397
395
 
398
396
  def test_custom_filter_keeps_spans(self):
399
397
  def custom_filter(span):
@@ -422,9 +420,9 @@ class TestSpanFiltering:
422
420
  spans = memory_exporter.get_finished_spans()
423
421
  span_names = [span.name for span in spans]
424
422
 
425
- assert "root" in span_names
426
423
  assert "custom_keep" in span_names # kept by custom filter
427
424
  assert "regular_operation" not in span_names # dropped by default logic
425
+ assert "root" not in span_names
428
426
 
429
427
  def test_custom_filter_drops_spans(self):
430
428
  def custom_filter(span):
@@ -453,9 +451,9 @@ class TestSpanFiltering:
453
451
  spans = memory_exporter.get_finished_spans()
454
452
  span_names = [span.name for span in spans]
455
453
 
456
- assert "root" in span_names
457
454
  assert "gen_ai.drop_this" not in span_names # dropped by custom filter
458
455
  assert "gen_ai.keep_this" in span_names # kept by default LLM logic
456
+ assert "root" not in span_names
459
457
 
460
458
  def test_custom_filter_none_uses_default_logic(self):
461
459
  def custom_filter(span):
@@ -482,7 +480,7 @@ class TestSpanFiltering:
482
480
  spans = memory_exporter.get_finished_spans()
483
481
  span_names = [span.name for span in spans]
484
482
 
485
- assert "root" in span_names
483
+ assert "root" not in span_names
486
484
  assert "gen_ai.completion" in span_names # kept by default LLM logic
487
485
  assert "regular_operation" not in span_names # dropped by default logic
488
486
 
@@ -546,11 +544,32 @@ class TestSpanFiltering:
546
544
  filtered_spans = filtered_spans_exporter.get_finished_spans()
547
545
  filtered_span_names = [span.name for span in filtered_spans]
548
546
 
549
- assert len(filtered_spans) == 3
550
- assert "user_request" in filtered_span_names # root span
547
+ assert len(filtered_spans) == 2
548
+ assert "user_request" not in filtered_span_names # root span
551
549
  assert "gen_ai.completion" in filtered_span_names # LLM name
552
550
  assert "response_formatting" in filtered_span_names # LLM attribute
553
551
 
552
+ def test_custom_filter_is_root_span(self):
553
+ from braintrust.otel import AISpanProcessor, is_root_span
554
+ from opentelemetry.sdk.trace import TracerProvider
555
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
556
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
557
+
558
+ memory_exporter = InMemorySpanExporter()
559
+ processor = AISpanProcessor(SimpleSpanProcessor(memory_exporter), custom_filter=is_root_span)
560
+ provider = TracerProvider()
561
+ provider.add_span_processor(processor)
562
+ tracer = provider.get_tracer("test-braintrust-root-filter")
563
+
564
+ with tracer.start_as_current_span("root_span"):
565
+ with tracer.start_as_current_span("child_span"):
566
+ pass
567
+
568
+ provider.shutdown()
569
+ spans = memory_exporter.get_finished_spans()
570
+ names = [span.name for span in spans]
571
+ assert "root_span" in names
572
+ assert "child_span" not in names
554
573
 
555
574
  def test_parent_from_headers_invalid_inputs():
556
575
  """Test parent_from_headers with various invalid inputs."""
@@ -716,3 +735,76 @@ def test_add_span_parent_to_baggage():
716
735
  # Test with None span (should return None and warn)
717
736
  token = add_span_parent_to_baggage(None)
718
737
  assert token is None
738
+
739
+
740
+ def test_parent_from_headers_with_custom_propagator():
741
+ """Test parent_from_headers with a custom propagator."""
742
+ if not _check_otel_installed():
743
+ pytest.skip("OpenTelemetry SDK not fully installed, skipping test")
744
+
745
+ from braintrust.otel import parent_from_headers
746
+ from opentelemetry import baggage as otel_baggage
747
+ from opentelemetry import context as otel_context
748
+ from opentelemetry import trace
749
+ from opentelemetry.propagators.textmap import CarrierT, Getter, TextMapPropagator, default_getter
750
+ from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
751
+
752
+ class CustomHeaderPropagator(TextMapPropagator):
753
+ """Custom propagator that reads trace context from X-Custom-* headers."""
754
+
755
+ def extract(
756
+ self,
757
+ carrier: CarrierT,
758
+ context: otel_context.Context | None = None,
759
+ getter: Getter = default_getter,
760
+ ) -> otel_context.Context:
761
+ if context is None:
762
+ context = otel_context.get_current()
763
+
764
+ trace_id = getter.get(carrier, "X-Custom-Trace-Id")
765
+ span_id = getter.get(carrier, "X-Custom-Span-Id")
766
+
767
+ if trace_id and span_id:
768
+ trace_id_list = trace_id if isinstance(trace_id, list) else [trace_id]
769
+ span_id_list = span_id if isinstance(span_id, list) else [span_id]
770
+
771
+ span_context = SpanContext(
772
+ trace_id=int(trace_id_list[0], 16),
773
+ span_id=int(span_id_list[0], 16),
774
+ is_remote=True,
775
+ trace_flags=TraceFlags.SAMPLED,
776
+ )
777
+ span = NonRecordingSpan(span_context)
778
+ context = trace.set_span_in_context(span, context)
779
+
780
+ # Also extract baggage from standard baggage header
781
+ baggage_header = getter.get(carrier, "baggage")
782
+ if baggage_header:
783
+ baggage_list = baggage_header if isinstance(baggage_header, list) else [baggage_header]
784
+ for item in baggage_list[0].split(","):
785
+ if "=" in item:
786
+ key, value = item.split("=", 1)
787
+ context = otel_baggage.set_baggage(key.strip(), value.strip(), context)
788
+
789
+ return context
790
+
791
+ def inject(self, carrier, context=None, setter=None):
792
+ pass # Not needed for this test
793
+
794
+ @property
795
+ def fields(self):
796
+ return {"X-Custom-Trace-Id", "X-Custom-Span-Id", "baggage"}
797
+
798
+ propagator = CustomHeaderPropagator()
799
+
800
+ # Custom header format
801
+ headers = {
802
+ "X-Custom-Trace-Id": "4bf92f3577b34da6a3ce929d0e0e4736",
803
+ "X-Custom-Span-Id": "00f067aa0ba902b7",
804
+ "baggage": "braintrust.parent=project_name:test-project",
805
+ }
806
+
807
+ result = parent_from_headers(headers, propagator=propagator)
808
+ assert result is not None
809
+ assert isinstance(result, str)
810
+ assert len(result) > 0
@@ -3,7 +3,7 @@ from typing import List
3
3
 
4
4
  import pytest
5
5
 
6
- from .util import LazyValue, mask_api_key
6
+ from .util import LazyValue, mask_api_key, merge_dicts_with_paths
7
7
 
8
8
 
9
9
  class TestLazyValue(unittest.TestCase):
@@ -160,3 +160,53 @@ def test_mask_api_key():
160
160
  assert mask_api_key("12345") == "12*45"
161
161
  for i in ["", "1", "12", "123", "1234"]:
162
162
  assert mask_api_key(i) == "*" * len(i)
163
+
164
+
165
+ class TestTagsSetUnionMerge:
166
+ def test_tags_arrays_are_merged_as_sets_by_default(self):
167
+ a = {"tags": ["a", "b"]}
168
+ b = {"tags": ["b", "c"]}
169
+ merge_dicts_with_paths(a, b, (), set())
170
+ assert set(a["tags"]) == {"a", "b", "c"}
171
+
172
+ def test_tags_merge_deduplicates_values(self):
173
+ a = {"tags": ["a", "b", "c"]}
174
+ b = {"tags": ["a", "b", "c", "d"]}
175
+ merge_dicts_with_paths(a, b, (), set())
176
+ assert set(a["tags"]) == {"a", "b", "c", "d"}
177
+
178
+ def test_tags_merge_works_when_merge_into_has_no_tags(self):
179
+ a = {"other": "data"}
180
+ b = {"tags": ["a", "b"]}
181
+ merge_dicts_with_paths(a, b, (), set())
182
+ assert set(a["tags"]) == {"a", "b"}
183
+
184
+ def test_tags_merge_works_when_merge_from_has_no_tags(self):
185
+ a = {"tags": ["a", "b"]}
186
+ b = {"other": "data"}
187
+ merge_dicts_with_paths(a, b, (), set())
188
+ assert set(a["tags"]) == {"a", "b"}
189
+
190
+ def test_tags_are_replaced_when_included_in_merge_paths(self):
191
+ a = {"tags": ["a", "b"]}
192
+ b = {"tags": ["c", "d"]}
193
+ merge_dicts_with_paths(a, b, (), {("tags",)})
194
+ assert a["tags"] == ["c", "d"]
195
+
196
+ def test_empty_tags_array_clears_tags_when_in_merge_paths(self):
197
+ a = {"tags": ["a", "b"]}
198
+ b = {"tags": []}
199
+ merge_dicts_with_paths(a, b, (), {("tags",)})
200
+ assert a["tags"] == []
201
+
202
+ def test_none_tags_replaces_tags(self):
203
+ a = {"tags": ["a", "b"]}
204
+ b = {"tags": None}
205
+ merge_dicts_with_paths(a, b, (), set())
206
+ assert a["tags"] is None
207
+
208
+ def test_set_union_only_applies_to_top_level_tags_field(self):
209
+ a = {"metadata": {"tags": ["a", "b"]}}
210
+ b = {"metadata": {"tags": ["c", "d"]}}
211
+ merge_dicts_with_paths(a, b, (), set())
212
+ assert a["metadata"]["tags"] == ["c", "d"]
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import json
2
3
  import sys
3
4
  import threading
4
5
  import urllib.parse
@@ -29,11 +30,16 @@ def coalesce(*args):
29
30
  return None
30
31
 
31
32
 
33
+ # Fields that automatically use set-union merge semantics (unless in merge_paths).
34
+ _SET_UNION_FIELDS = frozenset(["tags"])
35
+
36
+
32
37
  def merge_dicts_with_paths(
33
- merge_into: dict[str, Any], merge_from: Mapping[str, Any], path: tuple[str, ...], merge_paths: set[tuple[str]]
38
+ merge_into: dict[str, Any], merge_from: Mapping[str, Any], path: tuple[str, ...], merge_paths: set[tuple[str, ...]]
34
39
  ) -> dict[str, Any]:
35
40
  """Merges merge_from into merge_into, destructively updating merge_into. Does not merge any further than
36
- merge_paths."""
41
+ merge_paths. For fields in _SET_UNION_FIELDS (like "tags"), arrays are merged as sets (union)
42
+ unless the field is explicitly listed in merge_paths (opt-out to replacement)."""
37
43
 
38
44
  if not isinstance(merge_into, dict):
39
45
  raise ValueError("merge_into must be a dictionary")
@@ -43,7 +49,22 @@ def merge_dicts_with_paths(
43
49
  for k, merge_from_v in merge_from.items():
44
50
  full_path = path + (k,)
45
51
  merge_into_v = merge_into.get(k)
46
- if isinstance(merge_into_v, dict) and isinstance(merge_from_v, dict) and full_path not in merge_paths:
52
+
53
+ # Check if this field should use set-union merge (e.g., "tags" at top level)
54
+ is_set_union_field = len(path) == 0 and k in _SET_UNION_FIELDS and full_path not in merge_paths
55
+
56
+ if is_set_union_field and isinstance(merge_into_v, list) and isinstance(merge_from_v, list):
57
+ # Set-union merge: combine arrays, deduplicate using JSON for objects
58
+ seen: set[str] = set()
59
+ combined = []
60
+ for item in merge_into_v + list(merge_from_v):
61
+ # Use JSON serialization for consistent object comparison
62
+ item_key = json.dumps(item, sort_keys=True) if isinstance(item, (dict, list)) else str(item)
63
+ if item_key not in seen:
64
+ seen.add(item_key)
65
+ combined.append(item)
66
+ merge_into[k] = combined
67
+ elif isinstance(merge_into_v, dict) and isinstance(merge_from_v, dict) and full_path not in merge_paths:
47
68
  merge_dicts_with_paths(merge_into_v, merge_from_v, full_path, merge_paths)
48
69
  else:
49
70
  merge_into[k] = merge_from_v
@@ -0,0 +1,4 @@
1
+ VERSION = "0.4.2"
2
+
3
+ # this will be templated during the build
4
+ GIT_COMMIT = "3ca420e53e77d4665b91ccc7631c95dc97ce566d"
@@ -657,9 +657,52 @@ def patch_litellm():
657
657
  import litellm
658
658
 
659
659
  if not hasattr(litellm, "_braintrust_wrapped"):
660
+ # Store originals for unpatch_litellm()
661
+ litellm._braintrust_original_completion = litellm.completion
662
+ litellm._braintrust_original_acompletion = litellm.acompletion
663
+ litellm._braintrust_original_responses = litellm.responses
664
+ litellm._braintrust_original_aresponses = litellm.aresponses
665
+
660
666
  wrapped = wrap_litellm(litellm)
661
667
  litellm.completion = wrapped.completion
662
668
  litellm.acompletion = wrapped.acompletion
669
+ litellm.responses = wrapped.responses
670
+ litellm.aresponses = wrapped.aresponses
663
671
  litellm._braintrust_wrapped = True
664
672
  except ImportError:
665
673
  pass # litellm not available
674
+
675
+
676
+ def unpatch_litellm():
677
+ """
678
+ Restore LiteLLM to its original state, removing Braintrust tracing.
679
+
680
+ This undoes the patching done by patch_litellm(), restoring the original
681
+ completion, acompletion, responses, and aresponses functions.
682
+
683
+ Example:
684
+ ```python
685
+ import braintrust
686
+ braintrust.patch_litellm()
687
+
688
+ # ... use litellm with tracing ...
689
+
690
+ braintrust.unpatch_litellm() # restore original behavior
691
+ ```
692
+ """
693
+ try:
694
+ import litellm
695
+
696
+ if hasattr(litellm, "_braintrust_wrapped"):
697
+ litellm.completion = litellm._braintrust_original_completion
698
+ litellm.acompletion = litellm._braintrust_original_acompletion
699
+ litellm.responses = litellm._braintrust_original_responses
700
+ litellm.aresponses = litellm._braintrust_original_aresponses
701
+
702
+ delattr(litellm, "_braintrust_wrapped")
703
+ delattr(litellm, "_braintrust_original_completion")
704
+ delattr(litellm, "_braintrust_original_acompletion")
705
+ delattr(litellm, "_braintrust_original_responses")
706
+ delattr(litellm, "_braintrust_original_aresponses")
707
+ except ImportError:
708
+ pass # litellm not available
@@ -702,3 +702,76 @@ async def test_litellm_async_streaming_with_break(memory_logger):
702
702
  span = spans[0]
703
703
  metrics = span["metrics"]
704
704
  assert metrics["time_to_first_token"] >= 0
705
+
706
+
707
+ @pytest.mark.vcr
708
+ def test_patch_litellm_responses(memory_logger):
709
+ """Test that patch_litellm() patches responses."""
710
+ from braintrust.wrappers.litellm import patch_litellm, unpatch_litellm
711
+
712
+ assert not memory_logger.pop()
713
+
714
+ patch_litellm()
715
+ try:
716
+ start = time.time()
717
+ # Call litellm.responses directly (not wrapped_litellm.responses)
718
+ response = litellm.responses(
719
+ model=TEST_MODEL,
720
+ input=TEST_PROMPT,
721
+ instructions="Just the number please",
722
+ )
723
+ end = time.time()
724
+
725
+ assert response
726
+ assert response.output
727
+ assert len(response.output) > 0
728
+ content = response.output[0].content[0].text
729
+ assert "24" in content or "twenty-four" in content.lower()
730
+
731
+ # Verify span was created
732
+ spans = memory_logger.pop()
733
+ assert len(spans) == 1
734
+ span = spans[0]
735
+ assert_metrics_are_valid(span["metrics"], start, end)
736
+ assert span["metadata"]["model"] == TEST_MODEL
737
+ assert span["metadata"]["provider"] == "litellm"
738
+ assert TEST_PROMPT in str(span["input"])
739
+ finally:
740
+ unpatch_litellm()
741
+
742
+
743
+ @pytest.mark.vcr
744
+ @pytest.mark.asyncio
745
+ async def test_patch_litellm_aresponses(memory_logger):
746
+ """Test that patch_litellm() patches aresponses."""
747
+ from braintrust.wrappers.litellm import patch_litellm, unpatch_litellm
748
+
749
+ assert not memory_logger.pop()
750
+
751
+ patch_litellm()
752
+ try:
753
+ start = time.time()
754
+ # Call litellm.aresponses directly (not wrapped_litellm.aresponses)
755
+ response = await litellm.aresponses(
756
+ model=TEST_MODEL,
757
+ input=TEST_PROMPT,
758
+ instructions="Just the number please",
759
+ )
760
+ end = time.time()
761
+
762
+ assert response
763
+ assert response.output
764
+ assert len(response.output) > 0
765
+ content = response.output[0].content[0].text
766
+ assert "24" in content or "twenty-four" in content.lower()
767
+
768
+ # Verify span was created
769
+ spans = memory_logger.pop()
770
+ assert len(spans) == 1
771
+ span = spans[0]
772
+ assert_metrics_are_valid(span["metrics"], start, end)
773
+ assert span["metadata"]["model"] == TEST_MODEL
774
+ assert span["metadata"]["provider"] == "litellm"
775
+ assert TEST_PROMPT in str(span["input"])
776
+ finally:
777
+ unpatch_litellm()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: braintrust
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: SDK for integrating Braintrust
5
5
  Home-page: https://www.braintrust.dev
6
6
  Author: Braintrust
@@ -1,4 +0,0 @@
1
- VERSION = "0.4.1"
2
-
3
- # this will be templated during the build
4
- GIT_COMMIT = "d9c624ea93ca6bf62c2412abce1b3a2ef1a2be67"
File without changes
File without changes
File without changes