Skip to content

Commit d82dbd0

Browse files
committed
feat: add span customizers (SDK-316)
add customizers and re-implement masking fn with a customizer
1 parent a8468d9 commit d82dbd0

11 files changed

Lines changed: 862 additions & 245 deletions

File tree

‎py/src/braintrust/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,8 @@ def is_equal(expected, output):
8686
from .sandbox import RegisterSandboxResult as RegisterSandboxResult
8787
from .sandbox import SandboxConfig as SandboxConfig
8888
from .sandbox import register_sandbox as register_sandbox
89+
from .span_customizer import SpanCustomizer as SpanCustomizer
90+
from .span_customizer import SpanExportData as SpanExportData
91+
from .span_customizer import set_span_customizers as set_span_customizers
8992
from .util import BT_IS_ASYNC_ATTRIBUTE as BT_IS_ASYNC_ATTRIBUTE
9093
from .util import MarkAsyncWrapper as MarkAsyncWrapper

‎py/src/braintrust/auto.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import logging
8+
from collections.abc import Sequence
89
from contextlib import contextmanager
910

1011
from braintrust.integrations import (
@@ -40,6 +41,7 @@
4041
TypeSafeIntegration,
4142
)
4243
from braintrust.integrations.base import BaseIntegration
44+
from braintrust.span_customizer import SpanCustomizer, set_span_customizers
4345

4446

4547
__all__ = ["auto_instrument"]
@@ -90,6 +92,7 @@ def auto_instrument(
9092
livekit_agents: bool = True,
9193
pipecat: bool = True,
9294
typesafe: bool = True,
95+
span_customizers: Sequence[SpanCustomizer] | None = None,
9396
) -> dict[str, bool]:
9497
"""
9598
Auto-instrument supported AI/ML libraries for Braintrust tracing.
@@ -131,6 +134,9 @@ def auto_instrument(
131134
livekit_agents: Enable LiveKit Agents instrumentation (default: True)
132135
pipecat: Enable Pipecat AI instrumentation (default: True)
133136
typesafe: Enable TypeSafe instrumentation (default: True)
137+
span_customizers: Ordered synchronous export customizers for instrumentation
138+
spans. Copies and replaces the global list when provided; None leaves
139+
existing configuration unchanged. Pass [] to disable.
134140
135141
Returns:
136142
Dict mapping integration name to whether it was successfully instrumented.
@@ -176,6 +182,9 @@ def auto_instrument(
176182
client.models.generate_content(model="gemini-2.0-flash", contents="Hello!")
177183
```
178184
"""
185+
if span_customizers is not None:
186+
set_span_customizers(span_customizers)
187+
179188
results: dict[str, bool] = {}
180189

181190
if openai:

‎py/src/braintrust/integrations/auto_test_scripts/test_auto_google_discoveryengine.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
print("SUCCESS")
1919
sys.exit(0)
2020

21-
options = {name: False for name in inspect.signature(auto_instrument).parameters}
21+
options = {
22+
name: False
23+
for name, parameter in inspect.signature(auto_instrument).parameters.items()
24+
if isinstance(parameter.default, bool)
25+
}
2226
assert auto_instrument(**options) == {}
2327
RankServiceClient = None
2428
if sys.argv[1] == "before":

‎py/src/braintrust/integrations/pipecat/test_pipecat.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pathlib import Path
99

1010
import pytest
11-
from braintrust import logger
11+
from braintrust import SpanCustomizer, logger, set_span_customizers
1212
from braintrust.integrations.pipecat import (
1313
BraintrustPipecatObserver,
1414
PipecatIntegration,
@@ -58,6 +58,45 @@ def _single_span(logs, name):
5858
return matches[0]
5959

6060

61+
@pytest.mark.asyncio
62+
async def test_span_customizer_redacts_incremental_tts_input(memory_logger):
63+
TTSStartedFrame = _import("pipecat.frames.frames.TTSStartedFrame")
64+
TTSTextFrame = _import("pipecat.frames.frames.TTSTextFrame")
65+
TTSStoppedFrame = _import("pipecat.frames.frames.TTSStoppedFrame")
66+
67+
class Redact(SpanCustomizer):
68+
def on_span_export(self, data):
69+
if "input" in data:
70+
data["input"] = "[redacted]"
71+
return data
72+
73+
observer = BraintrustPipecatObserver()
74+
set_span_customizers([Redact()])
75+
try:
76+
await observer.on_pipeline_started()
77+
await observer._handle_frame(TTSStartedFrame(context_id="ctx"))
78+
first_frame = TTSTextFrame("private first", aggregated_by="sentence", context_id="ctx")
79+
await observer._handle_frame(first_frame)
80+
initial = memory_logger.pop()
81+
pipeline = _single_span(initial, "pipecat_pipeline")
82+
tts = _single_span(initial, "tts_response")
83+
assert tts["input"] == "[redacted]"
84+
assert tts["span_parents"] == [pipeline["span_id"]]
85+
assert first_frame.text == "private first"
86+
87+
second_frame = TTSTextFrame("private second", aggregated_by="sentence", context_id="ctx")
88+
await observer._handle_frame(second_frame)
89+
await observer._handle_frame(TTSStoppedFrame(context_id="ctx"))
90+
await observer.cleanup()
91+
updates = memory_logger.pop()
92+
updated_tts = next(row for row in updates if row["id"] == tts["id"])
93+
assert updated_tts["input"] == "[redacted]"
94+
assert updated_tts["span_id"] == tts["span_id"]
95+
assert second_frame.text == "private second"
96+
finally:
97+
set_span_customizers(None)
98+
99+
61100
def _pipeline_worker_kwargs(**overrides):
62101
PipelineWorker = _import("pipecat.pipeline.worker.PipelineWorker")
63102
signature = inspect.signature(PipelineWorker)

‎py/src/braintrust/integrations/pipecat/tracing.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@
99
_pcm_to_wav,
1010
_resolve_audio_attachment_options,
1111
)
12-
from braintrust.logger import NOOP_SPAN, Attachment, SpanTypeAttribute, current_span, start_span
12+
from braintrust.logger import NOOP_SPAN, Attachment, SpanTypeAttribute, current_span
13+
from braintrust.logger import start_span as _bt_start_span
14+
15+
16+
_INSTRUMENTATION = "pipecat-auto"
17+
18+
19+
def start_span(*args, **kwargs):
20+
internal = dict(kwargs.get("internal") or {})
21+
internal.setdefault("instrumentation", _INSTRUMENTATION)
22+
kwargs["internal"] = internal
23+
return _bt_start_span(*args, **kwargs)
1324

1425

1526
try:

‎py/src/braintrust/logger.py‎

Lines changed: 57 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
)
102102
from .queue import DEFAULT_QUEUE_SIZE, LogQueue
103103
from .serializable_data_class import SerializableDataClass
104+
from .span_customizer import SpanCustomizer, _customize_span_export, _get_span_customizers, _MaskingCustomizer
104105
from .span_identifier_v3 import SpanComponentsV3, SpanObjectTypeV3
105106
from .span_identifier_v4 import SpanComponentsV4
106107
from .span_origin import SpanOriginEnvironment, detect_environment, merge_span_origin_context
@@ -124,10 +125,6 @@
124125
from .xact_ids import prettify_xact
125126

126127

127-
# Fields that should be passed to the masking function
128-
# Note: "tags" field is intentionally excluded, but can be added if needed
129-
REDACTION_FIELDS = ["input", "output", "expected", "metadata", "context", "scores", "metrics"]
130-
131128
DATA_API_VERSION = 2
132129
LOGS3_OVERFLOW_REFERENCE_TYPE = "logs3_overflow"
133130
# 6 MB for the AWS lambda gateway (from our own testing).
@@ -1002,40 +999,6 @@ def utf8_byte_length(value: str) -> int:
1002999
return len(value.encode("utf-8"))
10031000

10041001

1005-
class _MaskingError:
1006-
"""Internal class to signal masking errors that need special handling."""
1007-
1008-
def __init__(self, field_name: str, error_type: str):
1009-
self.field_name = field_name
1010-
self.error_type = error_type
1011-
self.error_msg = f"ERROR: Failed to mask field '{field_name}' - {error_type}"
1012-
1013-
1014-
def _apply_masking_to_field(masking_function: Callable[[Any], Any], data: Any, field_name: str) -> Any:
1015-
"""Apply masking function to data and handle errors gracefully.
1016-
1017-
If the masking function raises an exception, returns an error message.
1018-
Returns _MaskingError for scores/metrics fields to signal they should be dropped.
1019-
"""
1020-
try:
1021-
return masking_function(data)
1022-
except Exception as mask_error:
1023-
# Return a generic error message without the stack trace to avoid leaking PII
1024-
error_type = type(mask_error).__name__
1025-
1026-
# For scores and metrics fields, return a special error object
1027-
# to signal the field should be dropped and error logged
1028-
if field_name in ["scores", "metrics"]:
1029-
return _MaskingError(field_name, error_type)
1030-
1031-
# For metadata field that expects dict type, return a dict with error key
1032-
if field_name == "metadata":
1033-
return {"error": f"ERROR: Failed to mask field '{field_name}' - {error_type}"}
1034-
1035-
# For other fields, return the error message as a string
1036-
return f"ERROR: Failed to mask field '{field_name}' - {error_type}"
1037-
1038-
10391002
class _BackgroundLogger(ABC):
10401003
@abstractmethod
10411004
def log(self, *args: LazyValue[dict[str, Any]]) -> None:
@@ -1050,7 +1013,7 @@ class _MemoryBackgroundLogger(_BackgroundLogger):
10501013
def __init__(self):
10511014
self.lock = threading.Lock()
10521015
self.logs = []
1053-
self.masking_function: Callable[[Any], Any] | None = None
1016+
self._export_customizers: tuple[SpanCustomizer, ...] = ()
10541017
self.upload_attempts: list[BaseAttachment] = [] # Track upload attempts
10551018

10561019
def enforce_queue_size_limit(self, enforce: bool) -> None:
@@ -1062,7 +1025,7 @@ def log(self, *args: LazyValue[dict[str, Any]]) -> None:
10621025

10631026
def set_masking_function(self, masking_function: Callable[[Any], Any] | None) -> None:
10641027
"""Set the masking function for the memory logger."""
1065-
self.masking_function = masking_function
1028+
self._export_customizers = (_MaskingCustomizer(masking_function),) if masking_function is not None else ()
10661029

10671030
def flush(self, batch_size: int | None = None):
10681031
"""Flush the memory logger, extracting attachments and tracking upload attempts."""
@@ -1093,28 +1056,8 @@ def pop(self):
10931056
# here
10941057
batch = merge_row_batch(logs)
10951058

1096-
# Apply masking after merge, similar to HTTPBackgroundLogger
1097-
if self.masking_function:
1098-
for i in range(len(batch)):
1099-
item = batch[i]
1100-
masked_item = item.copy()
1101-
1102-
# Only mask specific fields if they exist
1103-
for field in REDACTION_FIELDS:
1104-
if field in item:
1105-
masked_value = _apply_masking_to_field(self.masking_function, item[field], field)
1106-
if isinstance(masked_value, _MaskingError):
1107-
# Drop the field and add error message
1108-
if field in masked_item:
1109-
del masked_item[field]
1110-
if "error" in masked_item:
1111-
masked_item["error"] = f"{masked_item['error']}; {masked_value.error_msg}"
1112-
else:
1113-
masked_item["error"] = masked_value.error_msg
1114-
else:
1115-
masked_item[field] = masked_value
1116-
1117-
batch[i] = masked_item
1059+
if self._export_customizers:
1060+
batch = [_customize_span_export(item, self._export_customizers) for item in batch]
11181061

11191062
return batch
11201063

@@ -1129,7 +1072,7 @@ def pop(self):
11291072
class _HTTPBackgroundLogger:
11301073
def __init__(self, api_conn: LazyValue[HTTPConnection]):
11311074
self.api_conn = api_conn
1132-
self.masking_function: Callable[[Any], Any] | None = None
1075+
self._export_customizers: tuple[SpanCustomizer, ...] = ()
11331076
self.outfile = sys.stderr
11341077
self.flush_lock = threading.RLock()
11351078
self._max_request_size_override: int | None = None
@@ -1318,28 +1261,9 @@ def _unwrap_lazy_values(
13181261
unwrapped_items = [item.get() for item in wrapped_items]
13191262
merged_items = merge_row_batch(unwrapped_items)
13201263

1321-
# Apply masking after merging but before sending to backend
1322-
if self.masking_function:
1323-
for item_idx in range(len(merged_items)):
1324-
item = merged_items[item_idx]
1325-
masked_item = item.copy()
1326-
1327-
# Only mask specific fields if they exist
1328-
for field in REDACTION_FIELDS:
1329-
if field in item:
1330-
masked_value = _apply_masking_to_field(self.masking_function, item[field], field)
1331-
if isinstance(masked_value, _MaskingError):
1332-
# Drop the field and add error message
1333-
if field in masked_item:
1334-
del masked_item[field]
1335-
if "error" in masked_item:
1336-
masked_item["error"] = f"{masked_item['error']}; {masked_value.error_msg}"
1337-
else:
1338-
masked_item["error"] = masked_value.error_msg
1339-
else:
1340-
masked_item[field] = masked_value
1341-
1342-
merged_items[item_idx] = masked_item
1264+
# Logger-local hooks run after instrumentation hooks and merging.
1265+
if self._export_customizers:
1266+
merged_items = [_customize_span_export(item, self._export_customizers) for item in merged_items]
13431267

13441268
attachments: list["BaseAttachment"] = []
13451269
for item in merged_items:
@@ -1553,7 +1477,7 @@ def internal_replace_api_conn(self, api_conn: HTTPConnection):
15531477

15541478
def set_masking_function(self, masking_function: Callable[[Any], Any] | None):
15551479
"""Set or update the masking function."""
1556-
self.masking_function = masking_function
1480+
self._export_customizers = (_MaskingCustomizer(masking_function),) if masking_function is not None else ()
15571481

15581482

15591483
def _internal_reset_global_state() -> None:
@@ -2565,6 +2489,8 @@ def set_masking_function(masking_function: Callable[[Any], Any] | None) -> None:
25652489
"""
25662490
Set a global masking function that will be applied to all logged data before sending to Braintrust.
25672491
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.
25682494
25692495
:param masking_function: A function that takes a JSON-serializable object and returns a masked version.
25702496
Set to None to disable masking.
@@ -4975,36 +4901,68 @@ def log_internal(self, event: dict[str, Any] | None = None, internal_data: dict[
49754901
if serializable_partial_record.get("metrics", {}).get("end") is not None:
49764902
self._logged_end_time = serializable_partial_record["metrics"]["end"]
49774903

4978-
# Write to local span cache for scorer access
4979-
# Only cache experiment spans - regular logs don't need caching
4980-
if self.parent_object_type == SpanObjectTypeV3.EXPERIMENT:
4904+
# Snapshot at log time so the span cache and the export agree on whether
4905+
# (and how) this record is customized.
4906+
customizers = _get_span_customizers() if self._instrumentation != "braintrust-python-logger" else ()
4907+
pending_cache_key = (
4908+
object()
4909+
if customizers
4910+
and self.parent_object_type == SpanObjectTypeV3.EXPERIMENT
4911+
and not self.state.span_cache.disabled
4912+
else None
4913+
)
4914+
4915+
def write_span_cache(record: dict[str, Any]) -> None:
4916+
# Write to local span cache for scorer access
4917+
# Only cache experiment spans - regular logs don't need caching
4918+
if self.parent_object_type != SpanObjectTypeV3.EXPERIMENT:
4919+
return
49814920
from braintrust.span_cache import CachedSpan
49824921

49834922
cached_span = CachedSpan(
49844923
span_id=self.span_id,
4985-
input=serializable_partial_record.get("input"),
4986-
output=serializable_partial_record.get("output"),
4987-
metadata=serializable_partial_record.get("metadata"),
4924+
input=record.get("input"),
4925+
output=record.get("output"),
4926+
metadata=record.get("metadata"),
49884927
span_parents=self.span_parents,
4989-
span_attributes=serializable_partial_record.get("span_attributes"),
4990-
error=serializable_partial_record.get("error"),
4991-
metrics=serializable_partial_record.get("metrics"),
4992-
tags=serializable_partial_record.get("tags"),
4928+
span_attributes=record.get("span_attributes"),
4929+
error=record.get("error"),
4930+
metrics=record.get("metrics"),
4931+
tags=record.get("tags"),
49934932
)
49944933
self.state.span_cache.queue_write(self.root_span_id, self.span_id, cached_span)
49954934

4935+
# Customized records are cached after export customization instead, so
4936+
# local scorers never see content that a customizer redacted.
4937+
if not customizers:
4938+
write_span_cache(serializable_partial_record)
4939+
49964940
def compute_record() -> dict[str, Any]:
49974941
exporter = _get_exporter()
4998-
return dict(
4942+
record = dict(
49994943
**serializable_partial_record,
50004944
**{k: v.get() for k, v in lazy_partial_record.items()},
50014945
**exporter(
50024946
object_type=self.parent_object_type,
50034947
object_id=self.parent_object_id.get(),
50044948
).object_id_fields(),
50054949
)
5006-
5007-
self.state.global_bg_logger().log(LazyValue(compute_record, use_mutex=False))
4950+
# Resolve and customize inside the cached LazyValue: every incremental
4951+
# instrumentation record is transformed once, before the background
4952+
# logger merges, masks, extracts attachments, or retries delivery.
4953+
if customizers:
4954+
record = _customize_span_export(record, customizers)
4955+
write_span_cache(record)
4956+
if pending_cache_key is not None:
4957+
self.state.span_cache._forget_pending_record(self.root_span_id, pending_cache_key)
4958+
return record
4959+
4960+
# Cache readers and the publisher share resolution, including the cache
4961+
# write, so concurrent reads cannot customize a record twice.
4962+
lazy_record = LazyValue(compute_record, use_mutex=pending_cache_key is not None)
4963+
if pending_cache_key is not None:
4964+
self.state.span_cache._track_pending_record(self.root_span_id, pending_cache_key, lazy_record)
4965+
self.state.global_bg_logger().log(lazy_record)
50084966

50094967
def log_feedback(self, **event: Any) -> None:
50104968
return _log_feedback_impl(

0 commit comments

Comments
 (0)