Skip to content

Commit cbcc96b

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

11 files changed

Lines changed: 819 additions & 242 deletions

File tree

‎py/README.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,70 @@ Then run:
4141
BRAINTRUST_API_KEY=<YOUR_API_KEY> braintrust eval tutorial_eval.py
4242
```
4343

44+
## Customizing instrumentation exports
45+
46+
Use synchronous span customizers to redact captured values or add metadata without
47+
changing provider responses:
48+
49+
```python
50+
import braintrust
51+
52+
53+
class Redact(braintrust.SpanCustomizer):
54+
def on_span_export(self, data: braintrust.SpanExportData) -> braintrust.SpanExportData:
55+
for field in ("input", "output"):
56+
if field in data:
57+
data[field] = "[redacted]"
58+
data.pop("error", None)
59+
return data
60+
61+
62+
braintrust.auto_instrument(span_customizers=[Redact()])
63+
braintrust.init_logger(project="my-project")
64+
```
65+
66+
For explicit wrappers or integration setup, call
67+
`braintrust.set_span_customizers([Redact()])` before instrumented calls. Both APIs
68+
replace the process-wide ordered list with a snapshot of the supplied sequence;
69+
the customizer objects themselves are not copied. `set_span_customizers(None)` or
70+
an empty sequence disables customization. Omitting `span_customizers` (or passing
71+
`None`) to `auto_instrument()` leaves existing configuration unchanged. There is no
72+
environment-variable registration.
73+
74+
`SpanCustomizer` is an extensible base class with a default no-op
75+
`on_span_export(data)` method. Hooks run synchronously in registration order, each
76+
receiving its predecessor's result. Return a plain `dict` containing the data to
77+
export: either the input record or a replacement. Replacement is **not** a merge;
78+
retain the content fields you want to export. Returning `None` cannot drop a span.
79+
80+
Hooks apply only to **native instrumentation-created span records**, not manually
81+
created spans (even children of instrumented spans), dataset rows, feedback, or
82+
spans exported through a separate OpenTelemetry exporter. They run after lazy
83+
values resolve and before attachment processing, merging, masking, and transport
84+
serialization. Existing attachment objects retain their identity; hooks can
85+
remove them before upload without copying their contents.
86+
87+
A span can produce several incremental records, including records flushed before
88+
it ends. Input, output, metrics, and errors need not be present together. Removing
89+
a field does not retract an earlier export, so redact every record containing
90+
sensitive data. Each record uses the configuration at its first export resolution.
91+
Retries reuse the transformed record without calling hooks again.
92+
93+
Identity, parentage, routing, and merge protocol fields are restored after every
94+
hook, including nested protocol arrays. Protected fields are `id`, `span_id`,
95+
`root_span_id`, `span_parents`, `org_id`, `project_id`, `experiment_id`, `dataset_id`,
96+
`prompt_session_id`, `log_id`, `function_data`, `_is_merge`, `_merge_paths`,
97+
`_parent_id`, `_object_delete`, `_array_delete`, and `_xact_id`. Hooks cannot add an
98+
absent protected field or change the destination.
99+
100+
**Failures are fail-open.** Exceptions, `None`, awaitables, and other invalid return
101+
types are ignored; later hooks and export continue with the current record.
102+
In-place content mutations made before failure are retained, not rolled back.
103+
An asynchronous hook is not awaited. A failing redaction hook can therefore leak
104+
unredacted data: never throw to block export. Returned content must remain valid
105+
for SDK serialization. Keep hooks fast, avoid blocking I/O, and make them safe for
106+
background export threads rather than assuming the caller's thread or context.
107+
44108
## Optional Extras
45109

46110
Install extras as needed for specific workflows:

‎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:

0 commit comments

Comments
 (0)