Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integrations/langchain-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ build-backend = "setuptools.build_meta"

[tool.setuptools]
package-dir = { "" = "src" }
include-package-data = false

[tool.setuptools.packages.find]
where = ["src"]
exclude = ["tests*"]


[tool.uv.workspace]
Expand Down
54 changes: 44 additions & 10 deletions py/src/braintrust/otel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -191,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,
Expand All @@ -199,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)

Expand All @@ -216,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__(
Expand All @@ -226,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,
):
"""
Expand All @@ -238,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

Expand Down Expand Up @@ -322,6 +352,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()
Expand All @@ -332,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
Expand Down
19 changes: 19 additions & 0 deletions py/src/braintrust/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -207,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:
Expand All @@ -216,10 +220,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)

Expand All @@ -235,9 +241,22 @@ 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")

# 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")
Expand Down
Loading