Skip to content

Commit 3f70c19

Browse files
committed
span customizer enhancements
1 parent e45a830 commit 3f70c19

7 files changed

Lines changed: 486 additions & 99 deletions

File tree

‎.agents/skills/sdk-integrations/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def start_span(*args, **kwargs):
190190
return _bt_start_span(*args, **kwargs)
191191
```
192192

193-
Module-level `start_span(...)` calls flow through automatically. Calls that go through a `Logger` / `Experiment` / parent-`Span` instance (`logger.start_span(...)`, `parent.start_span(...)`) need `internal={"instrumentation": _INSTRUMENTATION}` explicitly. Tests assert `span["context"]["span_origin"]["instrumentation"]["name"] == "<provider>-auto"`; `SpanImpl._instrumentation` exposes the resolved value.
193+
Module-level `start_span(...)` calls flow through automatically. Calls that go through a `Logger` / `Experiment` / parent-`Span` instance (`logger.start_span(...)`, `parent.start_span(...)`) need `internal={"instrumentation": _INSTRUMENTATION}` explicitly. Tests assert the exported provenance: `span["context"]["span_origin"]["instrumentation"]["name"] == "<provider>-auto"`.
194194

195195
## Metrics
196196

‎py/src/braintrust/auto.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +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 instrumentation
107-
spans. Copies and replaces the global list when provided; None leaves
108-
existing configuration unchanged. Pass [] to disable.
106+
span_customizers: Ordered synchronous export customizers for all native SDK
107+
spans, including manual spans; dataset rows and feedback are excluded.
108+
Copies and replaces the global list when provided; None leaves existing
109+
configuration unchanged. Pass [] to disable.
109110
110111
Returns:
111112
Dict mapping integration name to whether it was successfully instrumented.

‎py/src/braintrust/logger.py‎

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -999,9 +999,12 @@ def utf8_byte_length(value: str) -> int:
999999
return len(value.encode("utf-8"))
10001000

10011001

1002+
_LazyLogRecord = LazyValue[dict[str, Any]] | LazyValue[dict[str, Any] | None]
1003+
1004+
10021005
class _BackgroundLogger(ABC):
10031006
@abstractmethod
1004-
def log(self, *args: LazyValue[dict[str, Any]]) -> None:
1007+
def log(self, *args: _LazyLogRecord) -> None:
10051008
pass
10061009

10071010
@abstractmethod
@@ -1019,7 +1022,7 @@ def __init__(self):
10191022
def enforce_queue_size_limit(self, enforce: bool) -> None:
10201023
pass
10211024

1022-
def log(self, *args: LazyValue[dict[str, Any]]) -> None:
1025+
def log(self, *args: _LazyLogRecord) -> None:
10231026
with self.lock:
10241027
self.logs.extend(args)
10251028

@@ -1033,8 +1036,8 @@ def flush(self, batch_size: int | None = None):
10331036
if not self.logs:
10341037
return
10351038

1036-
# Unwrap lazy values and extract attachments
1037-
logs = [l.get() for l in self.logs]
1039+
# Filter dropped records before touching attachments.
1040+
logs = [record for item in self.logs if (record := item.get()) is not None]
10381041

10391042
# Extract attachments from all logs
10401043
attachments: list[BaseAttachment] = []
@@ -1046,7 +1049,7 @@ def flush(self, batch_size: int | None = None):
10461049

10471050
def pop(self):
10481051
with self.lock:
1049-
logs = [l.get() for l in self.logs] # unwrap the LazyValues
1052+
logs = [record for item in self.logs if (record := item.get()) is not None]
10501053
self.logs = []
10511054

10521055
if not logs:
@@ -1057,7 +1060,11 @@ def pop(self):
10571060
batch = merge_row_batch(logs)
10581061

10591062
if self._export_customizers:
1060-
batch = [_customize_span_export(item, self._export_customizers) for item in batch]
1063+
batch = [
1064+
record
1065+
for item in batch
1066+
if (record := _customize_span_export(item, self._export_customizers)) is not None
1067+
]
10611068

10621069
return batch
10631070

@@ -1098,7 +1105,7 @@ def __init__(self, api_conn: LazyValue[HTTPConnection]):
10981105
self.started = False
10991106

11001107
self.logger = logging.getLogger("braintrust")
1101-
self.queue: "LogQueue[LazyValue[dict[str, Any]]]" = LogQueue(maxsize=self.queue_maxsize)
1108+
self.queue: "LogQueue[_LazyLogRecord]" = LogQueue(maxsize=self.queue_maxsize)
11021109

11031110
# Counter for tracking overflow uploads (useful for testing)
11041111
self._overflow_upload_count = 0
@@ -1113,7 +1120,7 @@ def enforce_queue_size_limit(self, enforce: bool) -> None:
11131120
"""
11141121
self.queue.enforce_queue_size_limit(enforce)
11151122

1116-
def log(self, *args: LazyValue[dict[str, Any]]) -> None:
1123+
def log(self, *args: _LazyLogRecord) -> None:
11171124
self._start()
11181125
dropped_items = []
11191126
for event in args:
@@ -1254,16 +1261,20 @@ def flush(self, batch_size: int | None = None):
12541261
)
12551262

12561263
def _unwrap_lazy_values(
1257-
self, wrapped_items: Sequence[LazyValue[dict[str, Any]]]
1264+
self, wrapped_items: Sequence[_LazyLogRecord]
12581265
) -> tuple[list[dict[str, Any]], list["BaseAttachment"]]:
12591266
for i in range(self.num_tries):
12601267
try:
1261-
unwrapped_items = [item.get() for item in wrapped_items]
1268+
unwrapped_items = [record for item in wrapped_items if (record := item.get()) is not None]
12621269
merged_items = merge_row_batch(unwrapped_items)
12631270

1264-
# Logger-local hooks run after instrumentation hooks and merging.
1271+
# Logger-local hooks run after span hooks and merging.
12651272
if self._export_customizers:
1266-
merged_items = [_customize_span_export(item, self._export_customizers) for item in merged_items]
1273+
merged_items = [
1274+
record
1275+
for item in merged_items
1276+
if (record := _customize_span_export(item, self._export_customizers)) is not None
1277+
]
12671278

12681279
attachments: list["BaseAttachment"] = []
12691280
for item in merged_items:
@@ -2489,8 +2500,8 @@ def set_masking_function(masking_function: Callable[[Any], Any] | None) -> None:
24892500
"""
24902501
Set a global masking function that will be applied to all logged data before sending to Braintrust.
24912502
The masking function will be applied after records are merged but before they are sent to the backend.
2492-
Internally, masking is a logger-local export customizer that runs after instrumentation
2493-
customizers and also covers manually logged records.
2503+
Internally, masking is a logger-local export customizer that runs after span
2504+
customizers and also covers non-span records such as datasets and feedback.
24942505
24952506
:param masking_function: A function that takes a JSON-serializable object and returns a masked version.
24962507
Set to None to disable masking.
@@ -3983,6 +3994,7 @@ def _update_span_impl(
39833994
)
39843995

39853996
update_event = bt_safe_deep_copy(update_event)
3997+
customizers = _get_span_customizers()
39863998

39873999
def parent_ids():
39884000
exporter = _get_exporter()
@@ -3991,15 +4003,16 @@ def parent_ids():
39914003
object_id=parent_object_id.get(),
39924004
).object_id_fields()
39934005

3994-
def compute_record():
3995-
return dict(
4006+
def compute_record() -> dict[str, Any] | None:
4007+
record = dict(
39964008
id=id,
39974009
**update_event,
39984010
**parent_ids(),
39994011
**{
40004012
IS_MERGE_FIELD: True,
40014013
},
40024014
)
4015+
return _customize_span_export(record, customizers) if customizers else record
40034016

40044017
_state.global_bg_logger().log(LazyValue(compute_record, use_mutex=False))
40054018

@@ -4815,6 +4828,7 @@ def __init__(
48154828
created=datetime.datetime.now(datetime.timezone.utc).isoformat(),
48164829
)
48174830
internal = internal or {}
4831+
# Integration consumers use this provenance independently of export hooks.
48184832
self._instrumentation = internal.get("instrumentation") or "braintrust-python-logger"
48194833
internal_data["context"] = merge_span_origin_context(
48204834
caller_location or {},
@@ -4903,7 +4917,7 @@ def log_internal(self, event: dict[str, Any] | None = None, internal_data: dict[
49034917

49044918
# Snapshot at log time so the span cache and the export agree on whether
49054919
# (and how) this record is customized.
4906-
customizers = _get_span_customizers() if self._instrumentation != "braintrust-python-logger" else ()
4920+
customizers = _get_span_customizers()
49074921
pending_cache_key = (
49084922
object()
49094923
if customizers
@@ -4933,11 +4947,11 @@ def write_span_cache(record: dict[str, Any]) -> None:
49334947
self.state.span_cache.queue_write(self.root_span_id, self.span_id, cached_span)
49344948

49354949
# Customized records are cached after export customization instead, so
4936-
# local scorers never see content that a customizer redacted.
4950+
# local scorers never see content that a customizer redacted or dropped.
49374951
if not customizers:
49384952
write_span_cache(serializable_partial_record)
49394953

4940-
def compute_record() -> dict[str, Any]:
4954+
def compute_record() -> dict[str, Any] | None:
49414955
exporter = _get_exporter()
49424956
record = dict(
49434957
**serializable_partial_record,
@@ -4948,13 +4962,15 @@ def compute_record() -> dict[str, Any]:
49484962
).object_id_fields(),
49494963
)
49504964
# Resolve and customize inside the cached LazyValue: every incremental
4951-
# instrumentation record is transformed once, before the background
4965+
# span record is transformed once, before the background
49524966
# logger merges, masks, extracts attachments, or retries delivery.
49534967
if customizers:
4954-
record = _customize_span_export(record, customizers)
4955-
write_span_cache(record)
4968+
customized_record = _customize_span_export(record, customizers)
4969+
if customized_record is not None:
4970+
write_span_cache(customized_record)
49564971
if pending_cache_key is not None:
49574972
self.state.span_cache._forget_pending_record(self.root_span_id, pending_cache_key)
4973+
return customized_record
49584974
return record
49594975

49604976
# Cache readers and the publisher share resolution, including the cache

‎py/src/braintrust/span_cache.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ def __init__(self, disabled: bool = False):
118118
self._root_span_index: set[str] = set()
119119
# Buffer for pending writes
120120
self._write_buffer: list[DiskSpanRecord] = []
121-
# Unresolved customized exports must be cached before a trace read returns.
122-
self._pending_records: dict[str, dict[object, LazyValue[dict[str, Any]]]] = {}
121+
# Unresolved exports must be cached or dropped before a trace read returns.
122+
self._pending_records: dict[str, dict[object, LazyValue[dict[str, Any] | None]]] = {}
123123
self._pending_lock = threading.Lock()
124124

125125
def disable(self) -> None:
@@ -203,7 +203,7 @@ def cleanup_all_caches():
203203

204204
atexit.register(cleanup_all_caches)
205205

206-
def _track_pending_record(self, root_span_id: str, key: object, record: LazyValue[dict[str, Any]]) -> None:
206+
def _track_pending_record(self, root_span_id: str, key: object, record: LazyValue[dict[str, Any] | None]) -> None:
207207
with self._pending_lock:
208208
if not self.disabled:
209209
self._pending_records.setdefault(root_span_id, {})[key] = record

‎py/src/braintrust/span_customizer.py‎

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Synchronous transformations of native export records."""
22

33
import inspect
4+
import logging
45
from collections.abc import Callable, Sequence
56
from typing import Any
67

@@ -14,6 +15,7 @@
1415
PARENT_ID_FIELD,
1516
TRANSACTION_ID_FIELD,
1617
)
18+
from .env import BraintrustEnv
1719

1820

1921
__all__ = ["SpanCustomizer", "SpanExportData", "set_span_customizers"]
@@ -23,15 +25,27 @@
2325

2426

2527
class SpanCustomizer:
26-
"""Extensible hooks for instrumentation-created spans. Omitted hooks are no-ops."""
28+
"""Extensible hooks for all native SDK spans. Omitted hooks are no-ops."""
2729

2830
def on_span_export(self, data: SpanExportData) -> SpanExportData:
2931
"""Return the original or a replacement export record, synchronously.
3032
3133
Hooks run in registration order after lazy values resolve, before merging,
3234
attachment processing, masking, and serialization. Identity, routing, and
3335
merge protocol fields are restored after each hook. Exceptions and invalid
34-
returns fail open: in-place edits survive and later hooks still run.
36+
synchronous returns log a safe error, stop the hook chain, and drop that
37+
outgoing record. Handle recoverable errors within the hook. Only
38+
KeyboardInterrupt and SystemExit propagate.
39+
40+
Records may contain SDK ``Attachment`` objects: integrations convert inline
41+
media at capture time. Remove or replace them to prevent their upload;
42+
hooks that serialize data must handle them.
43+
44+
Manual roots and children, instrumented spans, logger spans, and experiment
45+
spans are included. Dataset rows and feedback records are not. Each hook
46+
receives a possibly incremental record, not a completed logical span;
47+
fields may be absent and hooks may run before the span ends. Dropping a
48+
record does not retract earlier exports or disable later records.
3549
"""
3650
return data
3751

@@ -84,16 +98,21 @@ def on_span_export(self, data: SpanExportData) -> SpanExportData:
8498
def set_span_customizers(customizers: Sequence[SpanCustomizer] | None) -> None:
8599
"""Replace the process-wide ordered customizer list with a snapshot.
86100
87-
Configure before making instrumented calls; pass None or an empty sequence to
88-
disable. The sequence is copied, but customizer instances are not. Each record
101+
Configure before logging spans; pass None or an empty sequence to disable.
102+
The sequence is copied, but customizer instances are not. Each span record
89103
uses the configuration active when it is logged, and retries reuse its
90-
transformed data. There is no environment-variable registration.
104+
transformed data or dropped result. There is no environment-variable registration.
91105
92106
Raises TypeError for classes, objects without a callable ``on_span_export``,
93-
and async hooks, since those would otherwise silently export unredacted data.
107+
and async hooks. Invalid synchronous returns are rejected at export time.
108+
Raises NotImplementedError for non-empty customizers when BRAINTRUST_OTEL_COMPAT is enabled.
94109
"""
95110
global _span_customizers
96111
snapshot = tuple(customizers) if customizers is not None else ()
112+
if snapshot and BraintrustEnv.OTEL_COMPAT.get(False):
113+
raise NotImplementedError(
114+
"Span customizers are not yet supported with OTel compat mode (BRAINTRUST_OTEL_COMPAT)"
115+
)
97116
for customizer in snapshot:
98117
_validate_customizer(customizer)
99118
_span_customizers = snapshot
@@ -104,8 +123,7 @@ def _get_span_customizers() -> tuple[SpanCustomizer, ...]:
104123

105124

106125
def _validate_customizer(customizer: Any) -> None:
107-
# Reject misconfiguration eagerly: at export time these would silently fail
108-
# open and ship unredacted data.
126+
# Reject misconfiguration eagerly rather than dropping records at export time.
109127
if isinstance(customizer, type):
110128
raise TypeError(f"Span customizers must be instances, not classes; did you mean {customizer.__name__}()?")
111129
hook = getattr(customizer, "on_span_export", None)
@@ -136,15 +154,14 @@ def _restore_protocol_fields(data: SpanExportData, protected: SpanExportData) ->
136154

137155
def _customize_span_export(
138156
data: SpanExportData, customizers: Sequence[SpanCustomizer] | None = None
139-
) -> SpanExportData:
157+
) -> SpanExportData | None:
140158
if customizers is None:
141159
customizers = _span_customizers
142160
if not customizers:
143161
return data
144162

145163
protected = None
146164
for customizer in customizers:
147-
candidate = data
148165
try:
149166
hook = getattr(customizer, "on_span_export", None)
150167
if hook is None:
@@ -153,19 +170,22 @@ def _customize_span_export(
153170
protected = {key: _copy_protocol_value(data[key]) for key in _PROTECTED_FIELDS if key in data}
154171
# Protocol containers may alias span state; don't expose those to hooks.
155172
data = _restore_protocol_fields(data, protected)
156-
candidate = data
157173
result = hook(data)
158174
if inspect.isawaitable(result):
159175
# Do not execute asynchronous hooks or emit unawaited coroutine warnings.
160176
if inspect.iscoroutine(result):
161177
result.close()
162-
elif type(result) is dict:
163-
candidate = result
164-
except Exception:
165-
# Deliberately fail open, retaining in-place changes. Never log the
166-
# exception, since its message may itself contain sensitive payloads.
167-
pass
168-
169-
if protected is not None:
170-
data = _restore_protocol_fields(candidate, protected)
178+
raise TypeError("Span customizers must return a synchronous dict")
179+
if not isinstance(result, dict):
180+
raise TypeError("Span customizers must return a dict")
181+
# Always a fresh plain dict, even for dict subclasses.
182+
data = _restore_protocol_fields(result, protected)
183+
except (KeyboardInterrupt, SystemExit):
184+
raise
185+
except BaseException:
186+
# Treat GeneratorExit, CancelledError, etc. as hook failures so the drop
187+
# is memoized and cannot escape flush with the rest of the batch.
188+
# Neither exception details nor record data are safe to include here.
189+
logging.getLogger("braintrust").error("Span customizer failed; dropping outgoing span record.")
190+
return None
171191
return data

0 commit comments

Comments
 (0)