Skip to content

Commit 5bdff9e

Browse files
committed
support span customizers for otel mode
1 parent 43b2020 commit 5bdff9e

6 files changed

Lines changed: 665 additions & 64 deletions

File tree

‎py/src/braintrust/auto.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,10 @@ def auto_instrument(
103103
livekit_agents: Enable LiveKit Agents instrumentation (default: True)
104104
pipecat: Enable Pipecat AI instrumentation (default: True)
105105
typesafe: Enable TypeSafe instrumentation (default: True)
106-
span_customizers: Ordered synchronous export customizers for all native SDK
107-
spans, including manual spans; dataset rows and feedback are excluded.
106+
span_customizers: Ordered synchronous export customizers for native SDK and
107+
Braintrust OTel spans, including manual spans; dataset rows and feedback are excluded.
108108
Copies and replaces the global list when provided; None leaves existing
109109
configuration unchanged. Pass [] to disable.
110-
With BRAINTRUST_OTEL_COMPAT enabled, non-empty customizers log an error
111-
and leave the existing configuration unchanged; instrumentation continues.
112110
113111
Returns:
114112
Dict mapping integration name to whether it was successfully instrumented.

‎py/src/braintrust/otel/__init__.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from urllib.parse import urljoin
99

1010
from braintrust.env import BraintrustEnv
11+
from braintrust.span_customizer import _get_span_customizers
1112
from braintrust.span_origin import SpanOriginEnvironment, detect_environment, merge_span_origin_context
1213

1314

@@ -303,6 +304,21 @@ def initialize(self) -> None:
303304

304305
def export(self, spans):
305306
self._ensure_api_key()
307+
customizers = _get_span_customizers()
308+
if customizers:
309+
from .span_customizer import customize_otel_span
310+
311+
# Processor exports are already enriched after AI filtering. Direct
312+
# exporter users receive the same provenance before customization.
313+
spans = [
314+
customize_otel_span(
315+
span
316+
if isinstance(span, _SpanWithAttributes)
317+
else _with_span_origin_attributes(span, detect_environment()),
318+
customizers,
319+
)
320+
for span in spans
321+
]
306322
return super().export(spans)
307323

308324
def force_flush(self, timeout_millis=30000):
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Detached OTel export snapshots for the shared span customizer hook."""
2+
3+
from collections.abc import Mapping
4+
from copy import deepcopy
5+
from types import SimpleNamespace
6+
7+
from braintrust.span_customizer import _run_span_export_customizers
8+
from opentelemetry.sdk.resources import Resource
9+
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
10+
from opentelemetry.trace import SpanContext, SpanKind, StatusCode, TraceFlags
11+
12+
13+
try:
14+
from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans
15+
except ImportError:
16+
# OpenTelemetry 1.16 predates the shared HTTP/gRPC encoder package.
17+
from opentelemetry.exporter.otlp.proto.http.trace_exporter.encoder import _ProtobufEncoder
18+
19+
encode_spans = _ProtobufEncoder.encode
20+
21+
22+
class _ExportSpan(SimpleNamespace):
23+
def get_span_context(self):
24+
return self.context
25+
26+
27+
def _copy_value(value):
28+
if isinstance(value, Mapping):
29+
return {key: _copy_value(item) for key, item in value.items()}
30+
if isinstance(value, (tuple, list)):
31+
return [_copy_value(item) for item in value]
32+
if value is None or type(value) in (str, bool, int, float, bytes):
33+
return value
34+
raise TypeError("Invalid OTel snapshot value")
35+
36+
37+
def _context_data(context):
38+
if context is None:
39+
return None
40+
return {
41+
"trace_id": context.trace_id,
42+
"span_id": context.span_id,
43+
"is_remote": context.is_remote,
44+
"trace_flags": int(context.trace_flags),
45+
"trace_state": dict(context.trace_state.items()),
46+
}
47+
48+
49+
def _snapshot(span):
50+
scope = span.instrumentation_scope
51+
return {
52+
"name": span.name,
53+
"context": _context_data(span.context),
54+
"parent": _context_data(span.parent),
55+
"start_time": span.start_time,
56+
"end_time": span.end_time,
57+
"kind": span.kind.value,
58+
"attributes": _copy_value(span.attributes or {}),
59+
"events": [
60+
{
61+
"name": event.name,
62+
"timestamp": event.timestamp,
63+
"attributes": _copy_value(event.attributes or {}),
64+
"dropped_attributes": getattr(event, "dropped_attributes", getattr(event.attributes, "dropped", 0)),
65+
}
66+
for event in span.events
67+
],
68+
"links": [
69+
{
70+
"context": _context_data(link.context),
71+
"attributes": _copy_value(link.attributes or {}),
72+
"dropped_attributes": getattr(link, "dropped_attributes", getattr(link.attributes, "dropped", 0)),
73+
}
74+
for link in span.links
75+
],
76+
"status": {"status_code": span.status.status_code.value, "description": span.status.description},
77+
"resource": {"attributes": _copy_value(span.resource.attributes), "schema_url": span.resource.schema_url},
78+
"instrumentation_scope": {
79+
"name": scope.name,
80+
"version": scope.version,
81+
"schema_url": scope.schema_url,
82+
"attributes": _copy_value(getattr(scope, "attributes", None) or {}),
83+
}
84+
if scope
85+
else None,
86+
"dropped_attributes": span.dropped_attributes,
87+
"dropped_events": span.dropped_events,
88+
"dropped_links": span.dropped_links,
89+
}
90+
91+
92+
def _attributes(data):
93+
if not isinstance(data, dict):
94+
raise TypeError("OTel attributes must be a dict")
95+
for key, value in data.items():
96+
if type(key) is not str:
97+
raise TypeError("OTel attribute keys must be strings")
98+
key.encode("utf-8")
99+
values = value if isinstance(value, (list, tuple)) else (value,)
100+
for item in values:
101+
if type(item) not in (str, bool, int, float):
102+
raise TypeError("Invalid OTel attribute value")
103+
if type(item) is int and not -(2**63) <= item < 2**63:
104+
raise ValueError("OTel integer attribute out of range")
105+
if type(item) is str:
106+
item.encode("utf-8")
107+
if values and any(type(item) is not type(values[0]) for item in values):
108+
raise TypeError("OTel attribute arrays must be homogeneous")
109+
return data
110+
111+
112+
def _context(data):
113+
if data is None:
114+
return None
115+
state = data.get("trace_state", {})
116+
if not isinstance(state, dict) or any(type(k) is not str or type(v) is not str for k, v in state.items()):
117+
raise TypeError("Invalid OTel trace state")
118+
return SpanContext(
119+
trace_id=data["trace_id"],
120+
span_id=data["span_id"],
121+
is_remote=data.get("is_remote", False),
122+
trace_flags=TraceFlags(data.get("trace_flags", 0)),
123+
trace_state=state,
124+
)
125+
126+
127+
def _scope(data):
128+
if data is None:
129+
return None
130+
attributes = _attributes(data.get("attributes", {}))
131+
kwargs = {}
132+
if hasattr(InstrumentationScope, "attributes"):
133+
kwargs["attributes"] = attributes
134+
elif attributes:
135+
raise ValueError("Scope attributes require a newer OpenTelemetry SDK")
136+
return InstrumentationScope(
137+
name=data.get("name", ""), version=data.get("version"), schema_url=data.get("schema_url", ""), **kwargs
138+
)
139+
140+
141+
def _export_span(data):
142+
resource = data.get("resource", {})
143+
scope = data.get("instrumentation_scope")
144+
status = data.get("status", {})
145+
return _ExportSpan(
146+
name=data.get("name", ""),
147+
context=_context(data["context"]),
148+
parent=_context(data["parent"]),
149+
start_time=data["start_time"],
150+
end_time=data["end_time"],
151+
kind=SpanKind(data.get("kind", 0)),
152+
attributes=_attributes(data.get("attributes", {})),
153+
events=[
154+
SimpleNamespace(
155+
name=event["name"],
156+
timestamp=event["timestamp"],
157+
attributes=_attributes(event.get("attributes", {})),
158+
dropped_attributes=event.get("dropped_attributes", 0),
159+
)
160+
for event in data.get("events", [])
161+
],
162+
links=[
163+
SimpleNamespace(
164+
context=_context(link["context"]),
165+
attributes=_attributes(link.get("attributes", {})),
166+
dropped_attributes=link.get("dropped_attributes", 0),
167+
)
168+
for link in data.get("links", [])
169+
],
170+
status=SimpleNamespace(
171+
status_code=StatusCode(status.get("status_code", 0)), description=status.get("description")
172+
),
173+
resource=Resource(_attributes(resource.get("attributes", {})), schema_url=resource.get("schema_url", "")),
174+
instrumentation_scope=_scope(scope),
175+
dropped_attributes=data.get("dropped_attributes", 0),
176+
dropped_events=data.get("dropped_events", 0),
177+
dropped_links=data.get("dropped_links", 0),
178+
)
179+
180+
181+
def customize_otel_span(span, customizers):
182+
"""Customize exactly once at the Braintrust exporter, never other consumers."""
183+
data = _snapshot(span)
184+
protected = {key: deepcopy(data[key]) for key in ("context", "parent", "start_time", "end_time")}
185+
routing = {key: data["attributes"][key] for key in ("braintrust.parent",) if key in data["attributes"]}
186+
187+
def restore(result):
188+
result = _copy_value(result)
189+
result.update(deepcopy(protected))
190+
attributes = result.setdefault("attributes", {})
191+
if not isinstance(attributes, dict):
192+
raise TypeError("OTel attributes must be a dict")
193+
attributes.pop("braintrust.parent", None)
194+
attributes.update(routing)
195+
# Validate every hook result before the next hook runs. The upstream encoder
196+
# silently skips invalid attributes (and logs their values), so validate those
197+
# first; protobuf then checks all remaining wire types and numeric bounds.
198+
candidate = _export_span(result)
199+
encode_spans([candidate]).SerializeToString()
200+
return result
201+
202+
def stripped_record():
203+
stripped = deepcopy(protected)
204+
for field in ("context", "parent"):
205+
if stripped[field] is not None:
206+
stripped[field]["trace_state"] = {}
207+
stripped["attributes"] = {**routing, "braintrust.context_json": '{"customizer_error":true}'}
208+
return stripped
209+
210+
return _export_span(_run_span_export_customizers(data, customizers, restore, stripped_record))

‎py/src/braintrust/span_customizer.py‎

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Synchronous transformations of native export records."""
1+
"""Synchronous transformations of native and OpenTelemetry export records."""
22

33
import inspect
44
import logging
@@ -16,22 +16,31 @@
1616
PARENT_ID_FIELD,
1717
TRANSACTION_ID_FIELD,
1818
)
19-
from .env import BraintrustEnv
2019

2120

2221
__all__ = ["SpanCustomizer", "SpanExportData", "set_span_customizers"]
2322

2423
SpanExportData = dict[str, Any]
25-
"""A native, possibly incremental span export record, not a live span."""
24+
"""A native incremental record or OTel export snapshot, never a live span."""
2625

2726

2827
class SpanCustomizer:
29-
"""Extensible hooks for all native SDK spans. Omitted hooks are no-ops."""
28+
"""Extensible hooks for all SDK spans. Omitted hooks are no-ops."""
3029

3130
def on_span_export(self, data: SpanExportData) -> SpanExportData:
3231
"""Return the original or a replacement export record, synchronously.
3332
34-
Hooks run in registration order after lazy values resolve, before merging,
33+
Native records expose input/output/metadata and may be incremental. OTel
34+
exports expose detached dictionaries with name, attributes, events, links,
35+
status, resource, instrumentation_scope, context/parent, kind, start_time,
36+
end_time, and dropped-item counts (see the SDK README for exact shapes).
37+
OTel hooks run after filtering and origin enrichment; original context,
38+
parent, timing, and braintrust.parent routing are restored after each hook.
39+
OTel failures retain only identity/timing/routing and the attribute
40+
braintrust.context_json='{"customizer_error":true}', with empty resource
41+
and scope metadata. Both pipelines share one failure diagnostic budget.
42+
43+
Native hooks run in registration order after lazy values resolve, before merging,
3544
attachment processing, masking, and serialization. Identity, routing, and
3645
merge protocol fields are restored after each hook. Exceptions and invalid
3746
synchronous returns stop the hook chain and strip the outgoing record.
@@ -114,22 +123,16 @@ def set_span_customizers(customizers: Sequence[SpanCustomizer] | None) -> None:
114123
"""Replace the process-wide ordered customizer list with a snapshot.
115124
116125
Configure before logging spans; pass None or an empty sequence to disable.
117-
The sequence is copied, but customizer instances are not. Each span record
118-
uses the configuration active when it is logged, and retries reuse its
119-
transformed or stripped data. There is no environment-variable registration.
126+
The sequence is copied, but customizer instances are not. Native records use
127+
the configuration active when logged; OTel uses the export-time configuration.
128+
Transport retries reuse transformed or stripped data. There is no
129+
environment-variable registration.
120130
121131
Raises TypeError for classes, objects without a callable ``on_span_export``,
122132
and async hooks. Invalid synchronous returns are rejected at export time.
123-
Non-empty customizers with BRAINTRUST_OTEL_COMPAT enabled log one error per
124-
registration attempt and leave the existing configuration unchanged.
125133
"""
126134
global _span_customizers
127135
snapshot = tuple(customizers) if customizers is not None else ()
128-
if snapshot and BraintrustEnv.OTEL_COMPAT.get(False):
129-
logging.getLogger("braintrust").error(
130-
"Span customizers are not yet supported with OTel compat mode (BRAINTRUST_OTEL_COMPAT)"
131-
)
132-
return
133136
for customizer in snapshot:
134137
_validate_customizer(customizer)
135138
_span_customizers = snapshot
@@ -188,10 +191,36 @@ def _customize_span_export(
188191
preserved = _snapshot_fields(data, ("created",))
189192
timing = _snapshot_fields(data.get("metrics"), ("start", "end"))
190193
span_type = _snapshot_fields(data.get("span_attributes"), ("type",))
194+
195+
def stripped_record():
196+
stripped = _StrippedSpanExportData(protected)
197+
stripped.update(preserved)
198+
if timing:
199+
stripped["metrics"] = timing
200+
if span_type:
201+
stripped["span_attributes"] = span_type
202+
stripped["context"] = {"customizer_error": True}
203+
return stripped
204+
191205
try:
192206
# Protocol containers may alias span state; don't expose those to hooks.
193207
data = _restore_protocol_fields(data, protected)
194-
for customizer in customizers:
208+
except (KeyboardInterrupt, SystemExit):
209+
raise
210+
except BaseException:
211+
stripped = stripped_record()
212+
_log_customizer_failure()
213+
return stripped
214+
215+
return _run_span_export_customizers(
216+
data, customizers, lambda result: _restore_protocol_fields(result, protected), stripped_record
217+
)
218+
219+
220+
def _run_span_export_customizers(data, customizers, restore, stripped_record):
221+
"""Shared synchronous hook/failure policy; adapters own their export shape."""
222+
for customizer in customizers:
223+
try:
195224
hook = getattr(customizer, "on_span_export", None)
196225
if hook is None:
197226
continue
@@ -204,21 +233,14 @@ def _customize_span_export(
204233
if not isinstance(result, dict):
205234
raise TypeError("Span customizers must return a dict")
206235
# Always a fresh plain dict, even for dict subclasses.
207-
data = _restore_protocol_fields(result, protected)
208-
except (KeyboardInterrupt, SystemExit):
209-
raise
210-
except BaseException:
211-
# Memoize this stripped result in the caller's LazyValue, including
212-
# GeneratorExit and CancelledError, without rejecting other records.
213-
stripped = _StrippedSpanExportData(protected)
214-
stripped.update(preserved)
215-
if timing:
216-
stripped["metrics"] = timing
217-
if span_type:
218-
stripped["span_attributes"] = span_type
219-
stripped["context"] = {"customizer_error": True}
220-
_log_customizer_failure()
221-
return stripped
236+
data = restore(dict(result))
237+
except (KeyboardInterrupt, SystemExit):
238+
raise
239+
except BaseException:
240+
# No exception details are safe, including BaseException subclasses.
241+
stripped = stripped_record()
242+
_log_customizer_failure()
243+
return stripped
222244
return data
223245

224246

0 commit comments

Comments
 (0)