diff --git a/packages/traceloop-sdk/README.md b/packages/traceloop-sdk/README.md index af482a5318..14a3c1c797 100644 --- a/packages/traceloop-sdk/README.md +++ b/packages/traceloop-sdk/README.md @@ -2,6 +2,25 @@ Traceloop’s Python SDK allows you to easily start monitoring and debugging your LLM execution. Tracing is done in a non-intrusive way, built on top of OpenTelemetry. You can choose to export the traces to Traceloop, or to your existing observability stack. +For agent-to-agent HTTP calls, use the opt-in W3C helpers to carry the current +trace across service boundaries: + +```python +from opentelemetry import trace +from traceloop.sdk import inject_trace_context, extract_trace_context + +headers = inject_trace_context() +remote_context = extract_trace_context(request.headers) +tracer = trace.get_tracer(__name__) + +with tracer.start_as_current_span("handle-agent-request", context=remote_context): + handle_request() +``` + +The helpers use the application's configured global propagator. OpenTelemetry +defaults to W3C Trace Context and Baggage; custom propagators passed through +`Traceloop.init(propagator=...)` remain supported. + ```python Traceloop.init(app_name="joke_generation_service") diff --git a/packages/traceloop-sdk/tests/test_propagation.py b/packages/traceloop-sdk/tests/test_propagation.py new file mode 100644 index 0000000000..06fca73a82 --- /dev/null +++ b/packages/traceloop-sdk/tests/test_propagation.py @@ -0,0 +1,36 @@ +from opentelemetry import context, trace +from opentelemetry.sdk.trace import TracerProvider + +from traceloop.sdk.propagation import extract_trace_context, inject_trace_context + + +def test_inject_and_extract_trace_context_round_trip(): + provider = TracerProvider() + tracer = provider.get_tracer("test") + carrier = {} + + with tracer.start_as_current_span("parent") as span: + inject_trace_context(carrier) + extracted = extract_trace_context(carrier) + extracted_span = trace.get_current_span(extracted) + + assert carrier["traceparent"].startswith("00-") + assert f"-{span.get_span_context().span_id:016x}-" in carrier["traceparent"] + assert extracted_span.get_span_context().trace_id == span.get_span_context().trace_id + + +def test_inject_accepts_an_existing_carrier(): + carrier = {"x-request-id": "request-1"} + result = inject_trace_context(carrier) + + assert result is carrier + assert result["x-request-id"] == "request-1" + + +def test_extract_does_not_mutate_current_context(): + carrier = {} + current = context.get_current() + + extracted = extract_trace_context(carrier) + + assert extracted is not current diff --git a/packages/traceloop-sdk/tests/test_sdk_initialization.py b/packages/traceloop-sdk/tests/test_sdk_initialization.py index 18ef5d7d0f..878b83705e 100644 --- a/packages/traceloop-sdk/tests/test_sdk_initialization.py +++ b/packages/traceloop-sdk/tests/test_sdk_initialization.py @@ -8,6 +8,8 @@ from traceloop.sdk.tracing.tracing import TracerWrapper from opentelemetry.sdk.trace.export import SimpleSpanProcessor, BatchSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.propagate import get_global_textmap, set_global_textmap +from opentelemetry.propagators.composite import CompositePropagator @pytest.fixture @@ -320,6 +322,20 @@ def test_use_attributes_defaults_to_true(isolated_tracer_wrapper): assert OpenAIConfig.use_legacy_attributes is True assert AnthropicConfig.use_legacy_attributes is True + +def test_init_without_propagator_preserves_global_configuration(isolated_tracer_wrapper): + original_propagator = get_global_textmap() + configured_propagator = CompositePropagator([]) + set_global_textmap(configured_propagator) + + try: + Traceloop.init(exporter=InMemorySpanExporter(), disable_batch=True) + + assert get_global_textmap() is configured_propagator + finally: + set_global_textmap(original_propagator) + + def test_both_exporter_and_processor_warns(): """Passing both exporter and processor is a mistake — the processor already wraps the exporter internally. We warn instead of silently dropping the exporter, and diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 6663429d99..029d2e62df 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -33,6 +33,10 @@ from typing import Dict from traceloop.sdk.client.client import Client from traceloop.sdk.associations.associations import AssociationProperty as AssociationProperty +from traceloop.sdk.propagation import ( + extract_trace_context as extract_trace_context, + inject_trace_context as inject_trace_context, +) class Traceloop: diff --git a/packages/traceloop-sdk/traceloop/sdk/propagation.py b/packages/traceloop-sdk/traceloop/sdk/propagation.py new file mode 100644 index 0000000000..1a6a5df1bb --- /dev/null +++ b/packages/traceloop-sdk/traceloop/sdk/propagation.py @@ -0,0 +1,19 @@ +"""Opt-in W3C trace-context helpers for agent-to-agent calls.""" + +from typing import Any, MutableMapping + +from opentelemetry import propagate +from opentelemetry.context import Context + + +def inject_trace_context(carrier: MutableMapping[str, Any] | None = None) -> MutableMapping[str, Any]: + """Inject the current W3C trace context into an HTTP-like carrier.""" + target: MutableMapping[str, Any] = carrier if carrier is not None else {} + propagate.inject(target) + return target + + +def extract_trace_context(carrier: Any) -> Context: + """Extract a remote W3C trace context without changing the current context.""" + return propagate.extract(carrier) + diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 8808f85eab..e5cddb59c0 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -162,7 +162,7 @@ def wrapped_on_end(span): obj.__spans_processor.on_start = obj._span_processor_on_start obj.__tracer_provider.add_span_processor(obj.__spans_processor) - if propagator: + if propagator is not None: set_global_textmap(propagator) # this makes sure otel context is propagated so we always want it