From 8d310eb95d9e6fcfe6e0ccd83aaf3f7ca5cc5444 Mon Sep 17 00:00:00 2001 From: Colin B Date: Wed, 4 Mar 2026 11:19:22 -0800 Subject: [PATCH 1/4] Exclude test packages from setuptools find configuration in pyproject.toml --- integrations/langchain-py/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/integrations/langchain-py/pyproject.toml b/integrations/langchain-py/pyproject.toml index 579802b7..002a02f9 100644 --- a/integrations/langchain-py/pyproject.toml +++ b/integrations/langchain-py/pyproject.toml @@ -33,6 +33,7 @@ package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] +exclude = ["tests*"] [tool.uv.workspace] From d66be165029ec54e97b6245d154535ff2dbf49fc Mon Sep 17 00:00:00 2001 From: Colin B Date: Wed, 4 Mar 2026 11:32:08 -0800 Subject: [PATCH 2/4] Update pyproject.toml to disable package data inclusion in setuptools configuration --- integrations/langchain-py/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/integrations/langchain-py/pyproject.toml b/integrations/langchain-py/pyproject.toml index 002a02f9..6aa1e850 100644 --- a/integrations/langchain-py/pyproject.toml +++ b/integrations/langchain-py/pyproject.toml @@ -30,6 +30,7 @@ build-backend = "setuptools.build_meta" [tool.setuptools] package-dir = { "" = "src" } +include-package-data = false [tool.setuptools.packages.find] where = ["src"] From 2425499eb53461dbbe5275539d7dca477c2ebf1a Mon Sep 17 00:00:00 2001 From: Colin B Date: Wed, 4 Mar 2026 11:49:39 -0800 Subject: [PATCH 3/4] feat: add _on_ending hook support to span processors - Implemented _on_ending method in AISpanProcessor and BraintrustSpanProcessor to forward pre-end hooks. - Added a helper function _forward_on_ending to handle compatibility with different OpenTelemetry SDK versions. - Updated tests to verify the presence and functionality of the new _on_ending method. --- py/src/braintrust/otel/__init__.py | 23 +++++++++++++++++++++++ py/src/braintrust/test_otel.py | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/py/src/braintrust/otel/__init__.py b/py/src/braintrust/otel/__init__.py index 71648f01..fec3a4cd 100644 --- a/py/src/braintrust/otel/__init__.py +++ b/py/src/braintrust/otel/__init__.py @@ -43,6 +43,18 @@ def get_tracer_provider(): FILTER_PREFIXES = ("gen_ai.", "braintrust.", "llm.", "ai.", "traceloop.") +def _forward_on_ending(processor, span) -> None: + """ + Forward OpenTelemetry's optional _on_ending hook when available. + + Newer OpenTelemetry SDK versions call _on_ending before on_end. Older SDK + versions may not implement the hook on wrapped processors. + """ + on_ending = getattr(processor, "_on_ending", None) + if callable(on_ending): + on_ending(span) + + class AISpanProcessor: """ A span processor that filters spans to only export filtered telemetry. @@ -79,6 +91,13 @@ def on_end(self, span): if self._should_keep_filtered_span(span): self._processor.on_end(span) + def _on_ending(self, span): + """ + Forward pre-end hook for kept spans when the wrapped processor supports it. + """ + if self._should_keep_filtered_span(span): + _forward_on_ending(self._processor, span) + def shutdown(self): """Shutdown the inner processor.""" self._processor.shutdown() @@ -322,6 +341,10 @@ def on_end(self, span): """Forward span end events to the inner processor.""" self._processor.on_end(span) + def _on_ending(self, span): + """Forward pre-end hook when the wrapped processor supports it.""" + _forward_on_ending(self._processor, span) + def shutdown(self): """Shutdown the inner processor.""" self._processor.shutdown() diff --git a/py/src/braintrust/test_otel.py b/py/src/braintrust/test_otel.py index a0aa4fb1..2706cf62 100644 --- a/py/src/braintrust/test_otel.py +++ b/py/src/braintrust/test_otel.py @@ -191,7 +191,9 @@ def test_braintrust_otel_filter_ai_spans_environment_variable(): # Verify it has the expected attributes assert hasattr(filter_processor, "_processor") assert hasattr(filter_processor, "_custom_filter") + assert hasattr(filter_processor, "_on_ending") assert hasattr(filter_processor, "_should_keep_filtered_span") + assert callable(filter_processor._on_ending) assert callable(filter_processor._should_keep_filtered_span) finally: @@ -216,10 +218,12 @@ def test_braintrust_span_processor_class(): # Should have the span processor interface assert hasattr(processor, "on_start") assert hasattr(processor, "on_end") + assert hasattr(processor, "_on_ending") assert hasattr(processor, "shutdown") assert hasattr(processor, "force_flush") assert callable(processor.on_start) assert callable(processor.on_end) + assert callable(processor._on_ending) assert callable(processor.shutdown) assert callable(processor.force_flush) @@ -235,6 +239,7 @@ def test_braintrust_span_processor_class(): # Should have the same interface assert hasattr(processor_with_filtering, "on_start") assert hasattr(processor_with_filtering, "on_end") + assert hasattr(processor_with_filtering, "_on_ending") assert hasattr(processor_with_filtering, "shutdown") assert hasattr(processor_with_filtering, "force_flush") From 33c2031952ed126f7153aa188da1c3b45750ebf7 Mon Sep 17 00:00:00 2001 From: Colin B Date: Wed, 4 Mar 2026 16:28:37 -0800 Subject: [PATCH 4/4] enhance BraintrustSpanProcessor to support custom exporters --- py/src/braintrust/otel/__init__.py | 31 ++++++++++++++++++++---------- py/src/braintrust/test_otel.py | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/py/src/braintrust/otel/__init__.py b/py/src/braintrust/otel/__init__.py index fec3a4cd..fd4e1307 100644 --- a/py/src/braintrust/otel/__init__.py +++ b/py/src/braintrust/otel/__init__.py @@ -210,6 +210,7 @@ def add_braintrust_span_processor( filter_ai_spans: bool = False, custom_filter=None, headers: dict[str, str] | None = None, + exporter=None, ): processor = BraintrustSpanProcessor( api_key=api_key, @@ -218,6 +219,7 @@ def add_braintrust_span_processor( filter_ai_spans=filter_ai_spans, custom_filter=custom_filter, headers=headers, + exporter=exporter, ) tracer_provider.add_span_processor(processor) @@ -235,6 +237,10 @@ class BraintrustSpanProcessor: > processor = BraintrustSpanProcessor(filter_ai_spans=True) > provider.add_span_processor(processor) + + > custom_exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces") + > processor = BraintrustSpanProcessor(exporter=custom_exporter) + > provider.add_span_processor(processor) """ def __init__( @@ -245,6 +251,7 @@ def __init__( filter_ai_spans: bool = False, custom_filter=None, headers: dict[str, str] | None = None, + exporter=None, SpanProcessor: type | None = None, ): """ @@ -257,23 +264,27 @@ def __init__( filter_ai_spans: Whether to enable AI span filtering. Defaults to False. custom_filter: Optional custom filter function for filtering. headers: Additional headers to include in requests. + exporter: Optional pre-configured OpenTelemetry exporter instance. + When provided, api_key/parent/api_url/headers are ignored. SpanProcessor: Optional span processor class (BatchSpanProcessor or SimpleSpanProcessor). Defaults to BatchSpanProcessor. """ - # Create the exporter - # Convert api_url to the full endpoint URL that OtelExporter expects - exporter_url = None - if api_url: - exporter_url = f"{api_url.rstrip('/')}/otel/v1/traces" - - self._exporter = OtelExporter(url=exporter_url, api_key=api_key, parent=parent, headers=headers) - - # Create the processor chain if not OTEL_AVAILABLE: raise ImportError( "OpenTelemetry packages are not installed. " "Install optional OpenTelemetry dependencies with: pip install braintrust[otel]" ) + if exporter is not None: + self._exporter = exporter + else: + # Create the default Braintrust exporter. + # Convert api_url to the full endpoint URL that OtelExporter expects. + exporter_url = None + if api_url: + exporter_url = f"{api_url.rstrip('/')}/otel/v1/traces" + + self._exporter = OtelExporter(url=exporter_url, api_key=api_key, parent=parent, headers=headers) + if SpanProcessor is None: SpanProcessor = BatchSpanProcessor @@ -355,7 +366,7 @@ def force_flush(self, timeout_millis=30000): @property def exporter(self): - """Access to the underlying OtelExporter.""" + """Access to the underlying span exporter.""" return self._exporter @property diff --git a/py/src/braintrust/test_otel.py b/py/src/braintrust/test_otel.py index 2706cf62..5ad72629 100644 --- a/py/src/braintrust/test_otel.py +++ b/py/src/braintrust/test_otel.py @@ -209,6 +209,8 @@ def test_braintrust_span_processor_class(): pytest.skip("OpenTelemetry SDK not fully installed, skipping test") from braintrust.otel import BraintrustSpanProcessor + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter # Test basic processor without filtering with pytest.MonkeyPatch.context() as m: @@ -243,6 +245,18 @@ def test_braintrust_span_processor_class(): assert hasattr(processor_with_filtering, "shutdown") assert hasattr(processor_with_filtering, "force_flush") + # Test processor with custom exporter injection + custom_exporter = InMemorySpanExporter() + with pytest.MonkeyPatch.context() as m: + m.delenv("BRAINTRUST_API_KEY", raising=False) + m.delenv("BRAINTRUST_PARENT", raising=False) + processor_with_custom_exporter = BraintrustSpanProcessor( + exporter=custom_exporter, + SpanProcessor=SimpleSpanProcessor, + ) + + assert processor_with_custom_exporter.exporter is custom_exporter + # Test processor with custom parameters with pytest.MonkeyPatch.context() as m: m.setenv("BRAINTRUST_API_KEY", "test-api-key")