diff --git a/py/README.md b/py/README.md index b0b3242dc..441e71bff 100644 --- a/py/README.md +++ b/py/README.md @@ -41,6 +41,70 @@ Then run: BRAINTRUST_API_KEY= braintrust eval tutorial_eval.py ``` +## Customizing instrumentation exports + +Use synchronous span customizers to redact captured values or add metadata without +changing provider responses: + +```python +import braintrust + + +class Redact(braintrust.SpanCustomizer): + def on_span_export(self, data: braintrust.SpanExportData) -> braintrust.SpanExportData: + for field in ("input", "output"): + if field in data: + data[field] = "[redacted]" + data.pop("error", None) + return data + + +braintrust.auto_instrument(span_customizers=[Redact()]) +braintrust.init_logger(project="my-project") +``` + +For explicit wrappers or integration setup, call +`braintrust.set_span_customizers([Redact()])` before instrumented calls. Both APIs +replace the process-wide ordered list with a snapshot of the supplied sequence; +the customizer objects themselves are not copied. `set_span_customizers(None)` or +an empty sequence disables customization. Omitting `span_customizers` (or passing +`None`) to `auto_instrument()` leaves existing configuration unchanged. There is no +environment-variable registration. + +`SpanCustomizer` is an extensible base class with a default no-op +`on_span_export(data)` method. Hooks run synchronously in registration order, each +receiving its predecessor's result. Return a plain `dict` containing the data to +export: either the input record or a replacement. Replacement is **not** a merge; +retain the content fields you want to export. Returning `None` cannot drop a span. + +Hooks apply only to **native instrumentation-created span records**, not manually +created spans (even children of instrumented spans), dataset rows, feedback, or +spans exported through a separate OpenTelemetry exporter. They run after lazy +values resolve and before attachment processing, merging, masking, and transport +serialization. Existing attachment objects retain their identity; hooks can +remove them before upload without copying their contents. + +A span can produce several incremental records, including records flushed before +it ends. Input, output, metrics, and errors need not be present together. Removing +a field does not retract an earlier export, so redact every record containing +sensitive data. Each record uses the configuration at its first export resolution. +Retries reuse the transformed record without calling hooks again. + +Identity, parentage, routing, and merge protocol fields are restored after every +hook, including nested protocol arrays. Protected fields are `id`, `span_id`, +`root_span_id`, `span_parents`, `org_id`, `project_id`, `experiment_id`, `dataset_id`, +`prompt_session_id`, `log_id`, `function_data`, `_is_merge`, `_merge_paths`, +`_parent_id`, `_object_delete`, `_array_delete`, and `_xact_id`. Hooks cannot add an +absent protected field or change the destination. + +**Failures are fail-open.** Exceptions, `None`, awaitables, and other invalid return +types are ignored; later hooks and export continue with the current record. +In-place content mutations made before failure are retained, not rolled back. +An asynchronous hook is not awaited. A failing redaction hook can therefore leak +unredacted data: never throw to block export. Returned content must remain valid +for SDK serialization. Keep hooks fast, avoid blocking I/O, and make them safe for +background export threads rather than assuming the caller's thread or context. + ## Optional Extras Install extras as needed for specific workflows: diff --git a/py/src/braintrust/__init__.py b/py/src/braintrust/__init__.py index 1e66fced9..83888f276 100644 --- a/py/src/braintrust/__init__.py +++ b/py/src/braintrust/__init__.py @@ -85,5 +85,8 @@ def is_equal(expected, output): from .sandbox import RegisterSandboxResult as RegisterSandboxResult from .sandbox import SandboxConfig as SandboxConfig from .sandbox import register_sandbox as register_sandbox +from .span_customizer import SpanCustomizer as SpanCustomizer +from .span_customizer import SpanExportData as SpanExportData +from .span_customizer import set_span_customizers as set_span_customizers from .util import BT_IS_ASYNC_ATTRIBUTE as BT_IS_ASYNC_ATTRIBUTE from .util import MarkAsyncWrapper as MarkAsyncWrapper diff --git a/py/src/braintrust/auto.py b/py/src/braintrust/auto.py index 77d64ab25..d0721813d 100644 --- a/py/src/braintrust/auto.py +++ b/py/src/braintrust/auto.py @@ -5,6 +5,7 @@ """ import logging +from collections.abc import Sequence from contextlib import contextmanager from braintrust.integrations import ( @@ -40,6 +41,7 @@ TypeSafeIntegration, ) from braintrust.integrations.base import BaseIntegration +from braintrust.span_customizer import SpanCustomizer, set_span_customizers __all__ = ["auto_instrument"] @@ -90,6 +92,7 @@ def auto_instrument( livekit_agents: bool = True, pipecat: bool = True, typesafe: bool = True, + span_customizers: Sequence[SpanCustomizer] | None = None, ) -> dict[str, bool]: """ Auto-instrument supported AI/ML libraries for Braintrust tracing. @@ -131,6 +134,9 @@ def auto_instrument( livekit_agents: Enable LiveKit Agents instrumentation (default: True) pipecat: Enable Pipecat AI instrumentation (default: True) typesafe: Enable TypeSafe instrumentation (default: True) + span_customizers: Ordered synchronous export customizers for instrumentation + spans. Copies and replaces the global list when provided; None leaves + existing configuration unchanged. Pass [] to disable. Returns: Dict mapping integration name to whether it was successfully instrumented. @@ -176,6 +182,9 @@ def auto_instrument( client.models.generate_content(model="gemini-2.0-flash", contents="Hello!") ``` """ + if span_customizers is not None: + set_span_customizers(span_customizers) + results: dict[str, bool] = {} if openai: diff --git a/py/src/braintrust/integrations/auto_test_scripts/test_auto_google_discoveryengine.py b/py/src/braintrust/integrations/auto_test_scripts/test_auto_google_discoveryengine.py index dd7fa19fe..54ad287e2 100644 --- a/py/src/braintrust/integrations/auto_test_scripts/test_auto_google_discoveryengine.py +++ b/py/src/braintrust/integrations/auto_test_scripts/test_auto_google_discoveryengine.py @@ -18,7 +18,11 @@ print("SUCCESS") sys.exit(0) -options = {name: False for name in inspect.signature(auto_instrument).parameters} +options = { + name: False + for name, parameter in inspect.signature(auto_instrument).parameters.items() + if isinstance(parameter.default, bool) +} assert auto_instrument(**options) == {} RankServiceClient = None if sys.argv[1] == "before": diff --git a/py/src/braintrust/integrations/pipecat/test_pipecat.py b/py/src/braintrust/integrations/pipecat/test_pipecat.py index b42764041..d60034a0c 100644 --- a/py/src/braintrust/integrations/pipecat/test_pipecat.py +++ b/py/src/braintrust/integrations/pipecat/test_pipecat.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from braintrust import logger +from braintrust import SpanCustomizer, logger, set_span_customizers from braintrust.integrations.pipecat import ( BraintrustPipecatObserver, PipecatIntegration, @@ -58,6 +58,45 @@ def _single_span(logs, name): return matches[0] +@pytest.mark.asyncio +async def test_span_customizer_redacts_incremental_tts_input(memory_logger): + TTSStartedFrame = _import("pipecat.frames.frames.TTSStartedFrame") + TTSTextFrame = _import("pipecat.frames.frames.TTSTextFrame") + TTSStoppedFrame = _import("pipecat.frames.frames.TTSStoppedFrame") + + class Redact(SpanCustomizer): + def on_span_export(self, data): + if "input" in data: + data["input"] = "[redacted]" + return data + + observer = BraintrustPipecatObserver() + set_span_customizers([Redact()]) + try: + await observer.on_pipeline_started() + await observer._handle_frame(TTSStartedFrame(context_id="ctx")) + first_frame = TTSTextFrame("private first", aggregated_by="sentence", context_id="ctx") + await observer._handle_frame(first_frame) + initial = memory_logger.pop() + pipeline = _single_span(initial, "pipecat_pipeline") + tts = _single_span(initial, "tts_response") + assert tts["input"] == "[redacted]" + assert tts["span_parents"] == [pipeline["span_id"]] + assert first_frame.text == "private first" + + second_frame = TTSTextFrame("private second", aggregated_by="sentence", context_id="ctx") + await observer._handle_frame(second_frame) + await observer._handle_frame(TTSStoppedFrame(context_id="ctx")) + await observer.cleanup() + updates = memory_logger.pop() + updated_tts = next(row for row in updates if row["id"] == tts["id"]) + assert updated_tts["input"] == "[redacted]" + assert updated_tts["span_id"] == tts["span_id"] + assert second_frame.text == "private second" + finally: + set_span_customizers(None) + + def _pipeline_worker_kwargs(**overrides): PipelineWorker = _import("pipecat.pipeline.worker.PipelineWorker") signature = inspect.signature(PipelineWorker) diff --git a/py/src/braintrust/integrations/pipecat/tracing.py b/py/src/braintrust/integrations/pipecat/tracing.py index 1a56a712e..a60c04d2f 100644 --- a/py/src/braintrust/integrations/pipecat/tracing.py +++ b/py/src/braintrust/integrations/pipecat/tracing.py @@ -9,7 +9,18 @@ _pcm_to_wav, _resolve_audio_attachment_options, ) -from braintrust.logger import NOOP_SPAN, Attachment, SpanTypeAttribute, current_span, start_span +from braintrust.logger import NOOP_SPAN, Attachment, SpanTypeAttribute, current_span +from braintrust.logger import start_span as _bt_start_span + + +_INSTRUMENTATION = "pipecat-auto" + + +def start_span(*args, **kwargs): + internal = dict(kwargs.get("internal") or {}) + internal.setdefault("instrumentation", _INSTRUMENTATION) + kwargs["internal"] = internal + return _bt_start_span(*args, **kwargs) try: diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index f3c854ebb..fef75526b 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -99,6 +99,7 @@ ) from .queue import DEFAULT_QUEUE_SIZE, LogQueue from .serializable_data_class import SerializableDataClass +from .span_customizer import _customize_span_export from .span_identifier_v3 import SpanComponentsV3, SpanObjectTypeV3 from .span_identifier_v4 import SpanComponentsV4 from .span_origin import SpanOriginEnvironment, detect_environment, merge_span_origin_context @@ -4933,7 +4934,7 @@ def log_internal(self, event: dict[str, Any] | None = None, internal_data: dict[ def compute_record() -> dict[str, Any]: exporter = _get_exporter() - return dict( + record = dict( **serializable_partial_record, **{k: v.get() for k, v in lazy_partial_record.items()}, **exporter( @@ -4941,6 +4942,12 @@ def compute_record() -> dict[str, Any]: object_id=self.parent_object_id.get(), ).object_id_fields(), ) + # Resolve and customize inside the cached LazyValue: every incremental + # instrumentation record is transformed once, before the background + # logger merges, masks, extracts attachments, or retries delivery. + if self._instrumentation != "braintrust-python-logger": + return _customize_span_export(record) + return record self.state.global_bg_logger().log(LazyValue(compute_record, use_mutex=False)) diff --git a/py/src/braintrust/span_customizer.py b/py/src/braintrust/span_customizer.py new file mode 100644 index 000000000..2550d2e9c --- /dev/null +++ b/py/src/braintrust/span_customizer.py @@ -0,0 +1,118 @@ +"""Synchronous transformations of native instrumentation export records.""" + +import inspect +from collections.abc import Sequence +from typing import Any + +from .db_fields import ( + ARRAY_DELETE_FIELD, + ID_FIELD, + IS_MERGE_FIELD, + MERGE_PATHS_FIELD, + OBJECT_DELETE_FIELD, + OBJECT_ID_KEYS, + PARENT_ID_FIELD, + TRANSACTION_ID_FIELD, +) + + +__all__ = ["SpanCustomizer", "SpanExportData", "set_span_customizers"] + +SpanExportData = dict[str, Any] +"""A native, possibly incremental span export record, not a live span.""" + + +class SpanCustomizer: + """Extensible hooks for instrumentation-created spans. Omitted hooks are no-ops.""" + + def on_span_export(self, data: SpanExportData) -> SpanExportData: + """Return the original or a replacement export record, synchronously. + + Hooks run in registration order after lazy values resolve, before merging, + attachment processing, masking, and serialization. Identity, routing, and + merge protocol fields are restored after each hook. Exceptions and invalid + returns fail open: in-place edits survive and later hooks still run. + """ + return data + + +_span_customizers: tuple[SpanCustomizer, ...] = () +_PROTECTED_FIELDS = frozenset( + ( + ID_FIELD, + "span_id", + "root_span_id", + "span_parents", + "org_id", + *OBJECT_ID_KEYS, + IS_MERGE_FIELD, + MERGE_PATHS_FIELD, + PARENT_ID_FIELD, + OBJECT_DELETE_FIELD, + ARRAY_DELETE_FIELD, + TRANSACTION_ID_FIELD, + ) +) + + +def set_span_customizers(customizers: Sequence[SpanCustomizer] | None) -> None: + """Replace the process-wide ordered customizer list with a snapshot. + + Configure before making instrumented calls; pass None or an empty sequence to + disable. The sequence is copied, but customizer instances are not. Each record + uses the configuration at its first export resolution, and retries reuse its + transformed data. There is no environment-variable registration. + """ + global _span_customizers + _span_customizers = tuple(customizers) if customizers is not None else () + + +def _copy_protocol_value(value: Any) -> Any: + # Copy only protocol containers. Payloads (especially Attachment objects) must + # retain their existing serialization behavior and must not be deep-copied. + if isinstance(value, list): + return [_copy_protocol_value(item) for item in value] + if isinstance(value, dict): + return {key: _copy_protocol_value(item) for key, item in value.items()} + return value + + +def _restore_protocol_fields(data: SpanExportData, protected: SpanExportData) -> SpanExportData: + restored = {key: value for key, value in data.items() if key not in _PROTECTED_FIELDS} + # Never expose the snapshot itself: a later hook may mutate nested arrays. + restored.update({key: _copy_protocol_value(value) for key, value in protected.items()}) + return restored + + +def _customize_span_export(data: SpanExportData) -> SpanExportData: + customizers = _span_customizers + if not customizers: + return data + + protected = None + for customizer in customizers: + candidate = data + try: + hook = getattr(customizer, "on_span_export", None) + if hook is None: + continue + if protected is None: + protected = {key: _copy_protocol_value(data[key]) for key in _PROTECTED_FIELDS if key in data} + # Protocol containers may alias span state; don't expose those to hooks. + data = _restore_protocol_fields(data, protected) + candidate = data + result = hook(data) + if inspect.isawaitable(result): + # Do not execute asynchronous hooks or emit unawaited coroutine warnings. + if inspect.iscoroutine(result): + result.close() + elif type(result) is dict: + candidate = result + except Exception: + # Deliberately fail open, retaining in-place changes. Never log the + # exception, since its message may itself contain sensitive payloads. + pass + + if protected is not None: + data = _restore_protocol_fields(candidate, protected) + return data diff --git a/py/src/braintrust/test_span_customizer.py b/py/src/braintrust/test_span_customizer.py new file mode 100644 index 000000000..4bb4540a5 --- /dev/null +++ b/py/src/braintrust/test_span_customizer.py @@ -0,0 +1,288 @@ +import inspect +import json +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from braintrust import Attachment, SpanCustomizer, SpanExportData, auto_instrument, logger, set_span_customizers +from braintrust.functions.stream import BraintrustJsonChunk, BraintrustStream +from braintrust.span_customizer import _customize_span_export +from braintrust.test_helpers import with_memory_logger # noqa: F401 # type: ignore[reportUnusedImport] +from braintrust.util import LazyValue + + +@pytest.fixture(autouse=True) +def reset_customizers(): + set_span_customizers(None) + try: + yield + finally: + set_span_customizers(None) + + +@pytest.fixture +def test_logger(): + metadata = logger.OrgProjectMetadata( + org_id="org", project=logger.ObjectMetadata(id="project", name="project", full_info={}) + ) + return logger.Logger(LazyValue(lambda: metadata, use_mutex=False)) + + +def test_order_replacement_and_registration_snapshot(): + observed = [] + + class Replace(SpanCustomizer): + def on_span_export(self, data: SpanExportData) -> SpanExportData: + # Reconfiguration during export applies only to subsequent records. + set_span_customizers([]) + return {"output": "redacted"} + + class Observe(SpanCustomizer): + def on_span_export(self, data: SpanExportData) -> SpanExportData: + observed.append(dict(data)) + data["metadata"] = {"safe": True} + return data + + customizers = [Replace(), Observe()] + set_span_customizers(customizers) + customizers.clear() + result = _customize_span_export({"id": "row", "input": "secret", "output": "secret"}) + assert observed == [{"id": "row", "output": "redacted"}] + assert result == {"id": "row", "output": "redacted", "metadata": {"safe": True}} + assert _customize_span_export({"output": "next"}) == {"output": "next"} + + +def test_protected_protocol_fields_restored_between_hooks(): + protected = { + "id": "row", + "span_id": "span", + "root_span_id": "root", + "span_parents": ["parent"], + "org_id": "org", + "project_id": "project", + "log_id": "g", + "function_data": {"nested": ["routing"]}, + "_is_merge": True, + "_merge_paths": [["metadata", "nested"]], + "_parent_id": "parent-row", + "_object_delete": False, + "_array_delete": [["tags", "private"]], + "_xact_id": "transaction", + } + original = deepcopy(protected) + observed = [] + + class Corrupt(SpanCustomizer): + def on_span_export(self, data: SpanExportData) -> SpanExportData: + data["span_parents"].append("wrong") + data["_merge_paths"][0].append("wrong") + data["_array_delete"][0].clear() + data["function_data"]["nested"].clear() + for key in protected: + data[key] = "wrong" + data["experiment_id"] = "wrong-destination" + data["dataset_id"] = "wrong-destination" + data["prompt_session_id"] = "wrong-destination" + return data + + class Observe(SpanCustomizer): + def on_span_export(self, data: SpanExportData) -> SpanExportData: + observed.append(deepcopy(data)) + # A second mutation must not poison the stored snapshot either. + data["_merge_paths"][0].clear() + data["span_parents"].clear() + return {"output": "safe"} + + set_span_customizers([Corrupt(), Observe()]) + result = _customize_span_export(protected) + assert observed == [original] + assert result == {**original, "output": "safe"} + assert protected == original + + +@pytest.mark.parametrize("failure", ["exception", "none", "list", "coroutine", "awaitable"]) +def test_fail_open_keeps_mutations_and_continues(failure): + observed = [] + coroutines = [] + + class AwaitableRecord(dict): + def __await__(self): + raise AssertionError("Hooks must not be awaited") + yield + + async def asynchronous_result(): + raise AssertionError("Async hook must not execute") + + class Broken(SpanCustomizer): + def on_span_export(self, data): + data["output"] = "redacted" + data["id"] = "wrong" + if failure == "exception": + raise ValueError("private exception text") + if failure == "none": + return None + if failure == "list": + return [] + if failure == "awaitable": + return AwaitableRecord(output="wrong") + coroutine = asynchronous_result() + coroutines.append(coroutine) + return coroutine + + class Next(SpanCustomizer): + def on_span_export(self, data): + observed.append(dict(data)) + return data + + set_span_customizers([Broken(), Next()]) + result = _customize_span_export({"id": "row", "output": "secret"}) + assert observed == [{"id": "row", "output": "redacted"}] + assert result == observed[0] + assert all(inspect.getcoroutinestate(c) == inspect.CORO_CLOSED for c in coroutines) + + +def test_native_scope_incremental_redaction_and_provider_isolation(with_memory_logger, test_logger): + seen = [] + + class Redact(SpanCustomizer): + def on_span_export(self, data): + seen.append((data["id"], set(data))) + for key in ("input", "output"): + if key in data: + data[key]["secret"] = "redacted" + data.pop("error", None) + return data + + set_span_customizers([Redact()]) + application_input = {"secret": "input"} + with test_logger.start_span(name="manual", input=application_input) as manual: + with manual.start_span( + name="instrumented", input=application_input, internal={"instrumentation": "test-auto"} + ) as span: + # Flush before completion to expose the incremental lifecycle. + first = with_memory_logger.pop() + assert next(row for row in first if row["id"] == span.id)["input"] == {"secret": "redacted"} + assert next(row for row in first if row["id"] == manual.id)["input"] == {"secret": "input"} + stream = BraintrustStream([BraintrustJsonChunk(data='{"secret":"output"}')]) + span.log(output=stream, error="private error") + span.set_attributes(name="renamed") + with span.start_span(name="manual child", input=application_input) as manual_child: + pass + span.log_feedback(expected="feedback value") + + metadata = logger.ProjectDatasetMetadata( + project=logger.ObjectMetadata(id="project", name="project", full_info={}), + dataset=logger.ObjectMetadata(id="dataset", name="dataset", full_info={}), + ) + dataset = logger.Dataset(LazyValue(lambda: metadata, use_mutex=False), legacy=False) + dataset_id = dataset.insert(input={"secret": "dataset"}) + rows = with_memory_logger.pop() + exported = next(row for row in rows if row["id"] == span.id) + assert exported["output"] == {"secret": "redacted"} + assert "error" not in exported + assert exported["expected"] == "feedback value" + assert exported["span_attributes"]["name"] == "renamed" + assert next(row for row in rows if row["id"] == manual_child.id)["input"] == application_input + assert next(row for row in rows if row["id"] == dataset_id)["input"] == {"secret": "dataset"} + assert application_input == {"secret": "input"} + assert stream.final_value() == {"secret": "output"} + assert [row_id for row_id, _ in seen] == [span.id] * 4 + assert ["input" in keys for _, keys in seen] == [True, False, False, False] + assert ["output" in keys for _, keys in seen] == [False, True, False, False] + assert not any("expected" in keys for _, keys in seen) + + +def test_customization_precedes_attachments_masking_and_reuses_records_on_retry( + monkeypatch, with_memory_logger, test_logger +): + attachment = Attachment(data=b"private", filename="private.txt", content_type="text/plain") + seen_attachments = [] + invocations = [] + + class Redact(SpanCustomizer): + def on_span_export(self, data): + invocations.append(data["id"]) + if "input" in data: + seen_attachments.append(data["input"]) + data["input"] = "redacted" + if "output" in data: + data["output"] = "redacted output" + return data + + set_span_customizers([Redact()]) + span = test_logger.start_span(input=attachment, internal={"instrumentation": "test-auto"}) + span.log(output="private output") + span.end() + pending = list(with_memory_logger.logs) + with_memory_logger.logs.clear() + + # A later lazy record fails once, after earlier records have been customized. + attempts = 0 + + def resolve_later_record(): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise RuntimeError("retry lazy resolution") + return {"id": "other", "project_id": "project", "log_id": "g", "output": "other"} + + pending.append(LazyValue(resolve_later_record, use_mutex=False)) + monkeypatch.setenv("BRAINTRUST_DISABLE_ATEXIT_FLUSH", "1") + monkeypatch.setattr(logger.time, "sleep", lambda _: None) + connection = MagicMock() + payloads = [] + + def send(_path, *, data): + payloads.append(data) + if len(payloads) == 1: + raise ConnectionError("retry transport") + return SimpleNamespace(ok=True) + + connection.post.side_effect = send + background = logger._HTTPBackgroundLogger(LazyValue(lambda: connection, use_mutex=False)) + background.num_tries = 2 + background.sync_flush = True + masked = [] + + def mask(value): + masked.append(value) + return value + + background.set_masking_function(mask) + background._max_request_size_result = {"max_request_size": 6_000_000, "can_use_overflow": False} + for item in pending: + background.queue.put(item) + background.flush() + + assert attempts == 2 + assert invocations == [span.id] * 3 + assert seen_attachments == [attachment] + assert seen_attachments[0] is attachment + assert "redacted" in masked + assert "redacted output" in masked + assert payloads[0] == payloads[1] + exported = next(row for row in json.loads(payloads[1])["rows"] if row["id"] == span.id) + assert exported["input"] == "redacted" + assert exported["output"] == "redacted output" + + +def test_auto_instrument_registration_and_disable(monkeypatch, with_memory_logger, test_logger): + # Registration should work without importing optional provider libraries. + monkeypatch.setattr("braintrust.auto._instrument_integration", lambda _: False) + + class Redact(SpanCustomizer): + def on_span_export(self, data): + if "input" in data: + data["input"] = "redacted" + return data + + auto_instrument(span_customizers=[Redact()]) + auto_instrument() # Omitted configuration must not reset customizers. + with test_logger.start_span(input="private", internal={"instrumentation": "test-auto"}): + pass + assert with_memory_logger.pop()[0]["input"] == "redacted" + auto_instrument(span_customizers=[]) + with test_logger.start_span(input="untouched", internal={"instrumentation": "test-auto"}): + pass + assert with_memory_logger.pop()[0]["input"] == "untouched" diff --git a/py/src/braintrust/wrappers/langchain.py b/py/src/braintrust/wrappers/langchain.py index c9eff1fad..b8656715f 100644 --- a/py/src/braintrust/wrappers/langchain.py +++ b/py/src/braintrust/wrappers/langchain.py @@ -54,6 +54,9 @@ def _start_span(self, parent_run_id, run_id, name: str | None, **kwargs: Any) -> else: parent_span = braintrust + internal = dict(kwargs.get("internal") or {}) + internal.setdefault("instrumentation", "langchain-auto") + kwargs["internal"] = internal span = parent_span.start_span(name=name, **kwargs) langchain_parent.set(span) self.spans[run_id] = span