Skip to content
Merged
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
14 changes: 11 additions & 3 deletions py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,12 +822,20 @@ def test_cli(session):
_run_tests(session, DEVSERVER_DIR)


OTEL_VERSIONS = _get_matrix_versions("opentelemetry-sdk")


@nox.session()
def test_otel(session):
@nox.parametrize("version", OTEL_VERSIONS, ids=OTEL_VERSIONS)
def test_otel(session, version):
"""Test OtelExporter with OpenTelemetry installed."""
if version != LATEST and Version(version) < Version("1.28.0") and sys.version_info >= (3, 14):
session.skip("OpenTelemetry <1.28 requires protobuf<5, which does not support Python 3.14")
_install_test_deps(session)
session.install(".[otel]")
_run_tests(session, "braintrust/test_otel.py")
_install_matrix_dep(session, "opentelemetry-api", version)
_install_matrix_dep(session, "opentelemetry-sdk", version)
_install_matrix_dep(session, "opentelemetry-exporter-otlp-proto-http", version)
_run_tests(session, "braintrust/test_otel.py", version=version)


@nox.session()
Expand Down
19 changes: 19 additions & 0 deletions py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,25 @@ latest = "boto3==1.43.98"
latest = "botocore==1.43.98"
"1.34.116" = "botocore==1.34.116"

# OpenTelemetry api/sdk/exporter release in lockstep; keep their keys in sync.
# 1.45.0 moved OTLP HTTP exporter headers onto an internal client, so keep a
# pre-1.45 version covered alongside latest. 1.16.0 is the oldest release that
# imports without pkg_resources; test_otel skips it on Python 3.14 (protobuf<5).
[tool.braintrust.matrix.opentelemetry-api]
latest = "opentelemetry-api==1.45.0"
"1.44.0" = "opentelemetry-api==1.44.0"
"1.16.0" = "opentelemetry-api==1.16.0"

[tool.braintrust.matrix.opentelemetry-sdk]
latest = "opentelemetry-sdk==1.45.0"
"1.44.0" = "opentelemetry-sdk==1.44.0"
"1.16.0" = "opentelemetry-sdk==1.16.0"

[tool.braintrust.matrix.opentelemetry-exporter-otlp-proto-http]
latest = "opentelemetry-exporter-otlp-proto-http==1.45.0"
"1.44.0" = "opentelemetry-exporter-otlp-proto-http==1.44.0"
"1.16.0" = "opentelemetry-exporter-otlp-proto-http==1.16.0"

# ---------------------------------------------------------------------------
# Vendor packages — optional third-party packages the SDK can work without.
# Keys are matrix keys; values are Python import names. The noxfile uses this
Expand Down
4 changes: 3 additions & 1 deletion py/scripts/session-weights.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"test_openai_http2_streaming(latest)": 12,
"test_openrouter(0.6.0)": 8,
"test_openrouter(latest)": 12,
"test_otel": 9,
"test_otel(1.16.0)": 9,
"test_otel(1.44.0)": 9,
"test_otel(latest)": 9,
"test_otel_not_installed": 5,
"test_pydantic_ai_integration(1.10.0)": 34,
"test_pydantic_ai_integration(latest)": 27,
Expand Down
38 changes: 24 additions & 14 deletions py/src/braintrust/otel/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import os
import threading
import warnings
from urllib.parse import urljoin

Expand Down Expand Up @@ -39,6 +40,9 @@ def export(self, *args, **kwargs):
def force_flush(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)

def shutdown(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)

class BatchSpanProcessor:
def __init__(self, *args, **kwargs):
raise ImportError(INSTALL_ERR_MSG)
Expand Down Expand Up @@ -248,7 +252,6 @@ def __init__(
)

self._braintrust_api_key_arg = api_key_arg
self._braintrust_headers_override_authorization = "Authorization" in headers
self._braintrust_has_api_key = bool(api_key and api_key.strip())

exporter_headers = {}
Expand All @@ -261,21 +264,23 @@ def __init__(

self.parent = parent

super().__init__(endpoint=endpoint, headers=exporter_headers, **kwargs)
self._braintrust_exporter_kwargs = {"endpoint": endpoint, "headers": exporter_headers, **kwargs}
self._braintrust_shutdown = False
# Guards lazy re-init against a concurrent shutdown(), which re-init would undo.
self._braintrust_lifecycle_lock = threading.Lock()
super().__init__(**self._braintrust_exporter_kwargs)

def _set_api_key_header(self, api_key: str) -> None:
if not self._braintrust_headers_override_authorization:
authorization = {"Authorization": f"Bearer {api_key}"}
exporter_headers = getattr(self, "_headers", None)
if isinstance(exporter_headers, dict):
exporter_headers.update(authorization)
else:
self._headers = {**dict(exporter_headers or {}), **authorization}

session = getattr(self, "_session", None)
if session is not None:
session.headers.update(authorization)
self._braintrust_has_api_key = True
with self._braintrust_lifecycle_lock:
if self._braintrust_has_api_key:
return
exporter_kwargs = self._braintrust_exporter_kwargs
if "Authorization" not in exporter_kwargs["headers"] and not self._braintrust_shutdown:
# Re-run the upstream constructor instead of patching its private header
# storage, which moved in opentelemetry-exporter-otlp-proto-http 1.45.
headers = {"Authorization": f"Bearer {api_key}", **exporter_kwargs["headers"]}
super().__init__(**{**exporter_kwargs, "headers": headers})
self._braintrust_has_api_key = True

def _ensure_api_key(self) -> None:
if self._braintrust_has_api_key:
Expand All @@ -298,6 +303,11 @@ def force_flush(self, timeout_millis=30000):
self._ensure_api_key()
return super().force_flush(timeout_millis)

def shutdown(self):
with self._braintrust_lifecycle_lock:
self._braintrust_shutdown = True
return super().shutdown()


def add_braintrust_span_processor(
tracer_provider,
Expand Down
Loading
Loading