diff --git a/packages/traceloop-sdk/tests/test_sdk_initialization.py b/packages/traceloop-sdk/tests/test_sdk_initialization.py index 18ef5d7d0f..5f8969aa51 100644 --- a/packages/traceloop-sdk/tests/test_sdk_initialization.py +++ b/packages/traceloop-sdk/tests/test_sdk_initialization.py @@ -1,9 +1,11 @@ import json +import os import warnings import pytest from unittest.mock import patch from openai import OpenAI from traceloop.sdk import Traceloop +from traceloop.sdk.config import is_content_tracing_enabled from traceloop.sdk.decorators import workflow from traceloop.sdk.tracing.tracing import TracerWrapper from opentelemetry.sdk.trace.export import SimpleSpanProcessor, BatchSpanProcessor @@ -358,3 +360,87 @@ def probe(): del TracerWrapper.instance if saved_instance is not None: TracerWrapper.instance = saved_instance + + +@pytest.fixture +def isolated_trace_content_env(): + """Save/restore TRACELOOP_TRACE_CONTENT so trace_content tests don't leak. + + Also clears the var before yielding so tests start from a known-unset state — + otherwise a CI env that pre-sets TRACELOOP_TRACE_CONTENT could mask bugs in + tests that exercise the "env not set" default path.""" + saved = os.environ.pop("TRACELOOP_TRACE_CONTENT", None) + yield + if saved is None: + os.environ.pop("TRACELOOP_TRACE_CONTENT", None) + else: + os.environ["TRACELOOP_TRACE_CONTENT"] = saved + + +def test_trace_content_false_disables_content_tracing( + isolated_tracer_wrapper, isolated_trace_content_env +): + """trace_content=False must disable content capture regardless of how the env was set.""" + os.environ["TRACELOOP_TRACE_CONTENT"] = "true" + + Traceloop.init( + exporter=InMemorySpanExporter(), + disable_batch=True, + trace_content=False, + ) + + assert is_content_tracing_enabled() is False + assert os.environ["TRACELOOP_TRACE_CONTENT"] == "false" + + +def test_trace_content_true_overrides_env( + isolated_tracer_wrapper, isolated_trace_content_env +): + """An explicit trace_content=True must win over an env that disabled it — + otherwise the new arg would be weaker than the env var it is meant to expose.""" + os.environ["TRACELOOP_TRACE_CONTENT"] = "false" + + Traceloop.init( + exporter=InMemorySpanExporter(), + disable_batch=True, + trace_content=True, + ) + + assert is_content_tracing_enabled() is True + assert os.environ["TRACELOOP_TRACE_CONTENT"] == "true" + + +def test_trace_content_none_honors_env( + isolated_tracer_wrapper, isolated_trace_content_env +): + """When trace_content is omitted, the env var stays authoritative — existing + deployments that rely on TRACELOOP_TRACE_CONTENT must not change behavior.""" + os.environ["TRACELOOP_TRACE_CONTENT"] = "false" + + Traceloop.init(exporter=InMemorySpanExporter(), disable_batch=True) + + assert is_content_tracing_enabled() is False + assert os.environ["TRACELOOP_TRACE_CONTENT"] == "false" + + +def test_trace_content_override_is_sticky_across_inits( + isolated_tracer_wrapper, isolated_trace_content_env +): + """An explicit trace_content setting must persist across subsequent init() + calls that omit the arg. Once the env has been overwritten, trace_content=None + cannot restore the pre-init env value — the most recent explicit setting wins + until something external resets the env. This pins the documented behavior so + a future "save/restore env on init exit" refactor would force a docs update.""" + os.environ["TRACELOOP_TRACE_CONTENT"] = "false" + + Traceloop.init( + exporter=InMemorySpanExporter(), + disable_batch=True, + trace_content=True, + ) + assert os.environ["TRACELOOP_TRACE_CONTENT"] == "true" + + Traceloop.init(exporter=InMemorySpanExporter(), disable_batch=True) + + assert is_content_tracing_enabled() is True + assert os.environ["TRACELOOP_TRACE_CONTENT"] == "true" diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 6663429d99..fca71903bf 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -73,6 +73,7 @@ def init( endpoint_is_traceloop: Optional[bool] = False, use_attributes: Optional[bool] = None, use_legacy_attributes: Optional[bool] = None, + trace_content: Optional[bool] = None, ) -> Optional[Client]: """Initialize Traceloop tracing, metrics, and instrumentation. @@ -88,6 +89,30 @@ def init( events have nowhere to go and no prompt/completion data will be recorded. use_legacy_attributes: Deprecated alias for ``use_attributes``. Will be removed in a future release. + trace_content: Controls whether prompts/completions are captured by the + bundled instrumentations. If ``None`` (default), the value of the + ``TRACELOOP_TRACE_CONTENT`` environment variable is honored (defaults + to enabled). If ``True`` or ``False``, the argument explicitly overrides + the environment variable for the lifetime of the process. Use + ``False`` to disable prompt/completion capture when sensitive content + must not leave the host. + + Notes: + * This is implemented by writing ``TRACELOOP_TRACE_CONTENT`` into + ``os.environ``, so child processes and any other code reading + that variable will observe the override. + * The override is sticky: a subsequent ``Traceloop.init()`` call + with ``trace_content=None`` does not restore the previous + env-driven value — the most recent explicit ``True``/``False`` + keeps winning until the env var is changed externally. + * Per-span enablement via the ``override_enable_content_tracing`` + OTel context value still works when ``trace_content=False`` — + instrumentations treat env and context as OR, so individual + spans can opt back in while the global default stays off. + * Not applied when ``enabled=False`` or when tracing is disabled + via ``TRACELOOP_TRACING_ENABLED``: ``init()`` returns early + before the override would take effect, so the env var is left + untouched in those no-op paths. """ if use_attributes is not None and use_legacy_attributes is not None: raise TypeError( @@ -125,6 +150,9 @@ def init( print(Fore.YELLOW + "Tracing is disabled" + Fore.RESET) return + if trace_content is not None: + os.environ["TRACELOOP_TRACE_CONTENT"] = "true" if trace_content else "false" + enable_content_tracing = is_content_tracing_enabled() if exporter and processor: