From d3521ef3f262c8643a255b39dc431d2f43c79fd9 Mon Sep 17 00:00:00 2001 From: Steffen Schmitz Date: Tue, 21 Oct 2025 10:11:20 +0200 Subject: [PATCH 01/19] feat: propagate trace attributes onto all child spans on update (#1385) --- langfuse/_client/attributes.py | 1 - langfuse/_client/client.py | 103 ++++++++++- langfuse/_client/constants.py | 5 +- langfuse/_client/span.py | 11 +- langfuse/_client/span_processor.py | 61 ++++++- langfuse/_client/utils.py | 15 ++ tests/test_core_sdk.py | 276 +++++++++++++++++++++++++---- 7 files changed, 425 insertions(+), 47 deletions(-) diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index 5ae81000c..75c5645ea 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -18,7 +18,6 @@ ObservationTypeGenerationLike, ObservationTypeSpanLike, ) - from langfuse._utils.serializer import EventSerializer from langfuse.model import PromptClient from langfuse.types import MapValue, SpanLevel diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 09610567d..b6e361af5 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -16,6 +16,7 @@ Any, Callable, Dict, + Generator, List, Literal, Optional, @@ -27,8 +28,15 @@ import backoff import httpx -from opentelemetry import trace -from opentelemetry import trace as otel_trace_api +from opentelemetry import ( + baggage as otel_baggage_api, +) +from opentelemetry import ( + context as otel_context_api, +) +from opentelemetry import ( + trace as otel_trace_api, +) from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.util._decorator import ( @@ -39,6 +47,7 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.constants import ( + LANGFUSE_CORRELATION_CONTEXT_KEY, ObservationTypeGenerationLike, ObservationTypeLiteral, ObservationTypeLiteralNoEvent, @@ -69,7 +78,10 @@ LangfuseSpan, LangfuseTool, ) -from langfuse._client.utils import run_async_safely +from langfuse._client.utils import ( + get_attribute_key_from_correlation_context, + run_async_safely, +) from langfuse._utils import _get_timestamp from langfuse._utils.parse_error import handle_fern_exception from langfuse._utils.prompt_cache import PromptCache @@ -189,6 +201,7 @@ class Langfuse: _resources: Optional[LangfuseResourceManager] = None _mask: Optional[MaskFunction] = None _otel_tracer: otel_trace_api.Tracer + _host: str def __init__( self, @@ -348,6 +361,83 @@ def start_span( status_message=status_message, ) + @_agnosticcontextmanager + def correlation_context( + self, + correlation_context: Dict[str, str], + *, + as_baggage: bool = False, + ) -> Generator[None, None, None]: + """Create a context manager that propagates the given correlation_context to all spans within the context manager's scope. + + Args: + correlation_context (Dict[str, str]): Dictionary containing key-value pairs to be propagated + to all spans within the context manager's scope. Common keys include user_id, session_id, + and custom metadata. All values must be strings below 200 characters. + as_baggage (bool, optional): If True, stores the values in OpenTelemetry baggage + for cross-service propagation. If False, stores only in local context for + current-service propagation. Defaults to False. + + Returns: + Context manager that sets values on all spans created within its scope. + + Warning: + When as_baggage=True, the values will be included in HTTP headers of any + outbound requests made within this context. Only use this for non-sensitive + identifiers that are safe to transmit across service boundaries. + + Examples: + ```python + # Local context only (default) - pass context as dictionary + with langfuse.correlation_context({"session_id": "session_123"}): + with langfuse.start_as_current_span(name="process-request") as span: + # This span and all its children will have session_id="session_123" + child_span = langfuse.start_span(name="child-operation") + + # Multiple values in context dictionary + with langfuse.correlation_context({"user_id": "user_456", "experiment": "A"}): + # All spans will have both user_id and experiment attributes + span = langfuse.start_span(name="experiment-operation") + + # Cross-service propagation (use with caution) + with langfuse.correlation_context({"session_id": "session_123"}, as_baggage=True): + # session_id will be propagated to external service calls + response = requests.get("https://api.example.com/data") + ``` + """ + current_context = otel_context_api.get_current() + current_span = otel_trace_api.get_current_span() + + current_context = otel_context_api.set_value( + LANGFUSE_CORRELATION_CONTEXT_KEY, correlation_context, current_context + ) + + for key, value in correlation_context.items(): + if len(value) > 200: + langfuse_logger.warning( + f"Correlation context key '{key}' is over 200 characters ({len(value)} chars). Dropping value." + ) + continue + + attribute_key = get_attribute_key_from_correlation_context(key) + + if current_span is not None and current_span.is_recording(): + current_span.set_attribute(attribute_key, value) + + if as_baggage: + current_context = otel_baggage_api.set_baggage( + key, value, current_context + ) + + # Activate context, execute, and detach context + token = otel_context_api.attach(current_context) + + try: + yield + + finally: + otel_context_api.detach(token) + def start_as_current_span( self, *, @@ -1665,6 +1755,11 @@ def update_current_trace( span.update(output=response) ``` """ + warnings.warn( + "update_current_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ", + DeprecationWarning, + stacklevel=2, + ) if not self._tracing_enabled: langfuse_logger.debug( "Operation skipped: update_current_trace - Tracing is disabled or client is in no-op mode." @@ -1809,7 +1904,7 @@ def _create_remote_parent_span( is_remote=False, ) - return trace.NonRecordingSpan(span_context) + return otel_trace_api.NonRecordingSpan(span_context) def _is_valid_trace_id(self, trace_id: str) -> bool: pattern = r"^[0-9a-f]{32}$" diff --git a/langfuse/_client/constants.py b/langfuse/_client/constants.py index b699480c0..b385f0a8f 100644 --- a/langfuse/_client/constants.py +++ b/langfuse/_client/constants.py @@ -3,11 +3,14 @@ This module defines constants used throughout the Langfuse OpenTelemetry integration. """ -from typing import Literal, List, get_args, Union, Any +from typing import Any, List, Literal, Union, get_args + from typing_extensions import TypeAlias LANGFUSE_TRACER_NAME = "langfuse-sdk" +LANGFUSE_CORRELATION_CONTEXT_KEY = "langfuse.ctx.correlation" + """Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat""" ObservationTypeGenerationLike: TypeAlias = Literal[ diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index c078d995d..16a2bce35 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -13,9 +13,9 @@ and scoring integration specific to Langfuse's observability platform. """ +import warnings from datetime import datetime from time import time_ns -import warnings from typing import ( TYPE_CHECKING, Any, @@ -45,10 +45,10 @@ create_trace_attributes, ) from langfuse._client.constants import ( - ObservationTypeLiteral, ObservationTypeGenerationLike, - ObservationTypeSpanLike, + ObservationTypeLiteral, ObservationTypeLiteralNoEvent, + ObservationTypeSpanLike, get_observation_types_list, ) from langfuse.logger import langfuse_logger @@ -236,6 +236,11 @@ def update_trace( tags: List of tags to categorize the trace public: Whether the trace should be publicly accessible """ + warnings.warn( + "update_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ", + DeprecationWarning, + stacklevel=2, + ) if not self._otel_span.is_recording(): return self diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index 369d5ff9e..3e0c6df5c 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -15,17 +15,29 @@ import os from typing import Dict, List, Optional +from opentelemetry import baggage +from opentelemetry import context as context_api +from opentelemetry.context import Context from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace import ReadableSpan, Span from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import format_span_id -from langfuse._client.constants import LANGFUSE_TRACER_NAME +from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.constants import ( + LANGFUSE_CORRELATION_CONTEXT_KEY, + LANGFUSE_TRACER_NAME, +) from langfuse._client.environment_variables import ( LANGFUSE_FLUSH_AT, LANGFUSE_FLUSH_INTERVAL, LANGFUSE_OTEL_TRACES_EXPORT_PATH, ) -from langfuse._client.utils import span_formatter +from langfuse._client.utils import ( + correlation_context_to_attribute_map, + get_attribute_key_from_correlation_context, + span_formatter, +) from langfuse.logger import langfuse_logger from langfuse.version import __version__ as langfuse_version @@ -114,6 +126,49 @@ def __init__( else None, ) + def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None: + # Propagate correlation context to span + current_context = parent_context or context_api.get_current() + propagated_attributes = {} + + # Propagate correlation context in baggage + baggage_entries = baggage.get_all(context=current_context) + + for key, value in baggage_entries.items(): + if ( + key.startswith(LangfuseOtelSpanAttributes.TRACE_METADATA) + or key in correlation_context_to_attribute_map.values() + ): + propagated_attributes[key] = value + + # Propagate correlation context in OTEL context + correlation_context = ( + context_api.get_value(LANGFUSE_CORRELATION_CONTEXT_KEY, current_context) + or {} + ) + + if not isinstance(correlation_context, dict): + langfuse_logger.error( + f"Correlation context is not of type dict. Got type '{type(correlation_context)}'." + ) + + return super().on_start(span, parent_context) + + for key, value in correlation_context.items(): + attribute_key = get_attribute_key_from_correlation_context(key) + propagated_attributes[attribute_key] = value + + # Write attributes on span + if propagated_attributes: + for key, value in propagated_attributes.items(): + span.set_attribute(key, str(value)) + + langfuse_logger.debug( + f"Propagated {len(propagated_attributes)} attributes to span '{format_span_id(span.context.span_id)}': {propagated_attributes}" + ) + + return super().on_start(span, parent_context) + def on_end(self, span: ReadableSpan) -> None: # Only export spans that belong to the scoped project # This is important to not send spans to wrong project in multi-project setups diff --git a/langfuse/_client/utils.py b/langfuse/_client/utils.py index d34857ebd..340daddb6 100644 --- a/langfuse/_client/utils.py +++ b/langfuse/_client/utils.py @@ -13,6 +13,8 @@ from opentelemetry.sdk import util from opentelemetry.sdk.trace import ReadableSpan +from langfuse._client.attributes import LangfuseOtelSpanAttributes + def span_formatter(span: ReadableSpan) -> str: parent_id = ( @@ -125,3 +127,16 @@ async def my_async_function(): else: # Loop exists but not running, safe to use asyncio.run() return asyncio.run(coro) + + +correlation_context_to_attribute_map = { + "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, +} + + +def get_attribute_key_from_correlation_context(correlation_context_key: str) -> str: + return ( + correlation_context_to_attribute_map.get(correlation_context_key) + or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{correlation_context_key}" + ) diff --git a/tests/test_core_sdk.py b/tests/test_core_sdk.py index 26d11746c..829d9d971 100644 --- a/tests/test_core_sdk.py +++ b/tests/test_core_sdk.py @@ -338,7 +338,7 @@ def test_create_update_current_trace(): user_id="test", metadata={"key": "value"}, public=True, - input="test_input" + input="test_input", ) # Get trace ID for later reference trace_id = span.trace_id @@ -347,7 +347,9 @@ def test_create_update_current_trace(): sleep(1) # Update trace properties using update_current_trace - langfuse.update_current_trace(metadata={"key2": "value2"}, public=False, version="1.0") + langfuse.update_current_trace( + metadata={"key2": "value2"}, public=False, version="1.0" + ) # Ensure data is sent to the API langfuse.flush() @@ -1957,9 +1959,9 @@ def test_start_as_current_observation_types(): expected_types = {obs_type.upper() for obs_type in observation_types} | { "SPAN" } # includes parent span - assert expected_types.issubset(found_types), ( - f"Missing types: {expected_types - found_types}" - ) + assert expected_types.issubset( + found_types + ), f"Missing types: {expected_types - found_types}" # Verify each specific observation exists for obs_type in observation_types: @@ -2003,25 +2005,25 @@ def test_that_generation_like_properties_are_actually_created(): ) as obs: # Verify the properties are accessible on the observation object if hasattr(obs, "model"): - assert obs.model == test_model, ( - f"{obs_type} should have model property" - ) + assert ( + obs.model == test_model + ), f"{obs_type} should have model property" if hasattr(obs, "completion_start_time"): - assert obs.completion_start_time == test_completion_start_time, ( - f"{obs_type} should have completion_start_time property" - ) + assert ( + obs.completion_start_time == test_completion_start_time + ), f"{obs_type} should have completion_start_time property" if hasattr(obs, "model_parameters"): - assert obs.model_parameters == test_model_parameters, ( - f"{obs_type} should have model_parameters property" - ) + assert ( + obs.model_parameters == test_model_parameters + ), f"{obs_type} should have model_parameters property" if hasattr(obs, "usage_details"): - assert obs.usage_details == test_usage_details, ( - f"{obs_type} should have usage_details property" - ) + assert ( + obs.usage_details == test_usage_details + ), f"{obs_type} should have usage_details property" if hasattr(obs, "cost_details"): - assert obs.cost_details == test_cost_details, ( - f"{obs_type} should have cost_details property" - ) + assert ( + obs.cost_details == test_cost_details + ), f"{obs_type} should have cost_details property" langfuse.flush() @@ -2035,28 +2037,232 @@ def test_that_generation_like_properties_are_actually_created(): for obs in trace.observations if obs.name == f"test-{obs_type}" and obs.type == obs_type.upper() ] - assert len(observations) == 1, ( - f"Expected one {obs_type.upper()} observation, but found {len(observations)}" - ) + assert ( + len(observations) == 1 + ), f"Expected one {obs_type.upper()} observation, but found {len(observations)}" obs = observations[0] assert obs.model == test_model, f"{obs_type} should have model property" - assert obs.model_parameters == test_model_parameters, ( - f"{obs_type} should have model_parameters property" - ) + assert ( + obs.model_parameters == test_model_parameters + ), f"{obs_type} should have model_parameters property" # usage_details assert hasattr(obs, "usage_details"), f"{obs_type} should have usage_details" - assert obs.usage_details == dict(test_usage_details, total=30), ( - f"{obs_type} should persist usage_details" - ) # API adds total + assert obs.usage_details == dict( + test_usage_details, total=30 + ), f"{obs_type} should persist usage_details" # API adds total - assert obs.cost_details == test_cost_details, ( - f"{obs_type} should persist cost_details" - ) + assert ( + obs.cost_details == test_cost_details + ), f"{obs_type} should persist cost_details" # completion_start_time, because of time skew not asserting time - assert obs.completion_start_time is not None, ( - f"{obs_type} should persist completion_start_time property" - ) + assert ( + obs.completion_start_time is not None + ), f"{obs_type} should persist completion_start_time property" + + +def test_context_manager_user_propagation(): + """Test that user context manager propagates user_id to child spans.""" + langfuse = Langfuse() + + user_id = "test_user_123" + + with langfuse.start_as_current_span(name="parent-span") as parent_span: + with langfuse.correlation_context({"user_id": user_id}): + trace_id = parent_span.trace_id + + # Create child spans that should inherit user_id + child_span = langfuse.start_span(name="child-span") + child_span.end() + + # Create generation that should inherit user_id + generation = parent_span.start_generation(name="child-generation") + generation.end() + + langfuse.flush() + sleep(2) + + # Verify trace has user_id (child spans inherit via context propagation) + trace = get_api().trace.get(trace_id) + assert trace.user_id == user_id + + # Verify child observations were created and have user_id + child_observations = [ + obs + for obs in trace.observations + if obs.name in ["child-span", "child-generation"] + # Skip user.id validation as we currently drop it from the visible attributes server-side. + # and obs.metadata["attributes"]["user.id"] == user_id + ] + assert len(child_observations) == 2 + + +def test_context_manager_session_propagation(): + """Test that session context manager propagates session_id to child spans.""" + langfuse = Langfuse() + + session_id = "test_session_456" + + with langfuse.start_as_current_span(name="parent-span") as parent_span: + with langfuse.correlation_context({"session_id": session_id}): + trace_id = parent_span.trace_id + + # Create child spans that should inherit session_id + child_span = langfuse.start_span(name="child-span") + child_span.end() + + # Create nested context to test multiple levels + with langfuse.start_as_current_span(name="nested-span"): + grandchild_span = langfuse.start_span(name="grandchild-span") + grandchild_span.end() + + langfuse.flush() + sleep(2) + + # Verify trace has session_id + trace = get_api().trace.get(trace_id) + assert trace.session_id == session_id + + # Verify nested spans were created + nested_observations = [ + obs + for obs in trace.observations + if "span" in obs.name + # Skip session.id validation as we currently drop it from the visible attributes server-side. + # and obs.metadata["attributes"]["session.id"] == session_id + ] + assert len(nested_observations) >= 2 + + +def test_context_manager_metadata_propagation(): + """Test that metadata context manager propagates metadata to child spans.""" + langfuse = Langfuse() + + with langfuse.start_as_current_span(name="parent-span") as parent_span: + with langfuse.correlation_context( + { + "experiment": "A/B", + "version": "1.2.3", + "feature_flag": "enabled", + } + ): + trace_id = parent_span.trace_id + + # Create child spans that should inherit metadata + child_span = langfuse.start_span(name="child-span") + child_span.end() + + # Create generation that should inherit metadata + generation = parent_span.start_generation(name="child-generation") + generation.end() + + langfuse.flush() + sleep(2) + + # Verify trace has metadata + trace = get_api().trace.get(trace_id) + assert trace.metadata["experiment"] == "A/B" + assert trace.metadata["version"] == "1.2.3" + assert trace.metadata["feature_flag"] == "enabled" + + # Verify all observations have the metadata distributed as individual keys + for obs in trace.observations: + if obs.name in ["child-span", "child-generation", "parent-span"]: + # Check that metadata was set on the observation + assert hasattr(obs, "metadata"), f"Observation {obs.name} missing metadata" + assert ( + obs.metadata["experiment"] == "A/B" + ), f"Observation {obs.name} missing experiment metadata" + assert ( + obs.metadata["version"] == "1.2.3" + ), f"Observation {obs.name} missing version metadata" + assert ( + obs.metadata["feature_flag"] == "enabled" + ), f"Observation {obs.name} missing feature_flag metadata" + + +def test_context_manager_nested_contexts(): + """Test nested context managers with overrides and merging.""" + langfuse = Langfuse() + + with langfuse.start_as_current_span(name="outer-span") as outer_span: + with langfuse.correlation_context( + {"user_id": "user_1", "session_id": "session_1"} + ): + with langfuse.correlation_context({"env": "prod", "region": "us-east"}): + outer_trace_id = outer_span.trace_id + + # Create span in outer context + outer_child = langfuse.start_span(name="outer-child") + outer_child.end() + + nested_span = langfuse.start_span(name="nested-span") + nested_span.end() + + langfuse.flush() + sleep(2) + + # Verify trace was created with nested spans + trace = get_api().trace.get(outer_trace_id) + + # Verify trace-level properties from the context + assert trace.user_id == "user_1" + assert trace.session_id == "session_1" + assert trace.metadata["env"] == "prod" + assert trace.metadata["region"] == "us-east" + + # Verify child observations were created + child_observations = [ + obs for obs in trace.observations if "child" in obs.name or "nested" in obs.name + ] + assert len(child_observations) >= 2 + + # Verify specific child spans exist and have correct metadata + outer_child_obs = [obs for obs in trace.observations if obs.name == "outer-child"] + nested_span_obs = [obs for obs in trace.observations if obs.name == "nested-span"] + + assert len(outer_child_obs) == 1, "outer-child span should exist" + assert len(nested_span_obs) == 1, "nested-span should exist" + + +def test_context_manager_baggage_propagation(): + """Test context managers with as_baggage=True for cross-service propagation.""" + langfuse = Langfuse() + + # Test with baggage enabled (careful with sensitive data) + with langfuse.start_as_current_span(name="service-span") as span: + with langfuse.correlation_context( + {"session_id": "public_session_789"}, as_baggage=True + ): + with langfuse.correlation_context( + {"service": "api", "version": "v1.0"}, as_baggage=True + ): + trace_id = span.trace_id + + # Create child spans that inherit baggage context + child_span = langfuse.start_span(name="external-call-span") + child_span.end() + + langfuse.flush() + sleep(2) + + # Verify trace properties were set + trace = get_api().trace.get(trace_id) + assert trace.session_id == "public_session_789" + assert trace.metadata["service"] == "api" + assert trace.metadata["version"] == "v1.0" + + # Verify all observations have the metadata and session_id + for obs in trace.observations: + if obs.name in ["external-call-span", "service-span"]: + # Check that metadata was set on the observation + assert hasattr(obs, "metadata"), f"Observation {obs.name} missing metadata" + assert ( + obs.metadata["service"] == "api" + ), f"Observation {obs.name} missing service metadata" + assert ( + obs.metadata["version"] == "v1.0" + ), f"Observation {obs.name} missing version metadata" From 8b32cb1398de30fa9b96c97d38293e08d1613ec1 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:17:10 +0200 Subject: [PATCH 02/19] refactor to propagate_attributes --- langfuse/_client/client.py | 92 +------------ langfuse/_client/constants.py | 2 - langfuse/_client/propagation.py | 205 +++++++++++++++++++++++++++++ langfuse/_client/span.py | 8 +- langfuse/_client/span_processor.py | 50 +------ langfuse/_client/utils.py | 15 --- tests/test_core_sdk.py | 14 +- 7 files changed, 225 insertions(+), 161 deletions(-) create mode 100644 langfuse/_client/propagation.py diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index b6e361af5..3d965662b 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -16,7 +16,6 @@ Any, Callable, Dict, - Generator, List, Literal, Optional, @@ -28,12 +27,6 @@ import backoff import httpx -from opentelemetry import ( - baggage as otel_baggage_api, -) -from opentelemetry import ( - context as otel_context_api, -) from opentelemetry import ( trace as otel_trace_api, ) @@ -47,7 +40,6 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.constants import ( - LANGFUSE_CORRELATION_CONTEXT_KEY, ObservationTypeGenerationLike, ObservationTypeLiteral, ObservationTypeLiteralNoEvent, @@ -78,10 +70,7 @@ LangfuseSpan, LangfuseTool, ) -from langfuse._client.utils import ( - get_attribute_key_from_correlation_context, - run_async_safely, -) +from langfuse._client.utils import run_async_safely from langfuse._utils import _get_timestamp from langfuse._utils.parse_error import handle_fern_exception from langfuse._utils.prompt_cache import PromptCache @@ -361,83 +350,6 @@ def start_span( status_message=status_message, ) - @_agnosticcontextmanager - def correlation_context( - self, - correlation_context: Dict[str, str], - *, - as_baggage: bool = False, - ) -> Generator[None, None, None]: - """Create a context manager that propagates the given correlation_context to all spans within the context manager's scope. - - Args: - correlation_context (Dict[str, str]): Dictionary containing key-value pairs to be propagated - to all spans within the context manager's scope. Common keys include user_id, session_id, - and custom metadata. All values must be strings below 200 characters. - as_baggage (bool, optional): If True, stores the values in OpenTelemetry baggage - for cross-service propagation. If False, stores only in local context for - current-service propagation. Defaults to False. - - Returns: - Context manager that sets values on all spans created within its scope. - - Warning: - When as_baggage=True, the values will be included in HTTP headers of any - outbound requests made within this context. Only use this for non-sensitive - identifiers that are safe to transmit across service boundaries. - - Examples: - ```python - # Local context only (default) - pass context as dictionary - with langfuse.correlation_context({"session_id": "session_123"}): - with langfuse.start_as_current_span(name="process-request") as span: - # This span and all its children will have session_id="session_123" - child_span = langfuse.start_span(name="child-operation") - - # Multiple values in context dictionary - with langfuse.correlation_context({"user_id": "user_456", "experiment": "A"}): - # All spans will have both user_id and experiment attributes - span = langfuse.start_span(name="experiment-operation") - - # Cross-service propagation (use with caution) - with langfuse.correlation_context({"session_id": "session_123"}, as_baggage=True): - # session_id will be propagated to external service calls - response = requests.get("https://api.example.com/data") - ``` - """ - current_context = otel_context_api.get_current() - current_span = otel_trace_api.get_current_span() - - current_context = otel_context_api.set_value( - LANGFUSE_CORRELATION_CONTEXT_KEY, correlation_context, current_context - ) - - for key, value in correlation_context.items(): - if len(value) > 200: - langfuse_logger.warning( - f"Correlation context key '{key}' is over 200 characters ({len(value)} chars). Dropping value." - ) - continue - - attribute_key = get_attribute_key_from_correlation_context(key) - - if current_span is not None and current_span.is_recording(): - current_span.set_attribute(attribute_key, value) - - if as_baggage: - current_context = otel_baggage_api.set_baggage( - key, value, current_context - ) - - # Activate context, execute, and detach context - token = otel_context_api.attach(current_context) - - try: - yield - - finally: - otel_context_api.detach(token) - def start_as_current_span( self, *, @@ -1756,7 +1668,7 @@ def update_current_trace( ``` """ warnings.warn( - "update_current_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ", + "update_current_trace is deprecated and will be removed in a future version. Use `with langfuse.propagate_attributes(...)` instead. ", DeprecationWarning, stacklevel=2, ) diff --git a/langfuse/_client/constants.py b/langfuse/_client/constants.py index b385f0a8f..8438bff1b 100644 --- a/langfuse/_client/constants.py +++ b/langfuse/_client/constants.py @@ -9,8 +9,6 @@ LANGFUSE_TRACER_NAME = "langfuse-sdk" -LANGFUSE_CORRELATION_CONTEXT_KEY = "langfuse.ctx.correlation" - """Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat""" ObservationTypeGenerationLike: TypeAlias = Literal[ diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py new file mode 100644 index 000000000..733ef2889 --- /dev/null +++ b/langfuse/_client/propagation.py @@ -0,0 +1,205 @@ +from typing import Any, Dict, Generator, List, Literal, Optional, Union + +from opentelemetry import baggage +from opentelemetry import ( + baggage as otel_baggage_api, +) +from opentelemetry import ( + context as otel_context_api, +) +from opentelemetry import ( + trace as otel_trace_api, +) +from opentelemetry.util._decorator import _agnosticcontextmanager + +from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse.logger import langfuse_logger + +PropagatedKeys = Literal["user_id", "session_id", "metadata"] + + +@_agnosticcontextmanager +def propagate_attributes( + *, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + as_baggage: bool = False, +) -> Generator[Any, Any, Any]: + context = otel_context_api.get_current() + current_span = otel_trace_api.get_current_span() + + if user_id is not None: + context = _set_propagated_attribute( + key="user_id", + value=user_id, + context=context, + span=current_span, + as_baggage=as_baggage, + ) + + if session_id is not None: + context = _set_propagated_attribute( + key="session_id", + value=session_id, + context=context, + span=current_span, + as_baggage=as_baggage, + ) + + if metadata is not None: + context = _set_propagated_attribute( + key="metadata", + value=_validate_propagated_metadata(metadata), + context=context, + span=current_span, + as_baggage=as_baggage, + ) + + # Activate context, execute, and detach context + token = otel_context_api.attach(context=context) + + try: + yield + + finally: + otel_context_api.detach(token) + + +def _get_propagated_attributes_from_context( + context: otel_context_api.Context, +) -> Dict[str, str]: + propagated_attributes: Dict[str, str] = {} + + # Handle baggage + baggage_entries = baggage.get_all(context=context) + for baggage_key, baggage_value in baggage_entries.items(): + if baggage_key.startswith(LANGFUSE_BAGGAGE_PREFIX): + span_key = _get_span_key_from_baggage_key(baggage_key) + + if span_key: + propagated_attributes[span_key] = str(baggage_value) + + # Handle OTEL context + propagated_keys: List[PropagatedKeys] = ["user_id", "session_id", "metadata"] + + for key in propagated_keys: + context_key = _get_propagated_context_key(key) + value = otel_context_api.get_value(key=context_key, context=context) + + if isinstance(value, dict): + # Handle metadata + for k, v in value.items(): + span_key = f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{k}" + propagated_attributes[span_key] = v + + else: + span_key = { + "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, + "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + }.get(key, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}") + + propagated_attributes[span_key] = str(value) + + return propagated_attributes + + +def _set_propagated_attribute( + *, + key: PropagatedKeys, + value: Union[str, Dict[str, str]], + context: otel_context_api.Context, + span: otel_trace_api.Span, + as_baggage: bool, +) -> otel_context_api.Context: + # Get key names + context_key = _get_propagated_context_key(key) + span_key = _get_propagated_span_key(key) + baggage_key = _get_propagated_baggage_key(key) + + # Set in context + context = otel_context_api.set_value( + key=context_key, + value=value, + context=context, + ) + + # Set on current span + if span is not None and span.is_recording(): + if isinstance(value, dict): + # Handle metadata + for k, v in value.items(): + span.set_attribute( + key=f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{k}", + value=v, + ) + + else: + span.set_attribute(key=span_key, value=value) + + # Set on baggage + if as_baggage: + if isinstance(value, dict): + # Handle metadata + for k, v in value.items(): + context = otel_baggage_api.set_baggage( + name=f"{baggage_key}_{k}", value=v, context=context + ) + else: + context = otel_baggage_api.set_baggage( + name=baggage_key, value=value, context=context + ) + + return context + + +def _validate_propagated_metadata(metadata: Dict[str, str]) -> Dict[str, str]: + validated_metadata: Dict[str, str] = {} + + for key, value in metadata.items(): + if not isinstance(value, str): + langfuse_logger.warning( # type: ignore + f"Propagated attribute value of '{key}' not a string. Dropping value." + ) + continue + + if len(value) > 200: + langfuse_logger.warning( + f"Propagated attribute value of '{key}' is over 200 characters ({len(value)} chars). Dropping value." + ) + continue + + validated_metadata[key] = value + + return validated_metadata + + +def _get_propagated_context_key(key: PropagatedKeys) -> str: + return f"langfuse.propagated.{key}" + + +LANGFUSE_BAGGAGE_PREFIX = "langfuse_" + + +def _get_propagated_baggage_key(key: PropagatedKeys) -> str: + return f"{LANGFUSE_BAGGAGE_PREFIX}{key}" + + +def _get_span_key_from_baggage_key(key: str) -> Optional[str]: + if not key.startswith(LANGFUSE_BAGGAGE_PREFIX): + return None + + if "user_id" in key: + return LangfuseOtelSpanAttributes.TRACE_USER_ID + + if "session_id" in key: + return LangfuseOtelSpanAttributes.TRACE_SESSION_ID + + return f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + + +def _get_propagated_span_key(key: PropagatedKeys) -> str: + return { + "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, + }.get(key) or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 16a2bce35..030884d8d 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -190,7 +190,9 @@ def __init__( {k: v for k, v in attributes.items() if v is not None} ) # Set OTEL span status if level is ERROR - self._set_otel_span_status_if_error(level=level, status_message=status_message) + self._set_otel_span_status_if_error( + level=level, status_message=status_message + ) def end(self, *, end_time: Optional[int] = None) -> "LangfuseObservationWrapper": """End the span, marking it as completed. @@ -237,7 +239,7 @@ def update_trace( public: Whether the trace should be publicly accessible """ warnings.warn( - "update_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ", + "update_trace is deprecated and will be removed in a future version. Use `with langfuse.propagate_attributes(...)` instead. ", DeprecationWarning, stacklevel=2, ) @@ -549,7 +551,7 @@ def _process_media_in_attribute( return data def _set_otel_span_status_if_error( - self, *, level: Optional[SpanLevel] = None, status_message: Optional[str] = None + self, *, level: Optional[SpanLevel] = None, status_message: Optional[str] = None ) -> None: """Set OpenTelemetry span status to ERROR if level is ERROR. diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index 3e0c6df5c..c28f38f40 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -15,7 +15,6 @@ import os from typing import Dict, List, Optional -from opentelemetry import baggage from opentelemetry import context as context_api from opentelemetry.context import Context from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter @@ -23,21 +22,14 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import format_span_id -from langfuse._client.attributes import LangfuseOtelSpanAttributes -from langfuse._client.constants import ( - LANGFUSE_CORRELATION_CONTEXT_KEY, - LANGFUSE_TRACER_NAME, -) +from langfuse._client.constants import LANGFUSE_TRACER_NAME from langfuse._client.environment_variables import ( LANGFUSE_FLUSH_AT, LANGFUSE_FLUSH_INTERVAL, LANGFUSE_OTEL_TRACES_EXPORT_PATH, ) -from langfuse._client.utils import ( - correlation_context_to_attribute_map, - get_attribute_key_from_correlation_context, - span_formatter, -) +from langfuse._client.propagation import _get_propagated_attributes_from_context +from langfuse._client.utils import span_formatter from langfuse.logger import langfuse_logger from langfuse.version import __version__ as langfuse_version @@ -127,41 +119,11 @@ def __init__( ) def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None: - # Propagate correlation context to span - current_context = parent_context or context_api.get_current() - propagated_attributes = {} - - # Propagate correlation context in baggage - baggage_entries = baggage.get_all(context=current_context) - - for key, value in baggage_entries.items(): - if ( - key.startswith(LangfuseOtelSpanAttributes.TRACE_METADATA) - or key in correlation_context_to_attribute_map.values() - ): - propagated_attributes[key] = value - - # Propagate correlation context in OTEL context - correlation_context = ( - context_api.get_value(LANGFUSE_CORRELATION_CONTEXT_KEY, current_context) - or {} - ) - - if not isinstance(correlation_context, dict): - langfuse_logger.error( - f"Correlation context is not of type dict. Got type '{type(correlation_context)}'." - ) - - return super().on_start(span, parent_context) - - for key, value in correlation_context.items(): - attribute_key = get_attribute_key_from_correlation_context(key) - propagated_attributes[attribute_key] = value + context = parent_context or context_api.get_current() + propagated_attributes = _get_propagated_attributes_from_context(context) - # Write attributes on span if propagated_attributes: - for key, value in propagated_attributes.items(): - span.set_attribute(key, str(value)) + span.set_attributes(propagated_attributes) langfuse_logger.debug( f"Propagated {len(propagated_attributes)} attributes to span '{format_span_id(span.context.span_id)}': {propagated_attributes}" diff --git a/langfuse/_client/utils.py b/langfuse/_client/utils.py index 340daddb6..d34857ebd 100644 --- a/langfuse/_client/utils.py +++ b/langfuse/_client/utils.py @@ -13,8 +13,6 @@ from opentelemetry.sdk import util from opentelemetry.sdk.trace import ReadableSpan -from langfuse._client.attributes import LangfuseOtelSpanAttributes - def span_formatter(span: ReadableSpan) -> str: parent_id = ( @@ -127,16 +125,3 @@ async def my_async_function(): else: # Loop exists but not running, safe to use asyncio.run() return asyncio.run(coro) - - -correlation_context_to_attribute_map = { - "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, - "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, -} - - -def get_attribute_key_from_correlation_context(correlation_context_key: str) -> str: - return ( - correlation_context_to_attribute_map.get(correlation_context_key) - or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{correlation_context_key}" - ) diff --git a/tests/test_core_sdk.py b/tests/test_core_sdk.py index 829d9d971..f3f2261b8 100644 --- a/tests/test_core_sdk.py +++ b/tests/test_core_sdk.py @@ -2071,7 +2071,7 @@ def test_context_manager_user_propagation(): user_id = "test_user_123" with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.correlation_context({"user_id": user_id}): + with langfuse.propagate_attributes({"user_id": user_id}): trace_id = parent_span.trace_id # Create child spans that should inherit user_id @@ -2107,7 +2107,7 @@ def test_context_manager_session_propagation(): session_id = "test_session_456" with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.correlation_context({"session_id": session_id}): + with langfuse.propagate_attributes({"session_id": session_id}): trace_id = parent_span.trace_id # Create child spans that should inherit session_id @@ -2142,7 +2142,7 @@ def test_context_manager_metadata_propagation(): langfuse = Langfuse() with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.correlation_context( + with langfuse.propagate_attributes( { "experiment": "A/B", "version": "1.2.3", @@ -2189,10 +2189,10 @@ def test_context_manager_nested_contexts(): langfuse = Langfuse() with langfuse.start_as_current_span(name="outer-span") as outer_span: - with langfuse.correlation_context( + with langfuse.propagate_attributes( {"user_id": "user_1", "session_id": "session_1"} ): - with langfuse.correlation_context({"env": "prod", "region": "us-east"}): + with langfuse.propagate_attributes({"env": "prod", "region": "us-east"}): outer_trace_id = outer_span.trace_id # Create span in outer context @@ -2234,10 +2234,10 @@ def test_context_manager_baggage_propagation(): # Test with baggage enabled (careful with sensitive data) with langfuse.start_as_current_span(name="service-span") as span: - with langfuse.correlation_context( + with langfuse.propagate_attributes( {"session_id": "public_session_789"}, as_baggage=True ): - with langfuse.correlation_context( + with langfuse.propagate_attributes( {"service": "api", "version": "v1.0"}, as_baggage=True ): trace_id = span.trace_id From a0243efd0bd68b5396515ba380a30dc6e789a6de Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:35:24 +0200 Subject: [PATCH 03/19] push --- langfuse/_client/client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 3d965662b..d8a2637c7 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -27,9 +27,7 @@ import backoff import httpx -from opentelemetry import ( - trace as otel_trace_api, -) +from opentelemetry import trace as otel_trace_api from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.util._decorator import ( @@ -190,7 +188,6 @@ class Langfuse: _resources: Optional[LangfuseResourceManager] = None _mask: Optional[MaskFunction] = None _otel_tracer: otel_trace_api.Tracer - _host: str def __init__( self, From 717ca2d2e39edc924fd20e1728e1a18089c3f112 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:18:24 +0200 Subject: [PATCH 04/19] add docstrings --- langfuse/__init__.py | 2 + langfuse/_client/client.py | 70 ++++++++---- langfuse/_client/propagation.py | 182 +++++++++++++++++++++++++++----- langfuse/_client/span.py | 46 +++++++- 4 files changed, 249 insertions(+), 51 deletions(-) diff --git a/langfuse/__init__.py b/langfuse/__init__.py index b2b73b54b..f96b18bc8 100644 --- a/langfuse/__init__.py +++ b/langfuse/__init__.py @@ -7,6 +7,7 @@ from ._client.constants import ObservationTypeLiteral from ._client.get_client import get_client from ._client.observe import observe +from ._client.propagation import propagate_attributes from ._client.span import ( LangfuseAgent, LangfuseChain, @@ -26,6 +27,7 @@ "Langfuse", "get_client", "observe", + "propagate_attributes", "ObservationTypeLiteral", "LangfuseSpan", "LangfuseGeneration", diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index d8a2637c7..9364a377c 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -1629,9 +1629,44 @@ def update_current_trace( ) -> None: """Update the current trace with additional information. - This method updates the Langfuse trace that the current span belongs to. It's useful for - adding trace-level metadata like user ID, session ID, or tags that apply to - the entire Langfuse trace rather than just a single observation. + .. deprecated:: 3.9.0 + This method is deprecated and will be removed in a future version. + Use :func:`langfuse.propagate_attributes` instead. + + **Current behavior**: This method still works as expected - the Langfuse backend + handles setting trace-level attributes server-side. However, it will be removed + in a future version, so please migrate to ``propagate_attributes()``. + + **Why deprecated**: This method only sets attributes on a single span, which means + child spans created later won't have these attributes. This causes gaps when + using Langfuse aggregation queries (e.g., filtering by user_id or calculating + costs per session_id) because only the span with the attribute is included. + + **Migration**: Replace with ``propagate_attributes()`` to set attributes on ALL + child spans created within the context. Call it as early as possible in your trace: + + .. code-block:: python + + # OLD (deprecated) + with langfuse.start_as_current_span(name="handle-request") as span: + user = authenticate_user(request) + langfuse.update_current_trace( + user_id=user.id, + session_id=request.session_id + ) + # Child spans created here won't have user_id/session_id + response = process_request(request) + + # NEW (recommended) + with langfuse.start_as_current_span(name="handle-request"): + user = authenticate_user(request) + with langfuse.propagate_attributes( + user_id=user.id, + session_id=request.session_id, + metadata={"environment": "production"} + ): + # All child spans will have these attributes + response = process_request(request) Args: name: Updated name for the Langfuse trace @@ -1644,28 +1679,17 @@ def update_current_trace( tags: List of tags to categorize the Langfuse trace public: Whether the Langfuse trace should be publicly accessible - Example: - ```python - with langfuse.start_as_current_span(name="handle-request") as span: - # Get user information - user = authenticate_user(request) - - # Update trace with user context - langfuse.update_current_trace( - user_id=user.id, - session_id=request.session_id, - tags=["production", "web-app"] - ) - - # Continue processing - response = process_request(request) - - # Update span with results - span.update(output=response) - ``` + See Also: + :func:`langfuse.propagate_attributes`: Recommended replacement """ warnings.warn( - "update_current_trace is deprecated and will be removed in a future version. Use `with langfuse.propagate_attributes(...)` instead. ", + "update_current_trace() is deprecated and will be removed in a future version. " + "While it still works (handled server-side), it only sets attributes on a single span, " + "causing gaps in aggregation queries. " + "Migrate to `with langfuse.propagate_attributes(user_id=..., session_id=..., metadata={...})` " + "to propagate attributes to ALL child spans. Call propagate_attributes() as early " + "as possible in your trace for complete coverage. " + "See: https://langfuse.com/docs/sdk/python/decorators#trace-level-attributes", DeprecationWarning, stacklevel=2, ) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 733ef2889..8929e1ce3 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -1,3 +1,10 @@ +"""Attribute propagation utilities for Langfuse OpenTelemetry integration. + +This module provides the `propagate_attributes` context manager for setting trace-level +attributes (user_id, session_id, metadata) that automatically propagate to all child spans +within the context. +""" + from typing import Any, Dict, Generator, List, Literal, Optional, Union from opentelemetry import baggage @@ -26,10 +33,123 @@ def propagate_attributes( metadata: Optional[Dict[str, str]] = None, as_baggage: bool = False, ) -> Generator[Any, Any, Any]: + """Propagate trace-level attributes to all spans created within this context. + + This context manager sets attributes on the currently active span AND automatically + propagates them to all new child spans created within the context. This is the + recommended way to set trace-level attributes like user_id, session_id, and metadata + dimensions that should be consistently applied across all observations in a trace. + + **IMPORTANT**: Call this as early as possible within your trace/workflow. Only the + currently active span and spans created after entering this context will have these + attributes. Pre-existing spans will NOT be retroactively updated. + + **Why this matters**: Langfuse aggregation queries (e.g., total cost by user_id, + filtering by session_id) only include observations that have the attribute set. + If you call `propagate_attributes` late in your workflow, earlier spans won't be + included in aggregations for that attribute. + + Args: + user_id: User identifier to associate with all spans in this context. + Must be US-ASCII string, ≤200 characters. Use this to track which user + generated each trace and enable e.g. per-user cost/performance analysis. + session_id: Session identifier to associate with all spans in this context. + Must be US-ASCII string, ≤200 characters. Use this to group related traces + within a user session (e.g., a conversation thread, multi-turn interaction). + metadata: Additional key-value metadata to propagate to all spans. + - Keys and values must be US-ASCII strings + - All values must be ≤200 characters + - Use for dimensions like internal correlating identifiers + - AVOID: large payloads, sensitive data, non-string values (will be dropped with warning) + as_baggage: If True, propagates attributes using OpenTelemetry baggage for + cross-process/service propagation. **Security warning**: When enabled, + attribute values are added to HTTP headers on ALL outbound requests. + Only enable if values are safe to transmit via HTTP headers and you need + cross-service tracing. Default: False. + + Returns: + Context manager that propagates attributes to all child spans. + + Example: + Basic usage with user and session tracking: + + ```python + from langfuse import Langfuse + + langfuse = Langfuse() + + # Set attributes early in the trace + with langfuse.start_as_current_span(name="user_workflow") as span: + with langfuse.propagate_attributes( + user_id="user_123", + session_id="session_abc", + metadata={"experiment": "variant_a", "environment": "production"} + ): + # All spans created here will have user_id, session_id, and metadata + with langfuse.start_span(name="llm_call") as llm_span: + # This span inherits: user_id, session_id, experiment, environment + ... + + with langfuse.start_generation(name="completion") as gen: + # This span also inherits all attributes + ... + ``` + + Late propagation (anti-pattern): + + ```python + with langfuse.start_as_current_span(name="workflow") as span: + # These spans WON'T have user_id + early_span = langfuse.start_span(name="early_work") + early_span.end() + + # Set attributes in the middle + with langfuse.propagate_attributes(user_id="user_123"): + # Only spans created AFTER this point will have user_id + late_span = langfuse.start_span(name="late_work") + late_span.end() + + # Result: Aggregations by user_id will miss "early_work" span + ``` + + Cross-service propagation with baggage (advanced): + + ```python + # Service A - originating service + with langfuse.start_as_current_span(name="api_request"): + with langfuse.propagate_attributes( + user_id="user_123", + session_id="session_abc", + as_baggage=True # Propagate via HTTP headers + ): + # Make HTTP request to Service B + response = requests.get("https://service-b.example.com/api") + # user_id and session_id are now in HTTP headers + + # Service B - downstream service + # OpenTelemetry will automatically extract baggage from HTTP headers + # and propagate to spans in Service B + ``` + + Note: + - **Nesting**: Nesting `propagate_attributes` contexts is possible but + discouraged. Inner contexts will overwrite outer values for the same keys. + - **Migration**: This replaces the deprecated `update_trace()` and + `update_current_trace()` methods, which only set attributes on a single span + (causing aggregation gaps). Always use `propagate_attributes` for new code. + - **Validation**: All attribute values (user_id, session_id, metadata values) + must be strings ≤200 characters. Invalid values will be dropped with a + warning logged. Ensure values meet constraints before calling. + - **OpenTelemetry**: This uses OpenTelemetry context propagation under the hood, + making it compatible with other OTel-instrumented libraries. + + Raises: + No exceptions are raised. Invalid values are logged as warnings and dropped. + """ context = otel_context_api.get_current() current_span = otel_trace_api.get_current_span() - if user_id is not None: + if user_id is not None and _validate_propagated_string(user_id, "user_id"): context = _set_propagated_attribute( key="user_id", value=user_id, @@ -38,7 +158,7 @@ def propagate_attributes( as_baggage=as_baggage, ) - if session_id is not None: + if session_id is not None and _validate_propagated_string(session_id, "session_id"): context = _set_propagated_attribute( key="session_id", value=session_id, @@ -48,13 +168,20 @@ def propagate_attributes( ) if metadata is not None: - context = _set_propagated_attribute( - key="metadata", - value=_validate_propagated_metadata(metadata), - context=context, - span=current_span, - as_baggage=as_baggage, - ) + # Filter metadata to only include valid string values + validated_metadata: Dict[str, str] = {} + for key, value in metadata.items(): + if _validate_propagated_string(value, f"metadata.{key}"): + validated_metadata[key] = value + + if validated_metadata: + context = _set_propagated_attribute( + key="metadata", + value=validated_metadata, + context=context, + span=current_span, + as_baggage=as_baggage, + ) # Activate context, execute, and detach context token = otel_context_api.attach(context=context) @@ -87,6 +214,9 @@ def _get_propagated_attributes_from_context( context_key = _get_propagated_context_key(key) value = otel_context_api.get_value(key=context_key, context=context) + if value is None: + continue + if isinstance(value, dict): # Handle metadata for k, v in value.items(): @@ -153,25 +283,29 @@ def _set_propagated_attribute( return context -def _validate_propagated_metadata(metadata: Dict[str, str]) -> Dict[str, str]: - validated_metadata: Dict[str, str] = {} +def _validate_propagated_string(value: str, attribute_name: str) -> bool: + """Validate a propagated attribute string value. - for key, value in metadata.items(): - if not isinstance(value, str): - langfuse_logger.warning( # type: ignore - f"Propagated attribute value of '{key}' not a string. Dropping value." - ) - continue + Args: + value: The string value to validate + attribute_name: Name of the attribute for error messages - if len(value) > 200: - langfuse_logger.warning( - f"Propagated attribute value of '{key}' is over 200 characters ({len(value)} chars). Dropping value." - ) - continue + Returns: + True if valid, False otherwise (with warning logged) + """ + if not isinstance(value, str): + langfuse_logger.warning( # type: ignore + f"Propagated attribute '{attribute_name}' value is not a string. Dropping value." + ) + return False - validated_metadata[key] = value + if len(value) > 200: + langfuse_logger.warning( + f"Propagated attribute '{attribute_name}' value is over 200 characters ({len(value)} chars). Dropping value." + ) + return False - return validated_metadata + return True def _get_propagated_context_key(key: PropagatedKeys) -> str: diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 030884d8d..db4bfe380 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -223,9 +223,38 @@ def update_trace( ) -> "LangfuseObservationWrapper": """Update the trace that this span belongs to. - This method updates trace-level attributes of the trace that this span - belongs to. This is useful for adding or modifying trace-wide information - like user ID, session ID, or tags. + .. deprecated:: 3.9.0 + This method is deprecated and will be removed in a future version. + Use :func:`langfuse.propagate_attributes` instead. + + **Current behavior**: This method still works as expected - the Langfuse backend + handles setting trace-level attributes server-side. However, it will be removed + in a future version, so please migrate to ``propagate_attributes()``. + + **Why deprecated**: This method only sets attributes on a single span, which means + child spans created later won't have these attributes. This causes gaps when + using Langfuse aggregation queries (e.g., filtering by user_id or calculating + costs per session_id) because only the span with the attribute is included. + + **Migration**: Replace with ``propagate_attributes()`` to set attributes on ALL + child spans created within the context. Call it as early as possible in your trace: + + .. code-block:: python + + # OLD (deprecated) + with langfuse.start_as_current_span(name="workflow") as span: + span.update_trace(user_id="user_123", session_id="session_abc") + # Child spans won't have user_id/session_id + + # NEW (recommended) + with langfuse.start_as_current_span(name="workflow"): + with langfuse.propagate_attributes( + user_id="user_123", + session_id="session_abc", + metadata={"experiment": "variant_a"} + ): + # All child spans will have these attributes + pass Args: name: Updated name for the trace @@ -237,9 +266,18 @@ def update_trace( metadata: Additional metadata to associate with the trace tags: List of tags to categorize the trace public: Whether the trace should be publicly accessible + + See Also: + :func:`langfuse.propagate_attributes`: Recommended replacement """ warnings.warn( - "update_trace is deprecated and will be removed in a future version. Use `with langfuse.propagate_attributes(...)` instead. ", + "update_trace() is deprecated and will be removed in a future version. " + "While it still works (handled server-side), it only sets attributes on a single span, " + "causing gaps in aggregation queries. " + "Migrate to `with langfuse.propagate_attributes(user_id=..., session_id=..., metadata={...})` " + "to propagate attributes to ALL child spans. Call propagate_attributes() as early " + "as possible in your trace for complete coverage. " + "See: https://langfuse.com/docs/sdk/python/decorators#trace-level-attributes", DeprecationWarning, stacklevel=2, ) From 2ae5f6a3bddc4e2054fdce02742e709dff1507b1 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:15:36 +0200 Subject: [PATCH 05/19] add tests --- tests/test_otel.py | 93 ++- tests/test_propagate_attributes.py | 947 +++++++++++++++++++++++++++++ 2 files changed, 1015 insertions(+), 25 deletions(-) create mode 100644 tests/test_propagate_attributes.py diff --git a/tests/test_otel.py b/tests/test_otel.py index 623e866b5..c15f77cc0 100644 --- a/tests/test_otel.py +++ b/tests/test_otel.py @@ -950,13 +950,14 @@ def test_error_level_in_span_creation(self, langfuse_client, memory_exporter): span = langfuse_client.start_span( name="create-error-span", level="ERROR", - status_message="Initial error state" + status_message="Initial error state", ) span.end() # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "create-error-span" ] assert len(raw_spans) == 1, "Expected one span" @@ -964,6 +965,7 @@ def test_error_level_in_span_creation(self, langfuse_client, memory_exporter): # Verify OTEL span status was set to ERROR from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR assert raw_span.status.description == "Initial error state" @@ -972,7 +974,10 @@ def test_error_level_in_span_creation(self, langfuse_client, memory_exporter): span_data = spans[0] attributes = span_data["attributes"] assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "ERROR" - assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] == "Initial error state" + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] + == "Initial error state" + ) def test_error_level_in_span_update(self, langfuse_client, memory_exporter): """Test that OTEL span status is set to ERROR when updating spans to level='ERROR'.""" @@ -985,7 +990,8 @@ def test_error_level_in_span_update(self, langfuse_client, memory_exporter): # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "update-error-span" ] assert len(raw_spans) == 1, "Expected one span" @@ -993,6 +999,7 @@ def test_error_level_in_span_update(self, langfuse_client, memory_exporter): # Verify OTEL span status was set to ERROR from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR assert raw_span.status.description == "Updated to error state" @@ -1001,7 +1008,10 @@ def test_error_level_in_span_update(self, langfuse_client, memory_exporter): span_data = spans[0] attributes = span_data["attributes"] assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "ERROR" - assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] == "Updated to error state" + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] + == "Updated to error state" + ) def test_generation_error_level_in_creation(self, langfuse_client, memory_exporter): """Test that OTEL span status is set to ERROR when creating generations with level='ERROR'.""" @@ -1010,13 +1020,14 @@ def test_generation_error_level_in_creation(self, langfuse_client, memory_export name="create-error-generation", model="gpt-4", level="ERROR", - status_message="Generation failed during creation" + status_message="Generation failed during creation", ) generation.end() # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "create-error-generation" ] assert len(raw_spans) == 1, "Expected one span" @@ -1024,6 +1035,7 @@ def test_generation_error_level_in_creation(self, langfuse_client, memory_export # Verify OTEL span status was set to ERROR from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR assert raw_span.status.description == "Generation failed during creation" @@ -1032,24 +1044,28 @@ def test_generation_error_level_in_creation(self, langfuse_client, memory_export span_data = spans[0] attributes = span_data["attributes"] assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "ERROR" - assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] == "Generation failed during creation" + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] + == "Generation failed during creation" + ) def test_generation_error_level_in_update(self, langfuse_client, memory_exporter): """Test that OTEL span status is set to ERROR when updating generations to level='ERROR'.""" # Create a normal generation generation = langfuse_client.start_generation( - name="update-error-generation", - model="gpt-4", - level="INFO" + name="update-error-generation", model="gpt-4", level="INFO" ) # Update it to ERROR level - generation.update(level="ERROR", status_message="Generation failed during execution") + generation.update( + level="ERROR", status_message="Generation failed during execution" + ) generation.end() # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "update-error-generation" ] assert len(raw_spans) == 1, "Expected one span" @@ -1057,6 +1073,7 @@ def test_generation_error_level_in_update(self, langfuse_client, memory_exporter # Verify OTEL span status was set to ERROR from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR assert raw_span.status.description == "Generation failed during execution" @@ -1065,9 +1082,14 @@ def test_generation_error_level_in_update(self, langfuse_client, memory_exporter span_data = spans[0] attributes = span_data["attributes"] assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "ERROR" - assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] == "Generation failed during execution" + assert ( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] + == "Generation failed during execution" + ) - def test_non_error_levels_dont_set_otel_status(self, langfuse_client, memory_exporter): + def test_non_error_levels_dont_set_otel_status( + self, langfuse_client, memory_exporter + ): """Test that non-ERROR levels don't set OTEL span status to ERROR.""" # Test different non-error levels test_levels = ["INFO", "WARNING", "DEBUG", None] @@ -1084,16 +1106,18 @@ def test_non_error_levels_dont_set_otel_status(self, langfuse_client, memory_exp # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() - if s.name == span_name + s for s in memory_exporter.get_finished_spans() if s.name == span_name ] assert len(raw_spans) == 1, f"Expected one span for {span_name}" raw_span = raw_spans[0] # Verify OTEL span status was NOT set to ERROR from opentelemetry.trace.status import StatusCode + # Default status should be UNSET, not ERROR - assert raw_span.status.status_code != StatusCode.ERROR, f"Level {level} should not set ERROR status" + assert ( + raw_span.status.status_code != StatusCode.ERROR + ), f"Level {level} should not set ERROR status" def test_multiple_error_updates(self, langfuse_client, memory_exporter): """Test that multiple ERROR level updates work correctly.""" @@ -1110,7 +1134,8 @@ def test_multiple_error_updates(self, langfuse_client, memory_exporter): # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "multi-error-span" ] assert len(raw_spans) == 1, "Expected one span" @@ -1118,6 +1143,7 @@ def test_multiple_error_updates(self, langfuse_client, memory_exporter): # Verify OTEL span status shows the last error message from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR assert raw_span.status.description == "Second error" @@ -1129,7 +1155,8 @@ def test_error_without_status_message(self, langfuse_client, memory_exporter): # Get the raw OTEL spans to check the status raw_spans = [ - s for s in memory_exporter.get_finished_spans() + s + for s in memory_exporter.get_finished_spans() if s.name == "error-no-message-span" ] assert len(raw_spans) == 1, "Expected one span" @@ -1137,14 +1164,25 @@ def test_error_without_status_message(self, langfuse_client, memory_exporter): # Verify OTEL span status was set to ERROR even without description from opentelemetry.trace.status import StatusCode + assert raw_span.status.status_code == StatusCode.ERROR # Description should be None when no status_message provided assert raw_span.status.description is None - def test_different_observation_types_error_handling(self, langfuse_client, memory_exporter): + def test_different_observation_types_error_handling( + self, langfuse_client, memory_exporter + ): """Test that ERROR level setting works for different observation types.""" # Test different observation types - observation_types = ["agent", "tool", "chain", "retriever", "evaluator", "embedding", "guardrail"] + observation_types = [ + "agent", + "tool", + "chain", + "retriever", + "evaluator", + "embedding", + "guardrail", + ] # Create a parent span for child observations with langfuse_client.start_as_current_span(name="error-test-parent") as parent: @@ -1154,7 +1192,7 @@ def test_different_observation_types_error_handling(self, langfuse_client, memor name=f"error-{obs_type}", as_type=obs_type, level="ERROR", - status_message=f"{obs_type} failed" + status_message=f"{obs_type} failed", ) obs.end() @@ -1167,8 +1205,13 @@ def test_different_observation_types_error_handling(self, langfuse_client, memor raw_span = obs_spans[0] from opentelemetry.trace.status import StatusCode - assert raw_span.status.status_code == StatusCode.ERROR, f"{obs_type} should have ERROR status" - assert raw_span.status.description == f"{obs_type} failed", f"{obs_type} should have correct description" + + assert ( + raw_span.status.status_code == StatusCode.ERROR + ), f"{obs_type} should have ERROR status" + assert ( + raw_span.status.description == f"{obs_type} failed" + ), f"{obs_type} should have correct description" class TestAdvancedSpans(TestOTelBase): diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py new file mode 100644 index 000000000..3a68b1356 --- /dev/null +++ b/tests/test_propagate_attributes.py @@ -0,0 +1,947 @@ +"""Comprehensive tests for propagate_attributes functionality. + +This module tests the propagate_attributes context manager that allows setting +trace-level attributes (user_id, session_id, metadata) that automatically propagate +to all child spans within the context. +""" + +import concurrent.futures + +import pytest +from opentelemetry.instrumentation.threading import ThreadingInstrumentor + +from langfuse import propagate_attributes +from langfuse._client.attributes import LangfuseOtelSpanAttributes +from tests.test_otel import TestOTelBase + + +class TestPropagateAttributesBase(TestOTelBase): + """Base class for propagate_attributes tests with shared helper methods.""" + + @pytest.fixture + def langfuse_client(self, monkeypatch, tracer_provider, mock_processor_init): + """Create a mocked Langfuse client with explicit tracer_provider for testing.""" + from langfuse import Langfuse + + # Set environment variables + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "test-public-key") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "test-secret-key") + + # Create test client with explicit tracer_provider + client = Langfuse( + public_key="test-public-key", + secret_key="test-secret-key", + host="http://test-host", + tracing_enabled=True, + tracer_provider=tracer_provider, # Pass the test provider explicitly + ) + + yield client + + def get_span_by_name(self, memory_exporter, name: str) -> dict: + """Get single span by name (assert exactly one exists). + + Args: + memory_exporter: The in-memory span exporter fixture + name: The name of the span to retrieve + + Returns: + dict: The span data as a dictionary + + Raises: + AssertionError: If zero or more than one span with the name exists + """ + spans = self.get_spans_by_name(memory_exporter, name) + assert len(spans) == 1, f"Expected 1 span named '{name}', found {len(spans)}" + return spans[0] + + def verify_missing_attribute(self, span_data: dict, attr_key: str): + """Verify that a span does NOT have a specific attribute. + + Args: + span_data: The span data dictionary + attr_key: The attribute key to check for absence + + Raises: + AssertionError: If the attribute exists on the span + """ + attributes = span_data["attributes"] + assert ( + attr_key not in attributes + ), f"Attribute '{attr_key}' should NOT be on span '{span_data['name']}'" + + +class TestPropagateAttributesBasic(TestPropagateAttributesBase): + """Tests for basic propagate_attributes functionality.""" + + def test_user_id_propagates_to_child_spans(self, langfuse_client, memory_exporter): + """Verify user_id propagates to all child spans within context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="test_user_123"): + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + # Verify both children have user_id + child1_span = self.get_span_by_name(memory_exporter, "child-span-1") + self.verify_span_attribute( + child1_span, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "test_user_123", + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-span-2") + self.verify_span_attribute( + child2_span, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "test_user_123", + ) + + def test_session_id_propagates_to_child_spans( + self, langfuse_client, memory_exporter + ): + """Verify session_id propagates to all child spans within context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(session_id="session_abc"): + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + # Verify both children have session_id + child1_span = self.get_span_by_name(memory_exporter, "child-span-1") + self.verify_span_attribute( + child1_span, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "session_abc", + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-span-2") + self.verify_span_attribute( + child2_span, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "session_abc", + ) + + def test_metadata_propagates_to_child_spans(self, langfuse_client, memory_exporter): + """Verify metadata propagates to all child spans within context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + metadata={"experiment": "variant_a", "version": "1.0"} + ): + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + # Verify both children have metadata + child1_span = self.get_span_by_name(memory_exporter, "child-span-1") + self.verify_span_attribute( + child1_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "variant_a", + ) + self.verify_span_attribute( + child1_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + "1.0", + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-span-2") + self.verify_span_attribute( + child2_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "variant_a", + ) + self.verify_span_attribute( + child2_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + "1.0", + ) + + def test_all_attributes_propagate_together(self, langfuse_client, memory_exporter): + """Verify user_id, session_id, and metadata all propagate together.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + user_id="user_123", + session_id="session_abc", + metadata={"experiment": "test", "env": "prod"}, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child has all attributes + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session_abc" + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "test", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "prod", + ) + + +class TestPropagateAttributesHierarchy(TestPropagateAttributesBase): + """Tests for propagation across span hierarchies.""" + + def test_propagation_to_direct_children(self, langfuse_client, memory_exporter): + """Verify attributes propagate to all direct children.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user_123"): + child1 = langfuse_client.start_span(name="child-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-2") + child2.end() + + child3 = langfuse_client.start_span(name="child-3") + child3.end() + + # Verify all three children have user_id + for i in range(1, 4): + child_span = self.get_span_by_name(memory_exporter, f"child-{i}") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_propagation_to_grandchildren(self, langfuse_client, memory_exporter): + """Verify attributes propagate through multiple levels of nesting.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user_123", session_id="session_abc"): + with langfuse_client.start_as_current_span(name="child-span"): + grandchild = langfuse_client.start_span(name="grandchild-span") + grandchild.end() + + # Verify all three levels have attributes + parent_span = self.get_span_by_name(memory_exporter, "parent-span") + child_span = self.get_span_by_name(memory_exporter, "child-span") + grandchild_span = self.get_span_by_name(memory_exporter, "grandchild-span") + + for span in [parent_span, child_span, grandchild_span]: + self.verify_span_attribute( + span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + self.verify_span_attribute( + span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session_abc" + ) + + def test_propagation_across_observation_types( + self, langfuse_client, memory_exporter + ): + """Verify attributes propagate to different observation types.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user_123"): + # Create span + span = langfuse_client.start_span(name="test-span") + span.end() + + # Create generation + generation = langfuse_client.start_observation( + as_type="generation", name="test-generation" + ) + generation.end() + + # Verify both observation types have user_id + span_data = self.get_span_by_name(memory_exporter, "test-span") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + generation_data = self.get_span_by_name(memory_exporter, "test-generation") + self.verify_span_attribute( + generation_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + +class TestPropagateAttributesTiming(TestPropagateAttributesBase): + """Critical tests for early vs late propagation timing.""" + + def test_early_propagation_all_spans_covered( + self, langfuse_client, memory_exporter + ): + """Verify setting attributes early covers all child spans.""" + with langfuse_client.start_as_current_span(name="parent-span"): + # Set attributes BEFORE creating any children + with propagate_attributes(user_id="user_123"): + child1 = langfuse_client.start_span(name="child-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-2") + child2.end() + + child3 = langfuse_client.start_span(name="child-3") + child3.end() + + # Verify ALL children have user_id + for i in range(1, 4): + child_span = self.get_span_by_name(memory_exporter, f"child-{i}") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_late_propagation_only_future_spans_covered( + self, langfuse_client, memory_exporter + ): + """Verify late propagation only affects spans created after context entry.""" + with langfuse_client.start_as_current_span(name="parent-span"): + # Create child1 BEFORE propagate_attributes + child1 = langfuse_client.start_span(name="child-1") + child1.end() + + # NOW set attributes + with propagate_attributes(user_id="user_123"): + # Create child2 AFTER propagate_attributes + child2 = langfuse_client.start_span(name="child-2") + child2.end() + + # Verify: child1 does NOT have user_id, child2 DOES + child1_span = self.get_span_by_name(memory_exporter, "child-1") + self.verify_missing_attribute( + child1_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-2") + self.verify_span_attribute( + child2_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_current_span_gets_attributes(self, langfuse_client, memory_exporter): + """Verify the currently active span gets attributes when propagate_attributes is called.""" + with langfuse_client.start_as_current_span(name="parent-span"): + # Call propagate_attributes while parent-span is active + with propagate_attributes(user_id="user_123"): + pass + + # Verify parent span itself has the attribute + parent_span = self.get_span_by_name(memory_exporter, "parent-span") + self.verify_span_attribute( + parent_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_spans_outside_context_unaffected(self, langfuse_client, memory_exporter): + """Verify spans created outside context don't get attributes.""" + with langfuse_client.start_as_current_span(name="parent-span"): + # Span before context + span1 = langfuse_client.start_span(name="span-1") + span1.end() + + # Span inside context + with propagate_attributes(user_id="user_123"): + span2 = langfuse_client.start_span(name="span-2") + span2.end() + + # Span after context + span3 = langfuse_client.start_span(name="span-3") + span3.end() + + # Verify: only span2 has user_id + span1_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_missing_attribute( + span1_data, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + span2_data = self.get_span_by_name(memory_exporter, "span-2") + self.verify_span_attribute( + span2_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + span3_data = self.get_span_by_name(memory_exporter, "span-3") + self.verify_missing_attribute( + span3_data, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + +class TestPropagateAttributesValidation(TestPropagateAttributesBase): + """Tests for validation of propagated attribute values.""" + + def test_user_id_over_200_chars_dropped(self, langfuse_client, memory_exporter): + """Verify user_id over 200 characters is dropped with warning.""" + long_user_id = "x" * 201 + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id=long_user_id): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have user_id + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + def test_session_id_over_200_chars_dropped(self, langfuse_client, memory_exporter): + """Verify session_id over 200 characters is dropped with warning.""" + long_session_id = "y" * 201 + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(session_id=long_session_id): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have session_id + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID + ) + + def test_metadata_value_over_200_chars_dropped( + self, langfuse_client, memory_exporter + ): + """Verify metadata values over 200 characters are dropped with warning.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={"key": "z" * 201}): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have metadata.key + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key" + ) + + def test_exactly_200_chars_accepted(self, langfuse_client, memory_exporter): + """Verify exactly 200 characters is accepted (boundary test).""" + user_id_200 = "x" * 200 + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id=user_id_200): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child HAS user_id + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, user_id_200 + ) + + def test_201_chars_rejected(self, langfuse_client, memory_exporter): + """Verify 201 characters is rejected (boundary test).""" + user_id_201 = "x" * 201 + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id=user_id_201): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have user_id + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + def test_non_string_user_id_dropped(self, langfuse_client, memory_exporter): + """Verify non-string user_id is dropped with warning.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id=12345): # type: ignore + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have user_id + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): + """Verify mixed valid/invalid metadata - valid entries kept, invalid dropped.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + metadata={ + "valid_key": "valid_value", + "invalid_key": "x" * 201, # Too long + "another_valid": "ok", + } + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify: valid keys present, invalid key absent + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.valid_key", + "valid_value", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.another_valid", + "ok", + ) + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.invalid_key" + ) + + +class TestPropagateAttributesNesting(TestPropagateAttributesBase): + """Tests for nested propagate_attributes contexts.""" + + def test_nested_contexts_inner_overwrites(self, langfuse_client, memory_exporter): + """Verify inner context overwrites outer context values.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user1"): + # Create span in outer context + span1 = langfuse_client.start_span(name="span-1") + span1.end() + + # Inner context with different user_id + with propagate_attributes(user_id="user2"): + span2 = langfuse_client.start_span(name="span-2") + span2.end() + + # Verify: span1 has user1, span2 has user2 + span1_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span1_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + + span2_data = self.get_span_by_name(memory_exporter, "span-2") + self.verify_span_attribute( + span2_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user2" + ) + + def test_after_inner_context_outer_restored(self, langfuse_client, memory_exporter): + """Verify outer context is restored after exiting inner context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user1"): + # Span in outer context + span1 = langfuse_client.start_span(name="span-1") + span1.end() + + # Inner context + with propagate_attributes(user_id="user2"): + span2 = langfuse_client.start_span(name="span-2") + span2.end() + + # Back to outer context + span3 = langfuse_client.start_span(name="span-3") + span3.end() + + # Verify: span1 and span3 have user1, span2 has user2 + span1_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span1_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + + span2_data = self.get_span_by_name(memory_exporter, "span-2") + self.verify_span_attribute( + span2_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user2" + ) + + span3_data = self.get_span_by_name(memory_exporter, "span-3") + self.verify_span_attribute( + span3_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + + def test_nested_different_attributes(self, langfuse_client, memory_exporter): + """Verify nested contexts with different attributes merge correctly.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user1"): + # Inner context adds session_id + with propagate_attributes(session_id="session1"): + span = langfuse_client.start_span(name="span-1") + span.end() + + # Verify: span has BOTH user_id and session_id + span_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session1" + ) + + +class TestPropagateAttributesEdgeCases(TestPropagateAttributesBase): + """Tests for edge cases and unusual scenarios.""" + + def test_propagate_attributes_with_no_args(self, langfuse_client, memory_exporter): + """Verify calling propagate_attributes() with no args doesn't error.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Should not crash, spans created normally + child_span = self.get_span_by_name(memory_exporter, "child-span") + assert child_span is not None + + def test_none_values_ignored(self, langfuse_client, memory_exporter): + """Verify None values are ignored without error.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id=None, session_id=None, metadata=None): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Should not crash, no attributes set + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID + ) + + def test_empty_metadata_dict(self, langfuse_client, memory_exporter): + """Verify empty metadata dict doesn't cause errors.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={}): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Should not crash, no metadata attributes set + child_span = self.get_span_by_name(memory_exporter, "child-span") + assert child_span is not None + + def test_all_invalid_metadata_values(self, langfuse_client, memory_exporter): + """Verify all invalid metadata values results in no metadata attributes.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + metadata={ + "key1": "x" * 201, # Too long + "key2": "y" * 201, # Too long + } + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # No metadata attributes should be set + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key1" + ) + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key2" + ) + + def test_propagate_with_no_active_span(self, langfuse_client, memory_exporter): + """Verify propagate_attributes works even with no active span.""" + # Call propagate_attributes without creating a parent span first + with propagate_attributes(user_id="user_123"): + # Now create a span + with langfuse_client.start_as_current_span(name="span-1"): + pass + + # Should not crash, span should have user_id + span_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + +class TestPropagateAttributesFormat(TestPropagateAttributesBase): + """Tests for correct attribute formatting and naming.""" + + def test_user_id_uses_correct_attribute_name( + self, langfuse_client, memory_exporter + ): + """Verify user_id uses the correct OTel attribute name.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(user_id="user_123"): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + # Verify the exact attribute key is used + assert LangfuseOtelSpanAttributes.TRACE_USER_ID in child_span["attributes"] + assert ( + child_span["attributes"][LangfuseOtelSpanAttributes.TRACE_USER_ID] + == "user_123" + ) + + def test_session_id_uses_correct_attribute_name( + self, langfuse_client, memory_exporter + ): + """Verify session_id uses the correct OTel attribute name.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(session_id="session_abc"): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + # Verify the exact attribute key is used + assert LangfuseOtelSpanAttributes.TRACE_SESSION_ID in child_span["attributes"] + assert ( + child_span["attributes"][LangfuseOtelSpanAttributes.TRACE_SESSION_ID] + == "session_abc" + ) + + def test_metadata_keys_properly_prefixed(self, langfuse_client, memory_exporter): + """Verify metadata keys are properly prefixed with TRACE_METADATA.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + metadata={"experiment": "A", "version": "1.0", "env": "prod"} + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + attributes = child_span["attributes"] + + # Verify each metadata key is properly prefixed + expected_keys = [ + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + ] + + for key in expected_keys: + assert key in attributes, f"Expected key '{key}' not found in attributes" + + def test_multiple_metadata_keys_independent(self, langfuse_client, memory_exporter): + """Verify multiple metadata keys are stored as independent attributes.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={"k1": "v1", "k2": "v2", "k3": "v3"}): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + attributes = child_span["attributes"] + + # Verify all three are separate attributes with correct values + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k1"] == "v1" + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k2"] == "v2" + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k3"] == "v3" + + +class TestPropagateAttributesThreading(TestPropagateAttributesBase): + """Tests for propagate_attributes with ThreadPoolExecutor.""" + + @pytest.fixture(autouse=True) + def instrument_threading(self): + """Auto-instrument threading for all tests in this class.""" + instrumentor = ThreadingInstrumentor() + instrumentor.instrument() + yield + instrumentor.uninstrument() + + def test_propagation_with_threadpoolexecutor( + self, langfuse_client, memory_exporter + ): + """Verify attributes propagate from main thread to worker threads.""" + + def worker_function(span_name: str): + """Worker creates a span in thread pool.""" + span = langfuse_client.start_span(name=span_name) + span.end() + return span_name + + with langfuse_client.start_as_current_span(name="main-span"): + with propagate_attributes(user_id="main_user", session_id="main_session"): + # Execute work in thread pool + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [ + executor.submit(worker_function, f"worker-span-{i}") + for i in range(3) + ] + concurrent.futures.wait(futures) + + # Verify all worker spans have propagated attributes + for i in range(3): + worker_span = self.get_span_by_name(memory_exporter, f"worker-span-{i}") + self.verify_span_attribute( + worker_span, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "main_user", + ) + self.verify_span_attribute( + worker_span, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "main_session", + ) + + def test_propagation_isolated_between_threads( + self, langfuse_client, memory_exporter + ): + """Verify each thread's context is isolated from others.""" + + def create_trace_with_user(user_id: str): + """Create a trace with specific user_id.""" + with langfuse_client.start_as_current_span(name=f"trace-{user_id}"): + with propagate_attributes(user_id=user_id): + span = langfuse_client.start_span(name=f"span-{user_id}") + span.end() + + # Run two traces concurrently with different user_ids + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(create_trace_with_user, "user1") + future2 = executor.submit(create_trace_with_user, "user2") + concurrent.futures.wait([future1, future2]) + + # Verify each trace has the correct user_id (no mixing) + span1 = self.get_span_by_name(memory_exporter, "span-user1") + self.verify_span_attribute( + span1, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + + span2 = self.get_span_by_name(memory_exporter, "span-user2") + self.verify_span_attribute( + span2, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user2" + ) + + def test_nested_propagation_across_thread_boundary( + self, langfuse_client, memory_exporter + ): + """Verify nested spans across thread boundaries inherit attributes.""" + + def worker_creates_child(): + """Worker thread creates a child span.""" + child = langfuse_client.start_span(name="worker-child-span") + child.end() + + with langfuse_client.start_as_current_span(name="main-parent-span"): + with propagate_attributes(user_id="main_user"): + # Create span in main thread + main_child = langfuse_client.start_span(name="main-child-span") + main_child.end() + + # Create span in worker thread + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(worker_creates_child) + future.result() + + # Verify both spans (main and worker) have user_id + main_child_span = self.get_span_by_name(memory_exporter, "main-child-span") + self.verify_span_attribute( + main_child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" + ) + + worker_child_span = self.get_span_by_name(memory_exporter, "worker-child-span") + self.verify_span_attribute( + worker_child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" + ) + + def test_worker_thread_can_override_propagated_attrs( + self, langfuse_client, memory_exporter + ): + """Verify worker thread can override propagated attributes.""" + + def worker_overrides_user(): + """Worker thread sets its own user_id.""" + with propagate_attributes(user_id="worker_user"): + span = langfuse_client.start_span(name="worker-span") + span.end() + + with langfuse_client.start_as_current_span(name="main-span"): + with propagate_attributes(user_id="main_user"): + # Create span in main thread + main_span = langfuse_client.start_span(name="main-child-span") + main_span.end() + + # Worker overrides with its own user_id + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(worker_overrides_user) + future.result() + + # Verify: main span has main_user, worker span has worker_user + main_child = self.get_span_by_name(memory_exporter, "main-child-span") + self.verify_span_attribute( + main_child, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" + ) + + worker_span = self.get_span_by_name(memory_exporter, "worker-span") + self.verify_span_attribute( + worker_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "worker_user" + ) + + def test_multiple_workers_with_same_propagated_context( + self, langfuse_client, memory_exporter + ): + """Verify multiple workers all inherit same propagated context.""" + + def worker_function(worker_id: int): + """Worker creates a span.""" + span = langfuse_client.start_span(name=f"worker-{worker_id}") + span.end() + + with langfuse_client.start_as_current_span(name="main-span"): + with propagate_attributes(session_id="shared_session"): + # Submit 5 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(worker_function, i) for i in range(5)] + concurrent.futures.wait(futures) + + # Verify all 5 workers have same session_id + for i in range(5): + worker_span = self.get_span_by_name(memory_exporter, f"worker-{i}") + self.verify_span_attribute( + worker_span, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "shared_session", + ) + + def test_concurrent_traces_with_different_attributes( + self, langfuse_client, memory_exporter + ): + """Verify concurrent traces with different attributes don't mix.""" + + def create_trace(trace_id: int): + """Create a trace with unique user_id.""" + with langfuse_client.start_as_current_span(name=f"trace-{trace_id}"): + with propagate_attributes(user_id=f"user_{trace_id}"): + span = langfuse_client.start_span(name=f"span-{trace_id}") + span.end() + + # Create 10 traces concurrently + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(create_trace, i) for i in range(10)] + concurrent.futures.wait(futures) + + # Verify each trace has its correct user_id (no mixing) + for i in range(10): + span = self.get_span_by_name(memory_exporter, f"span-{i}") + self.verify_span_attribute( + span, LangfuseOtelSpanAttributes.TRACE_USER_ID, f"user_{i}" + ) + + def test_exception_in_worker_preserves_context( + self, langfuse_client, memory_exporter + ): + """Verify exception in worker doesn't corrupt main thread context.""" + + def worker_raises_exception(): + """Worker creates span then raises exception.""" + span = langfuse_client.start_span(name="worker-span") + span.end() + raise ValueError("Test exception") + + with langfuse_client.start_as_current_span(name="main-span"): + with propagate_attributes(user_id="main_user"): + # Create span before worker + span1 = langfuse_client.start_span(name="span-before") + span1.end() + + # Worker raises exception (catch it) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(worker_raises_exception) + try: + future.result() + except ValueError: + pass # Expected + + # Create span after exception + span2 = langfuse_client.start_span(name="span-after") + span2.end() + + # Verify both main thread spans still have correct user_id + span_before = self.get_span_by_name(memory_exporter, "span-before") + self.verify_span_attribute( + span_before, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" + ) + + span_after = self.get_span_by_name(memory_exporter, "span-after") + self.verify_span_attribute( + span_after, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" + ) From cd474fdeb9437c0c69794bbe2e17ebfb2c865c90 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:28:15 +0200 Subject: [PATCH 06/19] push --- tests/test_propagate_attributes.py | 205 +++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index 3a68b1356..aa6826520 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -945,3 +945,208 @@ def worker_raises_exception(): self.verify_span_attribute( span_after, LangfuseOtelSpanAttributes.TRACE_USER_ID, "main_user" ) + + +class TestPropagateAttributesCrossTracer(TestPropagateAttributesBase): + """Tests for propagate_attributes with different OpenTelemetry tracers.""" + + def test_different_tracer_spans_get_attributes( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify spans from different tracers get propagated attributes.""" + # Get a different tracer (not the Langfuse tracer) + other_tracer = tracer_provider.get_tracer("other-library", "1.0.0") + + with langfuse_client.start_as_current_span(name="langfuse-parent"): + with propagate_attributes(user_id="user_123", session_id="session_abc"): + # Create span with Langfuse tracer + langfuse_span = langfuse_client.start_span(name="langfuse-child") + langfuse_span.end() + + # Create span with different tracer + with other_tracer.start_as_current_span(name="other-library-span"): + pass + + # Verify both spans have the propagated attributes + langfuse_span_data = self.get_span_by_name(memory_exporter, "langfuse-child") + self.verify_span_attribute( + langfuse_span_data, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "user_123", + ) + self.verify_span_attribute( + langfuse_span_data, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "session_abc", + ) + + other_span_data = self.get_span_by_name(memory_exporter, "other-library-span") + self.verify_span_attribute( + other_span_data, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "user_123", + ) + self.verify_span_attribute( + other_span_data, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "session_abc", + ) + + def test_nested_spans_from_multiple_tracers( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify nested spans from multiple tracers all get propagated attributes.""" + tracer_a = tracer_provider.get_tracer("library-a", "1.0.0") + tracer_b = tracer_provider.get_tracer("library-b", "2.0.0") + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes( + user_id="user_123", metadata={"experiment": "cross_tracer"} + ): + # Create nested spans from different tracers + with tracer_a.start_as_current_span(name="library-a-span"): + with tracer_b.start_as_current_span(name="library-b-span"): + langfuse_leaf = langfuse_client.start_span(name="langfuse-leaf") + langfuse_leaf.end() + + # Verify all spans have the attributes + for span_name in ["library-a-span", "library-b-span", "langfuse-leaf"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "user_123", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "cross_tracer", + ) + + def test_other_tracer_span_before_propagate_context( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify spans created before propagate_attributes don't get attributes.""" + other_tracer = tracer_provider.get_tracer("other-library", "1.0.0") + + with langfuse_client.start_as_current_span(name="root"): + # Create span BEFORE propagate_attributes + with other_tracer.start_as_current_span(name="span-before"): + pass + + # NOW set attributes + with propagate_attributes(user_id="user_123"): + # Create span AFTER propagate_attributes + with other_tracer.start_as_current_span(name="span-after"): + pass + + # Verify: span-before does NOT have user_id, span-after DOES + span_before = self.get_span_by_name(memory_exporter, "span-before") + self.verify_missing_attribute( + span_before, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + span_after = self.get_span_by_name(memory_exporter, "span-after") + self.verify_span_attribute( + span_after, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_mixed_tracers_with_metadata( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify metadata propagates correctly to spans from different tracers.""" + other_tracer = tracer_provider.get_tracer("instrumented-library", "1.0.0") + + with langfuse_client.start_as_current_span(name="main"): + with propagate_attributes( + metadata={ + "env": "production", + "version": "2.0", + "feature_flag": "enabled", + } + ): + # Create spans from both tracers + langfuse_span = langfuse_client.start_span(name="langfuse-operation") + langfuse_span.end() + + with other_tracer.start_as_current_span(name="library-operation"): + pass + + # Verify both spans have all metadata + for span_name in ["langfuse-operation", "library-operation"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "production", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + "2.0", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.feature_flag", + "enabled", + ) + + def test_propagate_without_langfuse_parent( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify propagate_attributes works even when parent span is from different tracer.""" + other_tracer = tracer_provider.get_tracer("other-library", "1.0.0") + + # Parent span is from different tracer + with other_tracer.start_as_current_span(name="other-parent"): + with propagate_attributes(user_id="user_123", session_id="session_xyz"): + # Create children from both tracers + with other_tracer.start_as_current_span(name="other-child"): + pass + + langfuse_child = langfuse_client.start_span(name="langfuse-child") + langfuse_child.end() + + # Verify all spans have attributes (including non-Langfuse parent) + for span_name in ["other-parent", "other-child", "langfuse-child"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "user_123", + ) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "session_xyz", + ) + + def test_attributes_persist_across_tracer_changes( + self, langfuse_client, memory_exporter, tracer_provider + ): + """Verify attributes persist as execution moves between different tracers.""" + tracer_1 = tracer_provider.get_tracer("library-1", "1.0.0") + tracer_2 = tracer_provider.get_tracer("library-2", "1.0.0") + tracer_3 = tracer_provider.get_tracer("library-3", "1.0.0") + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes(user_id="persistent_user"): + # Bounce between different tracers + with tracer_1.start_as_current_span(name="step-1"): + pass + + with tracer_2.start_as_current_span(name="step-2"): + with tracer_3.start_as_current_span(name="step-3"): + pass + + langfuse_span = langfuse_client.start_span(name="step-4") + langfuse_span.end() + + # Verify all steps have the user_id + for step_name in ["step-1", "step-2", "step-3", "step-4"]: + span_data = self.get_span_by_name(memory_exporter, step_name) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "persistent_user", + ) From c334fdc019bc94359aa84729365886c7aec252ab Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:01:16 +0200 Subject: [PATCH 07/19] push --- tests/test_core_sdk.py | 204 ----------------------------------------- 1 file changed, 204 deletions(-) diff --git a/tests/test_core_sdk.py b/tests/test_core_sdk.py index f3f2261b8..81a874ae4 100644 --- a/tests/test_core_sdk.py +++ b/tests/test_core_sdk.py @@ -2062,207 +2062,3 @@ def test_that_generation_like_properties_are_actually_created(): assert ( obs.completion_start_time is not None ), f"{obs_type} should persist completion_start_time property" - - -def test_context_manager_user_propagation(): - """Test that user context manager propagates user_id to child spans.""" - langfuse = Langfuse() - - user_id = "test_user_123" - - with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.propagate_attributes({"user_id": user_id}): - trace_id = parent_span.trace_id - - # Create child spans that should inherit user_id - child_span = langfuse.start_span(name="child-span") - child_span.end() - - # Create generation that should inherit user_id - generation = parent_span.start_generation(name="child-generation") - generation.end() - - langfuse.flush() - sleep(2) - - # Verify trace has user_id (child spans inherit via context propagation) - trace = get_api().trace.get(trace_id) - assert trace.user_id == user_id - - # Verify child observations were created and have user_id - child_observations = [ - obs - for obs in trace.observations - if obs.name in ["child-span", "child-generation"] - # Skip user.id validation as we currently drop it from the visible attributes server-side. - # and obs.metadata["attributes"]["user.id"] == user_id - ] - assert len(child_observations) == 2 - - -def test_context_manager_session_propagation(): - """Test that session context manager propagates session_id to child spans.""" - langfuse = Langfuse() - - session_id = "test_session_456" - - with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.propagate_attributes({"session_id": session_id}): - trace_id = parent_span.trace_id - - # Create child spans that should inherit session_id - child_span = langfuse.start_span(name="child-span") - child_span.end() - - # Create nested context to test multiple levels - with langfuse.start_as_current_span(name="nested-span"): - grandchild_span = langfuse.start_span(name="grandchild-span") - grandchild_span.end() - - langfuse.flush() - sleep(2) - - # Verify trace has session_id - trace = get_api().trace.get(trace_id) - assert trace.session_id == session_id - - # Verify nested spans were created - nested_observations = [ - obs - for obs in trace.observations - if "span" in obs.name - # Skip session.id validation as we currently drop it from the visible attributes server-side. - # and obs.metadata["attributes"]["session.id"] == session_id - ] - assert len(nested_observations) >= 2 - - -def test_context_manager_metadata_propagation(): - """Test that metadata context manager propagates metadata to child spans.""" - langfuse = Langfuse() - - with langfuse.start_as_current_span(name="parent-span") as parent_span: - with langfuse.propagate_attributes( - { - "experiment": "A/B", - "version": "1.2.3", - "feature_flag": "enabled", - } - ): - trace_id = parent_span.trace_id - - # Create child spans that should inherit metadata - child_span = langfuse.start_span(name="child-span") - child_span.end() - - # Create generation that should inherit metadata - generation = parent_span.start_generation(name="child-generation") - generation.end() - - langfuse.flush() - sleep(2) - - # Verify trace has metadata - trace = get_api().trace.get(trace_id) - assert trace.metadata["experiment"] == "A/B" - assert trace.metadata["version"] == "1.2.3" - assert trace.metadata["feature_flag"] == "enabled" - - # Verify all observations have the metadata distributed as individual keys - for obs in trace.observations: - if obs.name in ["child-span", "child-generation", "parent-span"]: - # Check that metadata was set on the observation - assert hasattr(obs, "metadata"), f"Observation {obs.name} missing metadata" - assert ( - obs.metadata["experiment"] == "A/B" - ), f"Observation {obs.name} missing experiment metadata" - assert ( - obs.metadata["version"] == "1.2.3" - ), f"Observation {obs.name} missing version metadata" - assert ( - obs.metadata["feature_flag"] == "enabled" - ), f"Observation {obs.name} missing feature_flag metadata" - - -def test_context_manager_nested_contexts(): - """Test nested context managers with overrides and merging.""" - langfuse = Langfuse() - - with langfuse.start_as_current_span(name="outer-span") as outer_span: - with langfuse.propagate_attributes( - {"user_id": "user_1", "session_id": "session_1"} - ): - with langfuse.propagate_attributes({"env": "prod", "region": "us-east"}): - outer_trace_id = outer_span.trace_id - - # Create span in outer context - outer_child = langfuse.start_span(name="outer-child") - outer_child.end() - - nested_span = langfuse.start_span(name="nested-span") - nested_span.end() - - langfuse.flush() - sleep(2) - - # Verify trace was created with nested spans - trace = get_api().trace.get(outer_trace_id) - - # Verify trace-level properties from the context - assert trace.user_id == "user_1" - assert trace.session_id == "session_1" - assert trace.metadata["env"] == "prod" - assert trace.metadata["region"] == "us-east" - - # Verify child observations were created - child_observations = [ - obs for obs in trace.observations if "child" in obs.name or "nested" in obs.name - ] - assert len(child_observations) >= 2 - - # Verify specific child spans exist and have correct metadata - outer_child_obs = [obs for obs in trace.observations if obs.name == "outer-child"] - nested_span_obs = [obs for obs in trace.observations if obs.name == "nested-span"] - - assert len(outer_child_obs) == 1, "outer-child span should exist" - assert len(nested_span_obs) == 1, "nested-span should exist" - - -def test_context_manager_baggage_propagation(): - """Test context managers with as_baggage=True for cross-service propagation.""" - langfuse = Langfuse() - - # Test with baggage enabled (careful with sensitive data) - with langfuse.start_as_current_span(name="service-span") as span: - with langfuse.propagate_attributes( - {"session_id": "public_session_789"}, as_baggage=True - ): - with langfuse.propagate_attributes( - {"service": "api", "version": "v1.0"}, as_baggage=True - ): - trace_id = span.trace_id - - # Create child spans that inherit baggage context - child_span = langfuse.start_span(name="external-call-span") - child_span.end() - - langfuse.flush() - sleep(2) - - # Verify trace properties were set - trace = get_api().trace.get(trace_id) - assert trace.session_id == "public_session_789" - assert trace.metadata["service"] == "api" - assert trace.metadata["version"] == "v1.0" - - # Verify all observations have the metadata and session_id - for obs in trace.observations: - if obs.name in ["external-call-span", "service-span"]: - # Check that metadata was set on the observation - assert hasattr(obs, "metadata"), f"Observation {obs.name} missing metadata" - assert ( - obs.metadata["service"] == "api" - ), f"Observation {obs.name} missing service metadata" - assert ( - obs.metadata["version"] == "v1.0" - ), f"Observation {obs.name} missing version metadata" From 59bd33295270877b283b0eeeef60b1ee8630143d Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:18:57 +0200 Subject: [PATCH 08/19] push --- poetry.lock | 35 ++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 51f812200..378d67caf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1057,6 +1057,39 @@ opentelemetry-sdk = ">=1.38.0,<1.39.0" requests = ">=2.7,<3.0" typing-extensions = ">=4.5.0" +[[package]] +name = "opentelemetry-instrumentation" +version = "0.59b0" +description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee"}, + {file = "opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.4,<2.0" +opentelemetry-semantic-conventions = "0.59b0" +packaging = ">=18.0" +wrapt = ">=1.0.0,<2.0.0" + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.59b0" +description = "Thread context propagation support for OpenTelemetry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca"}, + {file = "opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.59b0" +wrapt = ">=1.0.0,<2.0.0" + [[package]] name = "opentelemetry-proto" version = "1.38.0" @@ -2755,4 +2788,4 @@ cffi = ["cffi (>=1.17,<2.0)", "cffi (>=2.0.0b)"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "cfda3b10ea654d3aa01a0d4292631380b85dcaa982e726b6e6f575f15d9a22da" +content-hash = "bb4ec20d58e29f5d71599357de616571eeae016818d5c246774f0c5bc01d3d0e" diff --git a/pyproject.toml b/pyproject.toml index c2aab6b1d..a3c892729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ langchain-openai = ">=0.0.5,<0.4" langchain = ">=1" langgraph = ">=1" autoevals = "^0.0.130" +opentelemetry-instrumentation-threading = "^0.59b0" [tool.poetry.group.docs.dependencies] pdoc = "^15.0.4" From da81030b4ac3923eb9be1941c2fd0dd1008a180a Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:28:01 +0200 Subject: [PATCH 09/19] push --- langfuse/_client/propagation.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 8929e1ce3..5804e3e91 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -323,13 +323,22 @@ def _get_span_key_from_baggage_key(key: str) -> Optional[str]: if not key.startswith(LANGFUSE_BAGGAGE_PREFIX): return None - if "user_id" in key: + # Remove prefix to get the actual key name + suffix = key[len(LANGFUSE_BAGGAGE_PREFIX) :] + + # Exact match for user_id and session_id + if suffix == "user_id": return LangfuseOtelSpanAttributes.TRACE_USER_ID - if "session_id" in key: + if suffix == "session_id": return LangfuseOtelSpanAttributes.TRACE_SESSION_ID - return f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + # Metadata keys have format: langfuse_metadata_{key_name} + if suffix.startswith("metadata_"): + metadata_key = suffix[len("metadata_") :] + return f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{metadata_key}" + + return None def _get_propagated_span_key(key: PropagatedKeys) -> str: From 784249ad101598b97e2d080a95812562c13fa236 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:39:49 +0200 Subject: [PATCH 10/19] push --- tests/test_propagate_attributes.py | 243 +++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index aa6826520..b527c8342 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -1150,3 +1150,246 @@ def test_attributes_persist_across_tracer_changes( LangfuseOtelSpanAttributes.TRACE_USER_ID, "persistent_user", ) + + +class TestPropagateAttributesBaggage(TestPropagateAttributesBase): + """Tests for as_baggage=True parameter and OpenTelemetry baggage propagation.""" + + def test_baggage_is_set_when_as_baggage_true(self, langfuse_client): + """Verify baggage entries are created with correct keys when as_baggage=True.""" + from opentelemetry import baggage + from opentelemetry import context as otel_context + + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + user_id="user_123", + session_id="session_abc", + metadata={"env": "test", "version": "2.0"}, + as_baggage=True, + ): + # Get current context and inspect baggage + current_context = otel_context.get_current() + baggage_entries = baggage.get_all(context=current_context) + + # Verify baggage entries exist with correct keys + assert "langfuse_user_id" in baggage_entries + assert baggage_entries["langfuse_user_id"] == "user_123" + + assert "langfuse_session_id" in baggage_entries + assert baggage_entries["langfuse_session_id"] == "session_abc" + + assert "langfuse_metadata_env" in baggage_entries + assert baggage_entries["langfuse_metadata_env"] == "test" + + assert "langfuse_metadata_version" in baggage_entries + assert baggage_entries["langfuse_metadata_version"] == "2.0" + + def test_spans_receive_attributes_from_baggage( + self, langfuse_client, memory_exporter + ): + """Verify child spans get attributes when parent uses as_baggage=True.""" + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + user_id="baggage_user", + session_id="baggage_session", + metadata={"source": "baggage"}, + as_baggage=True, + ): + # Create child span + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child span has all attributes + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "baggage_user" + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "baggage_session", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", + "baggage", + ) + + def test_baggage_disabled_by_default(self, langfuse_client): + """Verify as_baggage=False (default) doesn't create baggage entries.""" + from opentelemetry import baggage + from opentelemetry import context as otel_context + + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + user_id="user_123", + session_id="session_abc", + ): + # Get current context and inspect baggage + current_context = otel_context.get_current() + baggage_entries = baggage.get_all(context=current_context) + assert len(baggage_entries) == 0 + + def test_metadata_key_with_user_id_substring_doesnt_collide( + self, langfuse_client, memory_exporter + ): + """Verify metadata key containing 'user_id' substring doesn't map to TRACE_USER_ID.""" + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + metadata={"user_info": "some_data", "user_id_copy": "another"}, + as_baggage=True, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + + # Should NOT have TRACE_USER_ID attribute + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID + ) + + # Should have metadata attributes with correct keys + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_info", + "some_data", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_id_copy", + "another", + ) + + def test_metadata_key_with_session_substring_doesnt_collide( + self, langfuse_client, memory_exporter + ): + """Verify metadata key containing 'session_id' substring doesn't map to TRACE_SESSION_ID.""" + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + metadata={"session_data": "value1", "session_id_backup": "value2"}, + as_baggage=True, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + + # Should NOT have TRACE_SESSION_ID attribute + self.verify_missing_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID + ) + + # Should have metadata attributes with correct keys + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_data", + "value1", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_id_backup", + "value2", + ) + + def test_metadata_keys_extract_correctly_from_baggage( + self, langfuse_client, memory_exporter + ): + """Verify metadata keys are correctly formatted in baggage and extracted back.""" + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes( + metadata={ + "env": "production", + "region": "us-west", + "experiment_id": "exp_123", + }, + as_baggage=True, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + + # All metadata should be under TRACE_METADATA prefix + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "production", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-west", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment_id", + "exp_123", + ) + + def test_baggage_and_context_both_propagate(self, langfuse_client, memory_exporter): + """Verify attributes propagate when both baggage and context mechanisms are active.""" + with langfuse_client.start_as_current_span(name="parent"): + # Enable baggage + with propagate_attributes( + user_id="user_both", + session_id="session_both", + metadata={"source": "both"}, + as_baggage=True, + ): + # Create multiple levels of nesting + with langfuse_client.start_as_current_span(name="middle"): + child = langfuse_client.start_span(name="leaf") + child.end() + + # Verify all spans have attributes + for span_name in ["parent", "middle", "leaf"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_both" + ) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session_both" + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", + "both", + ) + + def test_baggage_survives_context_isolation(self, langfuse_client, memory_exporter): + """Simulate cross-process scenario: baggage persists when context is detached/reattached.""" + from opentelemetry import context as otel_context + + # Step 1: Create context with baggage + with langfuse_client.start_as_current_span(name="original-process"): + with propagate_attributes( + user_id="cross_process_user", + session_id="cross_process_session", + as_baggage=True, + ): + # Capture the context with baggage + context_with_baggage = otel_context.get_current() + + # Step 2: Simulate "remote" process by creating span in saved context + # This mimics what happens when receiving an HTTP request with baggage headers + token = otel_context.attach(context_with_baggage) + try: + with langfuse_client.start_as_current_span(name="remote-process"): + child = langfuse_client.start_span(name="remote-child") + child.end() + finally: + otel_context.detach(token) + + # Verify remote spans have the propagated attributes from baggage + remote_child = self.get_span_by_name(memory_exporter, "remote-child") + self.verify_span_attribute( + remote_child, + LangfuseOtelSpanAttributes.TRACE_USER_ID, + "cross_process_user", + ) + self.verify_span_attribute( + remote_child, + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + "cross_process_session", + ) From 53319aca3367ae7b71f399d538986bc6f7d8c70a Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:18:38 +0100 Subject: [PATCH 11/19] add version --- langfuse/_client/propagation.py | 30 +- langfuse/_client/span.py | 2 +- tests/test_propagate_attributes.py | 445 +++++++++++++++++++++++++++++ 3 files changed, 469 insertions(+), 8 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 5804e3e91..175fbdc01 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -22,7 +22,13 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse.logger import langfuse_logger -PropagatedKeys = Literal["user_id", "session_id", "metadata"] +PropagatedKeys = Literal["user_id", "session_id", "metadata", "version"] +propagated_keys: List[PropagatedKeys] = [ + "user_id", + "session_id", + "metadata", + "version", +] @_agnosticcontextmanager @@ -31,6 +37,7 @@ def propagate_attributes( user_id: Optional[str] = None, session_id: Optional[str] = None, metadata: Optional[Dict[str, str]] = None, + version: Optional[str] = None, as_baggage: bool = False, ) -> Generator[Any, Any, Any]: """Propagate trace-level attributes to all spans created within this context. @@ -61,6 +68,7 @@ def propagate_attributes( - All values must be ≤200 characters - Use for dimensions like internal correlating identifiers - AVOID: large payloads, sensitive data, non-string values (will be dropped with warning) + version: Version identfier for parts of your application that are independently versioned, e.g. agents as_baggage: If True, propagates attributes using OpenTelemetry baggage for cross-process/service propagation. **Security warning**: When enabled, attribute values are added to HTTP headers on ALL outbound requests. @@ -167,6 +175,15 @@ def propagate_attributes( as_baggage=as_baggage, ) + if version is not None and _validate_propagated_string(version, "version"): + context = _set_propagated_attribute( + key="version", + value=version, + context=context, + span=current_span, + as_baggage=as_baggage, + ) + if metadata is not None: # Filter metadata to only include valid string values validated_metadata: Dict[str, str] = {} @@ -208,8 +225,6 @@ def _get_propagated_attributes_from_context( propagated_attributes[span_key] = str(baggage_value) # Handle OTEL context - propagated_keys: List[PropagatedKeys] = ["user_id", "session_id", "metadata"] - for key in propagated_keys: context_key = _get_propagated_context_key(key) value = otel_context_api.get_value(key=context_key, context=context) @@ -224,10 +239,7 @@ def _get_propagated_attributes_from_context( propagated_attributes[span_key] = v else: - span_key = { - "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, - "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, - }.get(key, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}") + span_key = _get_propagated_span_key(key) propagated_attributes[span_key] = str(value) @@ -333,6 +345,9 @@ def _get_span_key_from_baggage_key(key: str) -> Optional[str]: if suffix == "session_id": return LangfuseOtelSpanAttributes.TRACE_SESSION_ID + if suffix == "version": + return LangfuseOtelSpanAttributes.VERSION + # Metadata keys have format: langfuse_metadata_{key_name} if suffix.startswith("metadata_"): metadata_key = suffix[len("metadata_") :] @@ -345,4 +360,5 @@ def _get_propagated_span_key(key: PropagatedKeys) -> str: return { "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, + "version": LangfuseOtelSpanAttributes.VERSION, }.get(key) or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index db4bfe380..ce3dd2d58 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -30,8 +30,8 @@ ) from opentelemetry import trace as otel_trace_api -from opentelemetry.util._decorator import _AgnosticContextManager from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util._decorator import _AgnosticContextManager from langfuse.model import PromptClient diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index b527c8342..91e30b607 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -1152,6 +1152,203 @@ def test_attributes_persist_across_tracer_changes( ) +class TestPropagateAttributesAsync(TestPropagateAttributesBase): + """Tests for propagate_attributes with async/await.""" + + @pytest.mark.asyncio + async def test_async_propagation_basic(self, langfuse_client, memory_exporter): + """Verify attributes propagate in async context.""" + + async def async_operation(): + """Async function that creates a span.""" + span = langfuse_client.start_span(name="async-span") + span.end() + + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes(user_id="async_user", session_id="async_session"): + await async_operation() + + # Verify async span has attributes + async_span = self.get_span_by_name(memory_exporter, "async-span") + self.verify_span_attribute( + async_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "async_user" + ) + self.verify_span_attribute( + async_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "async_session" + ) + + @pytest.mark.asyncio + async def test_async_nested_operations(self, langfuse_client, memory_exporter): + """Verify attributes propagate through nested async operations.""" + + async def level_3(): + span = langfuse_client.start_span(name="level-3-span") + span.end() + + async def level_2(): + span = langfuse_client.start_span(name="level-2-span") + span.end() + await level_3() + + async def level_1(): + span = langfuse_client.start_span(name="level-1-span") + span.end() + await level_2() + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes( + user_id="nested_user", metadata={"level": "nested"} + ): + await level_1() + + # Verify all levels have attributes + for span_name in ["level-1-span", "level-2-span", "level-3-span"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "nested_user" + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", + "nested", + ) + + @pytest.mark.asyncio + async def test_async_context_manager(self, langfuse_client, memory_exporter): + """Verify propagate_attributes works as context manager in async function.""" + with langfuse_client.start_as_current_span(name="parent"): + # propagate_attributes supports both sync and async contexts via regular 'with' + with propagate_attributes(user_id="async_ctx_user"): + span = langfuse_client.start_span(name="inside-async-ctx") + span.end() + + span_data = self.get_span_by_name(memory_exporter, "inside-async-ctx") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "async_ctx_user" + ) + + @pytest.mark.asyncio + async def test_multiple_async_tasks_concurrent( + self, langfuse_client, memory_exporter + ): + """Verify context isolation between concurrent async tasks.""" + import asyncio + + async def create_trace_with_user(user_id: str): + """Create a trace with specific user_id.""" + with langfuse_client.start_as_current_span(name=f"trace-{user_id}"): + with propagate_attributes(user_id=user_id): + await asyncio.sleep(0.01) # Simulate async work + span = langfuse_client.start_span(name=f"span-{user_id}") + span.end() + + # Run multiple traces concurrently + await asyncio.gather( + create_trace_with_user("user1"), + create_trace_with_user("user2"), + create_trace_with_user("user3"), + ) + + # Verify each trace has correct user_id (no mixing) + for user_id in ["user1", "user2", "user3"]: + span_data = self.get_span_by_name(memory_exporter, f"span-{user_id}") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, user_id + ) + + @pytest.mark.asyncio + async def test_async_with_sync_nested(self, langfuse_client, memory_exporter): + """Verify attributes propagate from async to sync code.""" + + def sync_operation(): + """Sync function called from async context.""" + span = langfuse_client.start_span(name="sync-in-async") + span.end() + + async def async_operation(): + """Async function that calls sync code.""" + span1 = langfuse_client.start_span(name="async-span") + span1.end() + sync_operation() + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes(user_id="mixed_user"): + await async_operation() + + # Verify both spans have attributes + async_span = self.get_span_by_name(memory_exporter, "async-span") + self.verify_span_attribute( + async_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "mixed_user" + ) + + sync_span = self.get_span_by_name(memory_exporter, "sync-in-async") + self.verify_span_attribute( + sync_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "mixed_user" + ) + + @pytest.mark.asyncio + async def test_async_exception_preserves_context( + self, langfuse_client, memory_exporter + ): + """Verify context is preserved even when async operation raises exception.""" + + async def failing_operation(): + """Async operation that raises exception.""" + span = langfuse_client.start_span(name="span-before-error") + span.end() + raise ValueError("Test error") + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes(user_id="error_user"): + span1 = langfuse_client.start_span(name="span-before-async") + span1.end() + + try: + await failing_operation() + except ValueError: + pass # Expected + + span2 = langfuse_client.start_span(name="span-after-error") + span2.end() + + # Verify all spans have attributes + for span_name in ["span-before-async", "span-before-error", "span-after-error"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "error_user" + ) + + @pytest.mark.asyncio + async def test_async_with_metadata(self, langfuse_client, memory_exporter): + """Verify metadata propagates correctly in async context.""" + + async def async_with_metadata(): + span = langfuse_client.start_span(name="async-metadata-span") + span.end() + + with langfuse_client.start_as_current_span(name="root"): + with propagate_attributes( + user_id="metadata_user", + metadata={"async": "true", "operation": "test"}, + ): + await async_with_metadata() + + span_data = self.get_span_by_name(memory_exporter, "async-metadata-span") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "metadata_user" + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.async", + "true", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.operation", + "test", + ) + + class TestPropagateAttributesBaggage(TestPropagateAttributesBase): """Tests for as_baggage=True parameter and OpenTelemetry baggage propagation.""" @@ -1393,3 +1590,251 @@ def test_baggage_survives_context_isolation(self, langfuse_client, memory_export LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "cross_process_session", ) + + +class TestPropagateAttributesVersion(TestPropagateAttributesBase): + """Tests for version parameter propagation.""" + + def test_version_propagates_to_child_spans(self, langfuse_client, memory_exporter): + """Verify version propagates to all child spans within context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version="v1.2.3"): + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + # Verify both children have version + child1_span = self.get_span_by_name(memory_exporter, "child-span-1") + self.verify_span_attribute( + child1_span, + LangfuseOtelSpanAttributes.VERSION, + "v1.2.3", + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-span-2") + self.verify_span_attribute( + child2_span, + LangfuseOtelSpanAttributes.VERSION, + "v1.2.3", + ) + + def test_version_with_user_and_session(self, langfuse_client, memory_exporter): + """Verify version works together with user_id and session_id.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + user_id="user_123", + session_id="session_abc", + version="2.0.0", + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child has all attributes + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session_abc" + ) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.VERSION, "2.0.0" + ) + + def test_version_with_metadata(self, langfuse_client, memory_exporter): + """Verify version works together with metadata.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + version="1.0.0", + metadata={"env": "production", "region": "us-east"}, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.VERSION, "1.0.0" + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "production", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-east", + ) + + def test_version_validation_over_200_chars(self, langfuse_client, memory_exporter): + """Verify version over 200 characters is dropped with warning.""" + long_version = "v" + "1.0.0" * 50 # Create a very long version string + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version=long_version): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have version + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute(child_span, LangfuseOtelSpanAttributes.VERSION) + + def test_version_exactly_200_chars(self, langfuse_client, memory_exporter): + """Verify exactly 200 character version is accepted.""" + version_200 = "v" * 200 + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version=version_200): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child HAS version + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.VERSION, version_200 + ) + + def test_version_nested_contexts_inner_overwrites( + self, langfuse_client, memory_exporter + ): + """Verify inner context overwrites outer version.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version="1.0.0"): + # Create span in outer context + span1 = langfuse_client.start_span(name="span-1") + span1.end() + + # Inner context with different version + with propagate_attributes(version="2.0.0"): + span2 = langfuse_client.start_span(name="span-2") + span2.end() + + # Back to outer context + span3 = langfuse_client.start_span(name="span-3") + span3.end() + + # Verify: span1 and span3 have version 1.0.0, span2 has 2.0.0 + span1_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span1_data, LangfuseOtelSpanAttributes.VERSION, "1.0.0" + ) + + span2_data = self.get_span_by_name(memory_exporter, "span-2") + self.verify_span_attribute( + span2_data, LangfuseOtelSpanAttributes.VERSION, "2.0.0" + ) + + span3_data = self.get_span_by_name(memory_exporter, "span-3") + self.verify_span_attribute( + span3_data, LangfuseOtelSpanAttributes.VERSION, "1.0.0" + ) + + def test_version_with_baggage(self, langfuse_client, memory_exporter): + """Verify version propagates through baggage.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + version="baggage_version", + user_id="user_123", + as_baggage=True, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child has version + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.VERSION, "baggage_version" + ) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + + def test_version_semantic_versioning_formats( + self, langfuse_client, memory_exporter + ): + """Verify various semantic versioning formats work correctly.""" + test_versions = [ + "1.0.0", + "v2.3.4", + "1.0.0-alpha", + "2.0.0-beta.1", + "3.1.4-rc.2+build.123", + "0.1.0", + ] + + with langfuse_client.start_as_current_span(name="parent-span"): + for idx, version in enumerate(test_versions): + with propagate_attributes(version=version): + span = langfuse_client.start_span(name=f"span-{idx}") + span.end() + + # Verify all versions are correctly set + for idx, expected_version in enumerate(test_versions): + span_data = self.get_span_by_name(memory_exporter, f"span-{idx}") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.VERSION, expected_version + ) + + def test_version_non_string_dropped(self, langfuse_client, memory_exporter): + """Verify non-string version is dropped with warning.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version=123): # type: ignore + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child does NOT have version + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute(child_span, LangfuseOtelSpanAttributes.VERSION) + + def test_version_propagates_to_grandchildren( + self, langfuse_client, memory_exporter + ): + """Verify version propagates through multiple levels of nesting.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version="nested_v1"): + with langfuse_client.start_as_current_span(name="child-span"): + grandchild = langfuse_client.start_span(name="grandchild-span") + grandchild.end() + + # Verify all three levels have version + parent_span = self.get_span_by_name(memory_exporter, "parent-span") + child_span = self.get_span_by_name(memory_exporter, "child-span") + grandchild_span = self.get_span_by_name(memory_exporter, "grandchild-span") + + for span in [parent_span, child_span, grandchild_span]: + self.verify_span_attribute( + span, LangfuseOtelSpanAttributes.VERSION, "nested_v1" + ) + + @pytest.mark.asyncio + async def test_version_with_async(self, langfuse_client, memory_exporter): + """Verify version propagates in async context.""" + + async def async_operation(): + span = langfuse_client.start_span(name="async-span") + span.end() + + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes(version="async_v1.0"): + await async_operation() + + async_span = self.get_span_by_name(memory_exporter, "async-span") + self.verify_span_attribute( + async_span, LangfuseOtelSpanAttributes.VERSION, "async_v1.0" + ) + + def test_version_attribute_key_format(self, langfuse_client, memory_exporter): + """Verify version uses correct attribute key format.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(version="key_test_v1"): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + attributes = child_span["attributes"] + + # Verify exact attribute key + assert LangfuseOtelSpanAttributes.VERSION in attributes + assert attributes[LangfuseOtelSpanAttributes.VERSION] == "key_test_v1" From b89f4aa54cbf0a4be2987a8a322955e96fd98b47 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:35:20 +0100 Subject: [PATCH 12/19] allow metadata merging --- langfuse/_client/propagation.py | 9 +- tests/test_propagate_attributes.py | 209 +++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 175fbdc01..63ed78ca6 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,7 +5,7 @@ within the context. """ -from typing import Any, Dict, Generator, List, Literal, Optional, Union +from typing import Any, Dict, Generator, List, Literal, Optional, Union, cast from opentelemetry import baggage from opentelemetry import ( @@ -259,6 +259,13 @@ def _set_propagated_attribute( span_key = _get_propagated_span_key(key) baggage_key = _get_propagated_baggage_key(key) + # Merge metadata with previously set metadata keys + if isinstance(value, dict): + existing_metadata_in_context = cast( + dict, otel_context_api.get_value(context_key) or {} + ) + value = existing_metadata_in_context | value + # Set in context context = otel_context_api.set_value( key=context_key, diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index 91e30b607..abcf6dc00 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -563,6 +563,215 @@ def test_nested_different_attributes(self, langfuse_client, memory_exporter): span_data, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session1" ) + def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporter): + """Verify nested contexts merge metadata keys additively.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={"env": "prod", "region": "us-east"}): + # Outer span should have outer metadata + outer_span = langfuse_client.start_span(name="outer-span") + outer_span.end() + + # Inner context adds more metadata + with propagate_attributes( + metadata={"experiment": "A", "version": "2.0"} + ): + inner_span = langfuse_client.start_span(name="inner-span") + inner_span.end() + + # Back to outer context + after_span = langfuse_client.start_span(name="after-span") + after_span.end() + + # Verify: outer span has only outer metadata + outer_span_data = self.get_span_by_name(memory_exporter, "outer-span") + self.verify_span_attribute( + outer_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "prod", + ) + self.verify_span_attribute( + outer_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-east", + ) + self.verify_missing_attribute( + outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" + ) + + # Verify: inner span has ALL metadata (merged) + inner_span_data = self.get_span_by_name(memory_exporter, "inner-span") + self.verify_span_attribute( + inner_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "prod", + ) + self.verify_span_attribute( + inner_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-east", + ) + self.verify_span_attribute( + inner_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "A", + ) + self.verify_span_attribute( + inner_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + "2.0", + ) + + # Verify: after span has only outer metadata (inner context exited) + after_span_data = self.get_span_by_name(memory_exporter, "after-span") + self.verify_span_attribute( + after_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "prod", + ) + self.verify_span_attribute( + after_span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-east", + ) + self.verify_missing_attribute( + after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" + ) + + def test_nested_metadata_inner_overwrites_conflicting_keys( + self, langfuse_client, memory_exporter + ): + """Verify nested contexts: inner metadata overwrites outer for same keys.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + metadata={"env": "staging", "version": "1.0", "region": "us-west"} + ): + # Inner context overwrites some keys + with propagate_attributes( + metadata={"env": "production", "experiment": "B"} + ): + span = langfuse_client.start_span(name="span-1") + span.end() + + # Verify: inner values overwrite outer for conflicting keys + span_data = self.get_span_by_name(memory_exporter, "span-1") + + # Overwritten key + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "production", # Inner value wins + ) + + # Preserved keys from outer + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", + "1.0", # From outer + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", + "us-west", # From outer + ) + + # New key from inner + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", + "B", # From inner + ) + + def test_triple_nested_metadata_accumulates(self, langfuse_client, memory_exporter): + """Verify metadata accumulates across three levels of nesting.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={"level": "1", "a": "outer"}): + with propagate_attributes(metadata={"level": "2", "b": "middle"}): + with propagate_attributes(metadata={"level": "3", "c": "inner"}): + span = langfuse_client.start_span(name="deep-span") + span.end() + + # Verify: deepest span has all metadata with innermost level winning + span_data = self.get_span_by_name(memory_exporter, "deep-span") + + # Conflicting key: innermost wins + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", + "3", + ) + + # Unique keys from each level + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.a", + "outer", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.b", + "middle", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.c", + "inner", + ) + + def test_metadata_merge_with_empty_inner(self, langfuse_client, memory_exporter): + """Verify empty inner metadata dict doesn't clear outer metadata.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(metadata={"key1": "value1", "key2": "value2"}): + # Inner context with empty metadata + with propagate_attributes(metadata={}): + span = langfuse_client.start_span(name="span-1") + span.end() + + # Verify: outer metadata is preserved + span_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key1", + "value1", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key2", + "value2", + ) + + def test_metadata_merge_preserves_user_session( + self, langfuse_client, memory_exporter + ): + """Verify metadata merging doesn't affect user_id/session_id.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + user_id="user1", + session_id="session1", + metadata={"outer": "value"}, + ): + with propagate_attributes(metadata={"inner": "value"}): + span = langfuse_client.start_span(name="span-1") + span.end() + + # Verify: user_id and session_id are preserved, metadata merged + span_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user1" + ) + self.verify_span_attribute( + span_data, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session1" + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.outer", + "value", + ) + self.verify_span_attribute( + span_data, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.inner", + "value", + ) + class TestPropagateAttributesEdgeCases(TestPropagateAttributesBase): """Tests for edge cases and unusual scenarios.""" From fb85b73d5cc021cca91aaa1b73ae5549e8812238 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:44:19 +0100 Subject: [PATCH 13/19] add propagation of tags and public --- langfuse/_client/propagation.py | 71 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 63ed78ca6..487c2368f 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -22,12 +22,16 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse.logger import langfuse_logger -PropagatedKeys = Literal["user_id", "session_id", "metadata", "version"] +PropagatedKeys = Literal[ + "user_id", "session_id", "metadata", "version", "tags", "public" +] propagated_keys: List[PropagatedKeys] = [ "user_id", "session_id", "metadata", "version", + "tags", + "public", ] @@ -38,6 +42,8 @@ def propagate_attributes( session_id: Optional[str] = None, metadata: Optional[Dict[str, str]] = None, version: Optional[str] = None, + tags: Optional[List[str]] = None, + public: Optional[bool] = None, as_baggage: bool = False, ) -> Generator[Any, Any, Any]: """Propagate trace-level attributes to all spans created within this context. @@ -69,6 +75,8 @@ def propagate_attributes( - Use for dimensions like internal correlating identifiers - AVOID: large payloads, sensitive data, non-string values (will be dropped with warning) version: Version identfier for parts of your application that are independently versioned, e.g. agents + tags: List of tags to categorize the trace + public: Whether the trace should be publicly accessible as_baggage: If True, propagates attributes using OpenTelemetry baggage for cross-process/service propagation. **Security warning**: When enabled, attribute values are added to HTTP headers on ALL outbound requests. @@ -140,11 +148,6 @@ def propagate_attributes( ``` Note: - - **Nesting**: Nesting `propagate_attributes` contexts is possible but - discouraged. Inner contexts will overwrite outer values for the same keys. - - **Migration**: This replaces the deprecated `update_trace()` and - `update_current_trace()` methods, which only set attributes on a single span - (causing aggregation gaps). Always use `propagate_attributes` for new code. - **Validation**: All attribute values (user_id, session_id, metadata values) must be strings ≤200 characters. Invalid values will be dropped with a warning logged. Ensure values meet constraints before calling. @@ -184,6 +187,26 @@ def propagate_attributes( as_baggage=as_baggage, ) + if public is not None and _validate_propagated_string(public, "public"): + context = _set_propagated_attribute( + key="public", + value=public, + context=context, + span=current_span, + as_baggage=as_baggage, + ) + + if tags is not None and all( + _validate_propagated_string(tag, "tag") for tag in tags + ): + context = _set_propagated_attribute( + key="tags", + value=tags, + context=context, + span=current_span, + as_baggage=as_baggage, + ) + if metadata is not None: # Filter metadata to only include valid string values validated_metadata: Dict[str, str] = {} @@ -212,8 +235,8 @@ def propagate_attributes( def _get_propagated_attributes_from_context( context: otel_context_api.Context, -) -> Dict[str, str]: - propagated_attributes: Dict[str, str] = {} +) -> Dict[str, Union[str, bool, List[str]]]: + propagated_attributes: Dict[str, Union[str, bool, List[str]]] = {} # Handle baggage baggage_entries = baggage.get_all(context=context) @@ -222,7 +245,11 @@ def _get_propagated_attributes_from_context( span_key = _get_span_key_from_baggage_key(baggage_key) if span_key: - propagated_attributes[span_key] = str(baggage_value) + propagated_attributes[span_key] = ( + baggage_value + if isinstance(baggage_value, (str, list, bool)) + else str(baggage_value) + ) # Handle OTEL context for key in propagated_keys: @@ -249,7 +276,7 @@ def _get_propagated_attributes_from_context( def _set_propagated_attribute( *, key: PropagatedKeys, - value: Union[str, Dict[str, str]], + value: Union[str, bool, List[str], Dict[str, str]], context: otel_context_api.Context, span: otel_trace_api.Span, as_baggage: bool, @@ -266,6 +293,16 @@ def _set_propagated_attribute( ) value = existing_metadata_in_context | value + # Merge tags with previously set tags + if isinstance(value, list): + existing_tags_in_context = cast( + list, otel_context_api.get_value(context_key) or [] + ) + merged_tags = list(existing_tags_in_context) + merged_tags.extend(tag for tag in value if tag not in existing_tags_in_context) + + value = merged_tags + # Set in context context = otel_context_api.set_value( key=context_key, @@ -302,7 +339,7 @@ def _set_propagated_attribute( return context -def _validate_propagated_string(value: str, attribute_name: str) -> bool: +def _validate_propagated_string(value: str | bool, attribute_name: str) -> bool: """Validate a propagated attribute string value. Args: @@ -312,6 +349,9 @@ def _validate_propagated_string(value: str, attribute_name: str) -> bool: Returns: True if valid, False otherwise (with warning logged) """ + if isinstance(value, bool): + return True + if not isinstance(value, str): langfuse_logger.warning( # type: ignore f"Propagated attribute '{attribute_name}' value is not a string. Dropping value." @@ -355,9 +395,16 @@ def _get_span_key_from_baggage_key(key: str) -> Optional[str]: if suffix == "version": return LangfuseOtelSpanAttributes.VERSION + if suffix == "tags": + return LangfuseOtelSpanAttributes.TRACE_TAGS + + if suffix == "public": + return LangfuseOtelSpanAttributes.TRACE_PUBLIC + # Metadata keys have format: langfuse_metadata_{key_name} if suffix.startswith("metadata_"): metadata_key = suffix[len("metadata_") :] + return f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{metadata_key}" return None @@ -368,4 +415,6 @@ def _get_propagated_span_key(key: PropagatedKeys) -> str: "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, "version": LangfuseOtelSpanAttributes.VERSION, + "tags": LangfuseOtelSpanAttributes.TRACE_TAGS, + "public": LangfuseOtelSpanAttributes.TRACE_PUBLIC, }.get(key) or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" From 568f4c9c415cb33f0d8da52951836edb759969a3 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:52:17 +0100 Subject: [PATCH 14/19] add tags propagation --- langfuse/_client/propagation.py | 41 ++---- tests/test_propagate_attributes.py | 229 +++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 27 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 487c2368f..bbd415639 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -23,7 +23,11 @@ from langfuse.logger import langfuse_logger PropagatedKeys = Literal[ - "user_id", "session_id", "metadata", "version", "tags", "public" + "user_id", + "session_id", + "metadata", + "version", + "tags", ] propagated_keys: List[PropagatedKeys] = [ "user_id", @@ -31,7 +35,6 @@ "metadata", "version", "tags", - "public", ] @@ -43,7 +46,6 @@ def propagate_attributes( metadata: Optional[Dict[str, str]] = None, version: Optional[str] = None, tags: Optional[List[str]] = None, - public: Optional[bool] = None, as_baggage: bool = False, ) -> Generator[Any, Any, Any]: """Propagate trace-level attributes to all spans created within this context. @@ -75,8 +77,7 @@ def propagate_attributes( - Use for dimensions like internal correlating identifiers - AVOID: large payloads, sensitive data, non-string values (will be dropped with warning) version: Version identfier for parts of your application that are independently versioned, e.g. agents - tags: List of tags to categorize the trace - public: Whether the trace should be publicly accessible + tags: List of tags to categorize the group of observations as_baggage: If True, propagates attributes using OpenTelemetry baggage for cross-process/service propagation. **Security warning**: When enabled, attribute values are added to HTTP headers on ALL outbound requests. @@ -187,15 +188,6 @@ def propagate_attributes( as_baggage=as_baggage, ) - if public is not None and _validate_propagated_string(public, "public"): - context = _set_propagated_attribute( - key="public", - value=public, - context=context, - span=current_span, - as_baggage=as_baggage, - ) - if tags is not None and all( _validate_propagated_string(tag, "tag") for tag in tags ): @@ -235,8 +227,8 @@ def propagate_attributes( def _get_propagated_attributes_from_context( context: otel_context_api.Context, -) -> Dict[str, Union[str, bool, List[str]]]: - propagated_attributes: Dict[str, Union[str, bool, List[str]]] = {} +) -> Dict[str, Union[str, List[str]]]: + propagated_attributes: Dict[str, Union[str, List[str]]] = {} # Handle baggage baggage_entries = baggage.get_all(context=context) @@ -247,7 +239,7 @@ def _get_propagated_attributes_from_context( if span_key: propagated_attributes[span_key] = ( baggage_value - if isinstance(baggage_value, (str, list, bool)) + if isinstance(baggage_value, (str, list)) else str(baggage_value) ) @@ -268,7 +260,9 @@ def _get_propagated_attributes_from_context( else: span_key = _get_propagated_span_key(key) - propagated_attributes[span_key] = str(value) + propagated_attributes[span_key] = ( + value if isinstance(value, (str, list)) else str(value) + ) return propagated_attributes @@ -276,7 +270,7 @@ def _get_propagated_attributes_from_context( def _set_propagated_attribute( *, key: PropagatedKeys, - value: Union[str, bool, List[str], Dict[str, str]], + value: Union[str, List[str], Dict[str, str]], context: otel_context_api.Context, span: otel_trace_api.Span, as_baggage: bool, @@ -339,7 +333,7 @@ def _set_propagated_attribute( return context -def _validate_propagated_string(value: str | bool, attribute_name: str) -> bool: +def _validate_propagated_string(value: str, attribute_name: str) -> bool: """Validate a propagated attribute string value. Args: @@ -349,9 +343,6 @@ def _validate_propagated_string(value: str | bool, attribute_name: str) -> bool: Returns: True if valid, False otherwise (with warning logged) """ - if isinstance(value, bool): - return True - if not isinstance(value, str): langfuse_logger.warning( # type: ignore f"Propagated attribute '{attribute_name}' value is not a string. Dropping value." @@ -398,9 +389,6 @@ def _get_span_key_from_baggage_key(key: str) -> Optional[str]: if suffix == "tags": return LangfuseOtelSpanAttributes.TRACE_TAGS - if suffix == "public": - return LangfuseOtelSpanAttributes.TRACE_PUBLIC - # Metadata keys have format: langfuse_metadata_{key_name} if suffix.startswith("metadata_"): metadata_key = suffix[len("metadata_") :] @@ -416,5 +404,4 @@ def _get_propagated_span_key(key: PropagatedKeys) -> str: "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, "version": LangfuseOtelSpanAttributes.VERSION, "tags": LangfuseOtelSpanAttributes.TRACE_TAGS, - "public": LangfuseOtelSpanAttributes.TRACE_PUBLIC, }.get(key) or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index abcf6dc00..674b63681 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -2047,3 +2047,232 @@ def test_version_attribute_key_format(self, langfuse_client, memory_exporter): # Verify exact attribute key assert LangfuseOtelSpanAttributes.VERSION in attributes assert attributes[LangfuseOtelSpanAttributes.VERSION] == "key_test_v1" + + +class TestPropagateAttributesTags(TestPropagateAttributesBase): + """Tests for tags parameter propagation.""" + + def test_tags_propagate_to_child_spans(self, langfuse_client, memory_exporter): + """Verify tags propagate to all child spans within context.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["production", "api-v2", "critical"]): + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + # Verify both children have tags + child1_span = self.get_span_by_name(memory_exporter, "child-span-1") + self.verify_span_attribute( + child1_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["production", "api-v2", "critical"]), + ) + + child2_span = self.get_span_by_name(memory_exporter, "child-span-2") + self.verify_span_attribute( + child2_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["production", "api-v2", "critical"]), + ) + + def test_tags_with_single_tag(self, langfuse_client, memory_exporter): + """Verify single tag works correctly.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["experiment"]): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["experiment"]), + ) + + def test_empty_tags_list(self, langfuse_client, memory_exporter): + """Verify empty tags list is handled correctly.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=[]): + child = langfuse_client.start_span(name="child-span") + child.end() + + # With empty list, tags should not be set + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple([]), + ) + + def test_tags_with_user_and_session(self, langfuse_client, memory_exporter): + """Verify tags work together with user_id and session_id.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + user_id="user_123", + session_id="session_abc", + tags=["test", "debug"], + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child has all attributes + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID, "user_123" + ) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "session_abc" + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["test", "debug"]), + ) + + def test_tags_with_metadata(self, langfuse_client, memory_exporter): + """Verify tags work together with metadata.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + tags=["experiment-a", "variant-1"], + metadata={"env": "staging"}, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["experiment-a", "variant-1"]), + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", + "staging", + ) + + def test_tags_validation_with_invalid_tag(self, langfuse_client, memory_exporter): + """Verify tags with one invalid entry drops all tags.""" + long_tag = "x" * 201 # Over 200 chars + + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["valid_tag", long_tag]): + child = langfuse_client.start_span(name="child-span") + child.end() + + # All tags should be dropped if any tag is invalid + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute(child_span, LangfuseOtelSpanAttributes.TRACE_TAGS) + + def test_tags_nested_contexts_inner_appends(self, langfuse_client, memory_exporter): + """Verify inner context appends to outer tags.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["outer", "tag1"]): + # Create span in outer context + span1 = langfuse_client.start_span(name="span-1") + span1.end() + + # Inner context with more tags + with propagate_attributes(tags=["inner", "tag2"]): + span2 = langfuse_client.start_span(name="span-2") + span2.end() + + # Back to outer context + span3 = langfuse_client.start_span(name="span-3") + span3.end() + + # Verify: span1 and span3 have outer tags, span2 has inner tags + span1_data = self.get_span_by_name(memory_exporter, "span-1") + self.verify_span_attribute( + span1_data, LangfuseOtelSpanAttributes.TRACE_TAGS, tuple(["outer", "tag1"]) + ) + + span2_data = self.get_span_by_name(memory_exporter, "span-2") + self.verify_span_attribute( + span2_data, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple( + [ + "outer", + "tag1", + "inner", + "tag2", + ] + ), + ) + + span3_data = self.get_span_by_name(memory_exporter, "span-3") + self.verify_span_attribute( + span3_data, LangfuseOtelSpanAttributes.TRACE_TAGS, tuple(["outer", "tag1"]) + ) + + def test_tags_with_baggage(self, langfuse_client, memory_exporter): + """Verify tags propagate through baggage.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes( + tags=["baggage_tag1", "baggage_tag2"], + as_baggage=True, + ): + child = langfuse_client.start_span(name="child-span") + child.end() + + # Verify child has tags + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["baggage_tag1", "baggage_tag2"]), + ) + + def test_tags_propagate_to_grandchildren(self, langfuse_client, memory_exporter): + """Verify tags propagate through multiple levels of nesting.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["level1", "level2", "level3"]): + with langfuse_client.start_as_current_span(name="child-span"): + grandchild = langfuse_client.start_span(name="grandchild-span") + grandchild.end() + + # Verify all three levels have tags + parent_span = self.get_span_by_name(memory_exporter, "parent-span") + child_span = self.get_span_by_name(memory_exporter, "child-span") + grandchild_span = self.get_span_by_name(memory_exporter, "grandchild-span") + + for span in [parent_span, child_span, grandchild_span]: + self.verify_span_attribute( + span, + LangfuseOtelSpanAttributes.TRACE_TAGS, + tuple(["level1", "level2", "level3"]), + ) + + @pytest.mark.asyncio + async def test_tags_with_async(self, langfuse_client, memory_exporter): + """Verify tags propagate in async context.""" + + async def async_operation(): + span = langfuse_client.start_span(name="async-span") + span.end() + + with langfuse_client.start_as_current_span(name="parent"): + with propagate_attributes(tags=["async", "test"]): + await async_operation() + + async_span = self.get_span_by_name(memory_exporter, "async-span") + self.verify_span_attribute( + async_span, LangfuseOtelSpanAttributes.TRACE_TAGS, tuple(["async", "test"]) + ) + + def test_tags_attribute_key_format(self, langfuse_client, memory_exporter): + """Verify tags use correct attribute key format.""" + with langfuse_client.start_as_current_span(name="parent-span"): + with propagate_attributes(tags=["key_test"]): + child = langfuse_client.start_span(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + attributes = child_span["attributes"] + + # Verify exact attribute key + assert LangfuseOtelSpanAttributes.TRACE_TAGS in attributes + assert attributes[LangfuseOtelSpanAttributes.TRACE_TAGS] == tuple(["key_test"]) From 9eafea431851a37392eff93abe5c9afbe0070945 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:58:37 +0100 Subject: [PATCH 15/19] add experiment attribute propagation --- langfuse/_client/attributes.py | 11 + langfuse/_client/client.py | 135 +++++---- langfuse/_client/propagation.py | 192 ++++++++----- langfuse/_client/utils.py | 5 + tests/test_propagate_attributes.py | 440 ++++++++++++++++++++++++++++- 5 files changed, 663 insertions(+), 120 deletions(-) diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index 75c5645ea..343c70cdb 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -59,6 +59,17 @@ class LangfuseOtelSpanAttributes: # Internal AS_ROOT = "langfuse.internal.as_root" + # Experiments + EXPERIMENT_ID = "langfuse.experiment.id" + EXPERIMENT_NAME = "langfuse.experiment.name" + EXPERIMENT_DESCRIPTION = "langfuse.experiment.description" + EXPERIMENT_METADATA = "langfuse.experiment.metadata" + EXPERIMENT_DATASET_ID = "langfuse.experiment.dataset.id" + EXPERIMENT_ITEM_ID = "langfuse.experiment.item.id" + EXPERIMENT_ITEM_EXPECTED_OUTPUT = "langfuse.experiment.item.expected_output" + EXPERIMENT_ITEM_METADATA = "langfuse.experiment.item.metadata" + EXPERIMENT_ITEM_ROOT_OBSERVATION_ID = "langfuse.experiment.item.root_observation_id" + def create_trace_attributes( *, diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 9364a377c..6deab5300 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -36,7 +36,7 @@ ) from packaging.version import Version -from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.attributes import LangfuseOtelSpanAttributes, _serialize from langfuse._client.constants import ( ObservationTypeGenerationLike, ObservationTypeLiteral, @@ -55,6 +55,10 @@ LANGFUSE_TRACING_ENABLED, LANGFUSE_TRACING_ENVIRONMENT, ) +from langfuse._client.propagation import ( + PropagatedExperimentAttributes, + _propagate_attributes, +) from langfuse._client.resource_manager import LangfuseResourceManager from langfuse._client.span import ( LangfuseAgent, @@ -68,7 +72,7 @@ LangfuseSpan, LangfuseTool, ) -from langfuse._client.utils import run_async_safely +from langfuse._client.utils import get_sha256_hash_hex, run_async_safely from langfuse._utils import _get_timestamp from langfuse._utils.parse_error import handle_fern_exception from langfuse._utils.prompt_cache import PromptCache @@ -2497,7 +2501,7 @@ def run_experiment( evaluators: List[EvaluatorFunction] = [], run_evaluators: List[RunEvaluatorFunction] = [], max_concurrency: int = 50, - metadata: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, str]] = None, ) -> ExperimentResult: """Run an experiment on a dataset with automatic tracing and evaluation. @@ -2669,7 +2673,7 @@ def average_accuracy(*, item_results, **kwargs): evaluators=evaluators or [], run_evaluators=run_evaluators or [], max_concurrency=max_concurrency, - metadata=metadata or {}, + metadata=metadata, ), ), ) @@ -2685,7 +2689,7 @@ async def _run_experiment_async( evaluators: List[EvaluatorFunction], run_evaluators: List[RunEvaluatorFunction], max_concurrency: int, - metadata: Dict[str, Any], + metadata: Optional[Dict[str, Any]] = None, ) -> ExperimentResult: langfuse_logger.debug( f"Starting experiment '{name}' run '{run_name}' with {len(data)} items" @@ -2783,65 +2787,54 @@ async def _process_experiment_item( experiment_name: str, experiment_run_name: str, experiment_description: Optional[str], - experiment_metadata: Dict[str, Any], + experiment_metadata: Optional[Dict[str, Any]] = None, ) -> ExperimentItemResult: - # Execute task with tracing span_name = "experiment-item-run" with self.start_as_current_span(name=span_name) as span: try: - output = await _run_task(task, item) - input_data = ( item.get("input") if isinstance(item, dict) else getattr(item, "input", None) ) - item_metadata: Dict[str, Any] = {} + expected_output = ( + item.get("expected_output") + if isinstance(item, dict) + else getattr(item, "expected_output", None) + ) - if isinstance(item, dict): - item_metadata = item.get("metadata", None) or {} + item_metadata = ( + item.get("metadata") + if isinstance(item, dict) + else getattr(item, "metadata", None) + ) - final_metadata = { + final_observation_metadata = { "experiment_name": experiment_name, "experiment_run_name": experiment_run_name, - **experiment_metadata, + **(experiment_metadata or {}), } - if ( - not isinstance(item, dict) - and hasattr(item, "dataset_id") - and hasattr(item, "id") - ): - final_metadata.update( - {"dataset_id": item.dataset_id, "dataset_item_id": item.id} - ) - - if isinstance(item_metadata, dict): - final_metadata.update(item_metadata) - - span.update( - input=input_data, - output=output, - metadata=final_metadata, - ) - - # Get trace ID for linking trace_id = span.trace_id + dataset_id = None + dataset_item_id = None dataset_run_id = None # Link to dataset run if this is a dataset item if hasattr(item, "id") and hasattr(item, "dataset_id"): try: - dataset_run_item = self.api.dataset_run_items.create( - request=CreateDatasetRunItemRequest( - runName=experiment_run_name, - runDescription=experiment_description, - metadata=experiment_metadata, - datasetItemId=item.id, # type: ignore - traceId=trace_id, - observationId=span.id, + dataset_run_item = ( + await self.async_api.dataset_run_items.create( + request=CreateDatasetRunItemRequest( + runName=experiment_run_name, + runDescription=experiment_description, + metadata=experiment_metadata, + datasetItemId=item.id, # type: ignore + traceId=trace_id, + observationId=span.id, + ) ) ) @@ -2850,18 +2843,63 @@ async def _process_experiment_item( except Exception as e: langfuse_logger.error(f"Failed to create dataset run item: {e}") + if ( + not isinstance(item, dict) + and hasattr(item, "dataset_id") + and hasattr(item, "id") + ): + dataset_id = item.dataset_id + dataset_item_id = item.id + + final_observation_metadata.update( + {"dataset_id": dataset_id, "dataset_item_id": dataset_item_id} + ) + + if isinstance(item_metadata, dict): + final_observation_metadata.update(item_metadata) + + experiment_id = dataset_run_id or self._create_observation_id() + experiment_item_id = ( + dataset_item_id or get_sha256_hash_hex(_serialize(input_data))[:16] + ) + + span._otel_span.set_attributes( + { + k: v + for k, v in { + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION: experiment_description, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT: _serialize( + expected_output + ), + }.items() + if v is not None + } + ) + + with _propagate_attributes( + experiment=PropagatedExperimentAttributes( + experiment_id=experiment_id, + experiment_name=experiment_run_name, + experiment_metadata=_serialize(experiment_metadata), + experiment_dataset_id=dataset_id, + experiment_item_id=experiment_item_id, + experiment_item_metadata=_serialize(item_metadata), + experiment_item_root_observation_id=span.id, + ) + ): + output = await _run_task(task, item) + + span.update( + input=input_data, + output=output, + metadata=final_observation_metadata, + ) + # Run evaluators evaluations = [] for evaluator in evaluators: try: - expected_output = None - - if isinstance(item, dict): - expected_output = item.get("expected_output") - elif hasattr(item, "expected_output"): - expected_output = item.expected_output - eval_metadata: Optional[Dict[str, Any]] = None if isinstance(item, dict): @@ -2882,6 +2920,7 @@ async def _process_experiment_item( for evaluation in eval_results: self.create_score( trace_id=trace_id, + observation_id=span.id, name=evaluation.name, value=evaluation.value, # type: ignore comment=evaluation.comment, diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index bbd415639..deb5fbec3 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,7 +5,7 @@ within the context. """ -from typing import Any, Dict, Generator, List, Literal, Optional, Union, cast +from typing import Any, Dict, Generator, List, Literal, Optional, TypedDict, Union, cast from opentelemetry import baggage from opentelemetry import ( @@ -17,7 +17,10 @@ from opentelemetry import ( trace as otel_trace_api, ) -from opentelemetry.util._decorator import _agnosticcontextmanager +from opentelemetry.util._decorator import ( + _AgnosticContextManager, + _agnosticcontextmanager, +) from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse.logger import langfuse_logger @@ -29,16 +32,43 @@ "version", "tags", ] -propagated_keys: List[PropagatedKeys] = [ + +InternalPropagatedKeys = Literal[ + "experiment_id", + "experiment_name", + "experiment_metadata", + "experiment_dataset_id", + "experiment_item_id", + "experiment_item_metadata", + "experiment_item_root_observation_id", +] + +propagated_keys: List[Union[PropagatedKeys, InternalPropagatedKeys]] = [ "user_id", "session_id", "metadata", "version", "tags", + "experiment_id", + "experiment_name", + "experiment_metadata", + "experiment_dataset_id", + "experiment_item_id", + "experiment_item_metadata", + "experiment_item_root_observation_id", ] -@_agnosticcontextmanager +class PropagatedExperimentAttributes(TypedDict): + experiment_id: str + experiment_name: str + experiment_metadata: Optional[str] + experiment_dataset_id: Optional[str] + experiment_item_id: str + experiment_item_metadata: Optional[str] + experiment_item_root_observation_id: str + + def propagate_attributes( *, user_id: Optional[str] = None, @@ -47,7 +77,7 @@ def propagate_attributes( version: Optional[str] = None, tags: Optional[List[str]] = None, as_baggage: bool = False, -) -> Generator[Any, Any, Any]: +) -> _AgnosticContextManager[Any]: """Propagate trace-level attributes to all spans created within this context. This context manager sets attributes on the currently active span AND automatically @@ -158,52 +188,63 @@ def propagate_attributes( Raises: No exceptions are raised. Invalid values are logged as warnings and dropped. """ + return _propagate_attributes( + user_id=user_id, + session_id=session_id, + metadata=metadata, + version=version, + tags=tags, + as_baggage=as_baggage, + ) + + +@_agnosticcontextmanager +def _propagate_attributes( + *, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + version: Optional[str] = None, + tags: Optional[List[str]] = None, + as_baggage: bool = False, + experiment: Optional[PropagatedExperimentAttributes] = None, +) -> Generator[Any, Any, Any]: context = otel_context_api.get_current() current_span = otel_trace_api.get_current_span() - if user_id is not None and _validate_propagated_string(user_id, "user_id"): - context = _set_propagated_attribute( - key="user_id", - value=user_id, - context=context, - span=current_span, - as_baggage=as_baggage, - ) + propagated_string_attributes: Dict[str, Optional[Union[str, List[str]]]] = { + "user_id": user_id, + "session_id": session_id, + "version": version, + "tags": tags, + } - if session_id is not None and _validate_propagated_string(session_id, "session_id"): - context = _set_propagated_attribute( - key="session_id", - value=session_id, - context=context, - span=current_span, - as_baggage=as_baggage, - ) + propagated_string_attributes = propagated_string_attributes | ( + cast(Dict[str, Union[str, List[str], None]], experiment) or {} + ) - if version is not None and _validate_propagated_string(version, "version"): - context = _set_propagated_attribute( - key="version", - value=version, - context=context, - span=current_span, - as_baggage=as_baggage, - ) + # Filter out None values + propagated_string_attributes = { + k: v for k, v in propagated_string_attributes.items() if v is not None + } - if tags is not None and all( - _validate_propagated_string(tag, "tag") for tag in tags - ): - context = _set_propagated_attribute( - key="tags", - value=tags, - context=context, - span=current_span, - as_baggage=as_baggage, - ) + for key, value in propagated_string_attributes.items(): + validated_value = _validate_propagated_value(value=value, key=key) + + if validated_value is not None: + context = _set_propagated_attribute( + key=key, + value=validated_value, + context=context, + span=current_span, + as_baggage=as_baggage, + ) if metadata is not None: - # Filter metadata to only include valid string values validated_metadata: Dict[str, str] = {} + for key, value in metadata.items(): - if _validate_propagated_string(value, f"metadata.{key}"): + if _validate_string_value(value=value, key=f"metadata.{key}"): validated_metadata[key] = value if validated_metadata: @@ -269,7 +310,7 @@ def _get_propagated_attributes_from_context( def _set_propagated_attribute( *, - key: PropagatedKeys, + key: str, value: Union[str, List[str], Dict[str, str]], context: otel_context_api.Context, span: otel_trace_api.Span, @@ -333,39 +374,55 @@ def _set_propagated_attribute( return context -def _validate_propagated_string(value: str, attribute_name: str) -> bool: - """Validate a propagated attribute string value. +def _validate_propagated_value( + *, value: Any, key: str +) -> Optional[Union[str, List[str]]]: + if isinstance(value, list): + validated_values = [ + v for v in value if _validate_string_value(key=key, value=v) + ] - Args: - value: The string value to validate - attribute_name: Name of the attribute for error messages + return validated_values if len(validated_values) > 0 else None - Returns: - True if valid, False otherwise (with warning logged) - """ if not isinstance(value, str): langfuse_logger.warning( # type: ignore - f"Propagated attribute '{attribute_name}' value is not a string. Dropping value." + f"Propagated attribute '{key}' value is not a string. Dropping value." + ) + return None + + if len(value) > 200: + langfuse_logger.warning( + f"Propagated attribute '{key}' value is over 200 characters ({len(value)} chars). Dropping value." + ) + return None + + return value + + +def _validate_string_value(*, value: str, key: str) -> bool: + if not isinstance(value, str): + langfuse_logger.warning( # type: ignore + f"Propagated attribute '{key}' value is not a string. Dropping value." ) return False if len(value) > 200: langfuse_logger.warning( - f"Propagated attribute '{attribute_name}' value is over 200 characters ({len(value)} chars). Dropping value." + f"Propagated attribute '{key}' value is over 200 characters ({len(value)} chars). Dropping value." ) return False return True -def _get_propagated_context_key(key: PropagatedKeys) -> str: +def _get_propagated_context_key(key: str) -> str: return f"langfuse.propagated.{key}" LANGFUSE_BAGGAGE_PREFIX = "langfuse_" -def _get_propagated_baggage_key(key: PropagatedKeys) -> str: +def _get_propagated_baggage_key(key: str) -> str: return f"{LANGFUSE_BAGGAGE_PREFIX}{key}" @@ -376,32 +433,25 @@ def _get_span_key_from_baggage_key(key: str) -> Optional[str]: # Remove prefix to get the actual key name suffix = key[len(LANGFUSE_BAGGAGE_PREFIX) :] - # Exact match for user_id and session_id - if suffix == "user_id": - return LangfuseOtelSpanAttributes.TRACE_USER_ID - - if suffix == "session_id": - return LangfuseOtelSpanAttributes.TRACE_SESSION_ID - - if suffix == "version": - return LangfuseOtelSpanAttributes.VERSION - - if suffix == "tags": - return LangfuseOtelSpanAttributes.TRACE_TAGS - - # Metadata keys have format: langfuse_metadata_{key_name} if suffix.startswith("metadata_"): metadata_key = suffix[len("metadata_") :] - return f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{metadata_key}" + return _get_propagated_span_key(metadata_key) - return None + return _get_propagated_span_key(suffix) -def _get_propagated_span_key(key: PropagatedKeys) -> str: +def _get_propagated_span_key(key: str) -> str: return { "session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID, "user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID, "version": LangfuseOtelSpanAttributes.VERSION, "tags": LangfuseOtelSpanAttributes.TRACE_TAGS, + "experiment_id": LangfuseOtelSpanAttributes.EXPERIMENT_ID, + "experiment_name": LangfuseOtelSpanAttributes.EXPERIMENT_NAME, + "experiment_metadata": LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + "experiment_dataset_id": LangfuseOtelSpanAttributes.EXPERIMENT_DATASET_ID, + "experiment_item_id": LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID, + "experiment_item_metadata": LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + "experiment_item_root_observation_id": LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID, }.get(key) or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" diff --git a/langfuse/_client/utils.py b/langfuse/_client/utils.py index d34857ebd..16d963d88 100644 --- a/langfuse/_client/utils.py +++ b/langfuse/_client/utils.py @@ -7,6 +7,7 @@ import asyncio import json import threading +from hashlib import sha256 from typing import Any, Coroutine from opentelemetry import trace as otel_trace_api @@ -125,3 +126,7 @@ async def my_async_function(): else: # Loop exists but not running, safe to use asyncio.run() return asyncio.run(coro) + + +def get_sha256_hash_hex(value: Any) -> str: + return sha256(value.encode("utf-8")).digest().hex() diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index 674b63681..2897a88ba 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -6,12 +6,13 @@ """ import concurrent.futures +import time import pytest from opentelemetry.instrumentation.threading import ThreadingInstrumentor from langfuse import propagate_attributes -from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.attributes import LangfuseOtelSpanAttributes, _serialize from tests.test_otel import TestOTelBase @@ -2276,3 +2277,440 @@ def test_tags_attribute_key_format(self, langfuse_client, memory_exporter): # Verify exact attribute key assert LangfuseOtelSpanAttributes.TRACE_TAGS in attributes assert attributes[LangfuseOtelSpanAttributes.TRACE_TAGS] == tuple(["key_test"]) + + +class TestPropagateAttributesExperiment(TestPropagateAttributesBase): + """Tests for experiment attribute propagation.""" + + def test_experiment_attributes_propagate_without_dataset( + self, langfuse_client, memory_exporter + ): + """Test experiment attribute propagation with local data (no Langfuse dataset).""" + # Create local dataset with metadata + local_data = [ + { + "input": "test input 1", + "expected_output": "expected result 1", + "metadata": {"item_type": "test", "priority": "high"}, + }, + ] + + # Task function that creates child spans + def task_with_child_spans(*, item, **kwargs): + # Create child spans to verify propagation + child1 = langfuse_client.start_span(name="child-span-1") + child1.end() + + child2 = langfuse_client.start_span(name="child-span-2") + child2.end() + + return f"processed: {item.get('input') if isinstance(item, dict) else item.input}" + + # Run experiment with local data + experiment_metadata = {"version": "1.0", "model": "test-model"} + result = langfuse_client.run_experiment( + name="Test Experiment", + description="Test experiment description", + data=local_data, + task=task_with_child_spans, + metadata=experiment_metadata, + ) + + # Flush to ensure spans are exported + langfuse_client.flush() + time.sleep(0.1) + + # Get the root span + root_spans = self.get_spans_by_name(memory_exporter, "experiment-item-run") + assert len(root_spans) >= 1, "Should have at least 1 root span" + first_root = root_spans[0] + + # Root-only attributes should be on root + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION, + "Test experiment description", + ) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT, + _serialize("expected result 1"), + ) + + # Propagated attributes should also be on root + experiment_id = first_root["attributes"][ + LangfuseOtelSpanAttributes.EXPERIMENT_ID + ] + experiment_item_id = first_root["attributes"][ + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID + ] + root_observation_id = first_root["attributes"][ + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID + ] + + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_NAME, + result.run_name, + ) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + _serialize(experiment_metadata), + ) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + _serialize({"item_type": "test", "priority": "high"}), + ) + + # Dataset ID should not be set for local data + self.verify_missing_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_DATASET_ID, + ) + + # Verify child spans have propagated attributes but NOT root-only attributes + child_spans = self.get_spans_by_name( + memory_exporter, "child-span-1" + ) + self.get_spans_by_name(memory_exporter, "child-span-2") + + assert len(child_spans) >= 2, "Should have at least 2 child spans" + + for child_span in child_spans[:2]: # Check first item's children + # Propagated attributes should be present + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ID, + experiment_id, + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_NAME, + result.run_name, + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + _serialize(experiment_metadata), + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID, + experiment_item_id, + ) + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID, + root_observation_id, + ) + + # Root-only attributes should NOT be present on children + self.verify_missing_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION, + ) + self.verify_missing_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT, + ) + + # Dataset ID should not be set for local data + self.verify_missing_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_DATASET_ID, + ) + + def test_experiment_attributes_propagate_with_dataset( + self, langfuse_client, memory_exporter, monkeypatch + ): + """Test experiment attribute propagation with Langfuse dataset.""" + import time + from datetime import datetime + + from langfuse._client.attributes import _serialize + from langfuse._client.datasets import DatasetClient, DatasetItemClient + from langfuse.model import Dataset, DatasetItem, DatasetStatus + + # Mock the async API to create dataset run items + async def mock_create_dataset_run_item(*args, **kwargs): + from langfuse.api.resources.dataset_run_items.types import DatasetRunItem + + request = kwargs.get("request") + return DatasetRunItem( + id="mock-run-item-id", + dataset_run_id="mock-dataset-run-id-123", + dataset_item_id=request.datasetItemId if request else "mock-item-id", + trace_id="mock-trace-id", + ) + + monkeypatch.setattr( + langfuse_client.async_api.dataset_run_items, + "create", + mock_create_dataset_run_item, + ) + + # Create a mock dataset with items + dataset_id = "test-dataset-id-456" + dataset_item_id = "test-dataset-item-id-789" + + mock_dataset = Dataset( + id=dataset_id, + name="Test Dataset", + description="Test dataset description", + project_id="test-project-id", + metadata={"test": "metadata"}, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + mock_dataset_item = DatasetItem( + id=dataset_item_id, + status=DatasetStatus.ACTIVE, + input="Germany", + expected_output="Berlin", + metadata={"source": "dataset", "index": 0}, + source_trace_id=None, + source_observation_id=None, + dataset_id=dataset_id, + dataset_name="Test Dataset", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + # Create dataset client with items + dataset_item_client = DatasetItemClient(mock_dataset_item, langfuse_client) + dataset = DatasetClient(mock_dataset, [dataset_item_client]) + + # Task with child spans + def task_with_children(*, item, **kwargs): + child1 = langfuse_client.start_span(name="dataset-child-1") + child1.end() + + child2 = langfuse_client.start_span(name="dataset-child-2") + child2.end() + + return f"Capital: {item.expected_output}" + + # Run experiment + experiment_metadata = {"dataset_version": "v2", "test_run": "true"} + dataset.run_experiment( + name="Dataset Test", + description="Dataset experiment description", + task=task_with_children, + metadata=experiment_metadata, + ) + + langfuse_client.flush() + time.sleep(0.1) + + # Verify root has dataset-specific attributes + root_spans = self.get_spans_by_name(memory_exporter, "experiment-item-run") + assert len(root_spans) >= 1, "Should have at least 1 root span" + first_root = root_spans[0] + + # Root-only attributes should be on root + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION, + "Dataset experiment description", + ) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT, + _serialize("Berlin"), + ) + + # Should have dataset ID (this is the key difference from local data) + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_DATASET_ID, + dataset_id, + ) + + # Should have the dataset item ID + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID, + dataset_item_id, + ) + + # Should have experiment metadata + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + _serialize(experiment_metadata), + ) + + # Verify child spans have dataset-specific propagated attributes + child_spans = self.get_spans_by_name( + memory_exporter, "dataset-child-1" + ) + self.get_spans_by_name(memory_exporter, "dataset-child-2") + + assert len(child_spans) >= 2, "Should have at least 2 child spans" + + for child_span in child_spans[:2]: + # Dataset ID should be propagated to children + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_DATASET_ID, + dataset_id, + ) + + # Dataset item ID should be propagated + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ID, + dataset_item_id, + ) + + # Experiment metadata should be propagated + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + _serialize(experiment_metadata), + ) + + # Item metadata should be propagated + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + _serialize({"source": "dataset", "index": 0}), + ) + + # Root-only attributes should NOT be present on children + self.verify_missing_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION, + ) + self.verify_missing_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT, + ) + + def test_experiment_attributes_propagate_to_nested_children( + self, langfuse_client, memory_exporter + ): + """Test experiment attributes propagate to deeply nested child spans.""" + local_data = [{"input": "test", "expected_output": "result"}] + + # Task with deeply nested spans + def task_with_nested_spans(*, item, **kwargs): + with langfuse_client.start_as_current_span(name="child-span"): + with langfuse_client.start_as_current_span(name="grandchild-span"): + great_grandchild = langfuse_client.start_span( + name="great-grandchild-span" + ) + great_grandchild.end() + + return "processed" + + result = langfuse_client.run_experiment( + name="Nested Test", + description="Nested test", + data=local_data, + task=task_with_nested_spans, + metadata={"depth": "test"}, + ) + + langfuse_client.flush() + time.sleep(0.1) + + root_spans = self.get_spans_by_name(memory_exporter, "experiment-item-run") + first_root = root_spans[0] + experiment_id = first_root["attributes"][ + LangfuseOtelSpanAttributes.EXPERIMENT_ID + ] + root_observation_id = first_root["attributes"][ + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID + ] + + # Verify all nested children have propagated attributes + for span_name in ["child-span", "grandchild-span", "great-grandchild-span"]: + span_data = self.get_span_by_name(memory_exporter, span_name) + + # Propagated attributes should be present + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.EXPERIMENT_ID, + experiment_id, + ) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.EXPERIMENT_NAME, + result.run_name, + ) + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID, + root_observation_id, + ) + + # Root-only attributes should NOT be present + self.verify_missing_attribute( + span_data, + LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION, + ) + self.verify_missing_attribute( + span_data, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT, + ) + + def test_experiment_metadata_merging(self, langfuse_client, memory_exporter): + """Test that experiment metadata and item metadata are both propagated correctly.""" + import time + + from langfuse._client.attributes import _serialize + + # Rich metadata + experiment_metadata = { + "experiment_type": "A/B test", + "model_version": "2.0", + "temperature": 0.7, + } + item_metadata = { + "item_category": "finance", + "difficulty": "hard", + "language": "en", + } + + local_data = [ + { + "input": "test", + "expected_output": {"status": "success"}, + "metadata": item_metadata, + } + ] + + def task_with_child(*, item, **kwargs): + child = langfuse_client.start_span(name="metadata-child") + child.end() + return "result" + + langfuse_client.run_experiment( + name="Metadata Test", + description="Metadata test", + data=local_data, + task=task_with_child, + metadata=experiment_metadata, + ) + + langfuse_client.flush() + time.sleep(0.1) + + # Verify child span has both experiment and item metadata propagated + child_span = self.get_span_by_name(memory_exporter, "metadata-child") + + # Verify experiment metadata is serialized and propagated + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_METADATA, + _serialize(experiment_metadata), + ) + + # Verify item metadata is serialized and propagated + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, + _serialize(item_metadata), + ) From 10fda14888f30588a13b55ae43388f2f11eb56fc Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:57:50 +0100 Subject: [PATCH 16/19] fix tags test --- tests/test_propagate_attributes.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index 2897a88ba..a259c23bb 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -2101,11 +2101,7 @@ def test_empty_tags_list(self, langfuse_client, memory_exporter): # With empty list, tags should not be set child_span = self.get_span_by_name(memory_exporter, "child-span") - self.verify_span_attribute( - child_span, - LangfuseOtelSpanAttributes.TRACE_TAGS, - tuple([]), - ) + self.verify_missing_attribute(child_span, LangfuseOtelSpanAttributes.TRACE_TAGS) def test_tags_with_user_and_session(self, langfuse_client, memory_exporter): """Verify tags work together with user_id and session_id.""" @@ -2163,9 +2159,10 @@ def test_tags_validation_with_invalid_tag(self, langfuse_client, memory_exporter child = langfuse_client.start_span(name="child-span") child.end() - # All tags should be dropped if any tag is invalid child_span = self.get_span_by_name(memory_exporter, "child-span") - self.verify_missing_attribute(child_span, LangfuseOtelSpanAttributes.TRACE_TAGS) + self.verify_span_attribute( + child_span, LangfuseOtelSpanAttributes.TRACE_TAGS, tuple(["valid_tag"]) + ) def test_tags_nested_contexts_inner_appends(self, langfuse_client, memory_exporter): """Verify inner context appends to outer tags.""" From 1449573fc262d1e5187053f5aceb1d1a24aa08e4 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:31:26 +0100 Subject: [PATCH 17/19] push --- langfuse/_client/client.py | 27 +++++++++++++++------------ tests/test_experiments.py | 2 +- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 6deab5300..880486dcf 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -2799,6 +2799,9 @@ async def _process_experiment_item( else getattr(item, "input", None) ) + if input_data is None: + raise ValueError("Experiment Item is missing input. Skipping item.") + expected_output = ( item.get("expected_output") if isinstance(item, dict) @@ -2825,17 +2828,18 @@ async def _process_experiment_item( # Link to dataset run if this is a dataset item if hasattr(item, "id") and hasattr(item, "dataset_id"): try: - dataset_run_item = ( - await self.async_api.dataset_run_items.create( - request=CreateDatasetRunItemRequest( - runName=experiment_run_name, - runDescription=experiment_description, - metadata=experiment_metadata, - datasetItemId=item.id, # type: ignore - traceId=trace_id, - observationId=span.id, - ) - ) + # Use sync API to avoid event loop issues when run_async_safely + # creates multiple event loops across different threads + dataset_run_item = await asyncio.to_thread( + self.api.dataset_run_items.create, + request=CreateDatasetRunItemRequest( + runName=experiment_run_name, + runDescription=experiment_description, + metadata=experiment_metadata, + datasetItemId=item.id, # type: ignore + traceId=trace_id, + observationId=span.id, + ), ) dataset_run_id = dataset_run_item.dataset_run_id @@ -2862,7 +2866,6 @@ async def _process_experiment_item( experiment_item_id = ( dataset_item_id or get_sha256_hash_hex(_serialize(input_data))[:16] ) - span._otel_span.set_attributes( { k: v diff --git a/tests/test_experiments.py b/tests/test_experiments.py index db3d74a65..71f2e5926 100644 --- a/tests/test_experiments.py +++ b/tests/test_experiments.py @@ -399,7 +399,7 @@ def test_dataset_with_missing_fields(): ) # Should handle missing fields gracefully - assert len(result.item_results) == 3 + assert len(result.item_results) == 2 for item_result in result.item_results: assert hasattr(item_result, "trace_id") assert hasattr(item_result, "output") From ef19337b1f2757bc1260f63c3ac452e6413bde0f Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Sun, 2 Nov 2025 09:47:21 +0100 Subject: [PATCH 18/19] remove deprecation warnings --- langfuse/_client/client.py | 50 -------------------------------------- langfuse/_client/span.py | 44 --------------------------------- 2 files changed, 94 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 880486dcf..5cba4e95f 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -1633,45 +1633,6 @@ def update_current_trace( ) -> None: """Update the current trace with additional information. - .. deprecated:: 3.9.0 - This method is deprecated and will be removed in a future version. - Use :func:`langfuse.propagate_attributes` instead. - - **Current behavior**: This method still works as expected - the Langfuse backend - handles setting trace-level attributes server-side. However, it will be removed - in a future version, so please migrate to ``propagate_attributes()``. - - **Why deprecated**: This method only sets attributes on a single span, which means - child spans created later won't have these attributes. This causes gaps when - using Langfuse aggregation queries (e.g., filtering by user_id or calculating - costs per session_id) because only the span with the attribute is included. - - **Migration**: Replace with ``propagate_attributes()`` to set attributes on ALL - child spans created within the context. Call it as early as possible in your trace: - - .. code-block:: python - - # OLD (deprecated) - with langfuse.start_as_current_span(name="handle-request") as span: - user = authenticate_user(request) - langfuse.update_current_trace( - user_id=user.id, - session_id=request.session_id - ) - # Child spans created here won't have user_id/session_id - response = process_request(request) - - # NEW (recommended) - with langfuse.start_as_current_span(name="handle-request"): - user = authenticate_user(request) - with langfuse.propagate_attributes( - user_id=user.id, - session_id=request.session_id, - metadata={"environment": "production"} - ): - # All child spans will have these attributes - response = process_request(request) - Args: name: Updated name for the Langfuse trace user_id: ID of the user who initiated the Langfuse trace @@ -1686,17 +1647,6 @@ def update_current_trace( See Also: :func:`langfuse.propagate_attributes`: Recommended replacement """ - warnings.warn( - "update_current_trace() is deprecated and will be removed in a future version. " - "While it still works (handled server-side), it only sets attributes on a single span, " - "causing gaps in aggregation queries. " - "Migrate to `with langfuse.propagate_attributes(user_id=..., session_id=..., metadata={...})` " - "to propagate attributes to ALL child spans. Call propagate_attributes() as early " - "as possible in your trace for complete coverage. " - "See: https://langfuse.com/docs/sdk/python/decorators#trace-level-attributes", - DeprecationWarning, - stacklevel=2, - ) if not self._tracing_enabled: langfuse_logger.debug( "Operation skipped: update_current_trace - Tracing is disabled or client is in no-op mode." diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index ce3dd2d58..72ebb6bee 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -223,39 +223,6 @@ def update_trace( ) -> "LangfuseObservationWrapper": """Update the trace that this span belongs to. - .. deprecated:: 3.9.0 - This method is deprecated and will be removed in a future version. - Use :func:`langfuse.propagate_attributes` instead. - - **Current behavior**: This method still works as expected - the Langfuse backend - handles setting trace-level attributes server-side. However, it will be removed - in a future version, so please migrate to ``propagate_attributes()``. - - **Why deprecated**: This method only sets attributes on a single span, which means - child spans created later won't have these attributes. This causes gaps when - using Langfuse aggregation queries (e.g., filtering by user_id or calculating - costs per session_id) because only the span with the attribute is included. - - **Migration**: Replace with ``propagate_attributes()`` to set attributes on ALL - child spans created within the context. Call it as early as possible in your trace: - - .. code-block:: python - - # OLD (deprecated) - with langfuse.start_as_current_span(name="workflow") as span: - span.update_trace(user_id="user_123", session_id="session_abc") - # Child spans won't have user_id/session_id - - # NEW (recommended) - with langfuse.start_as_current_span(name="workflow"): - with langfuse.propagate_attributes( - user_id="user_123", - session_id="session_abc", - metadata={"experiment": "variant_a"} - ): - # All child spans will have these attributes - pass - Args: name: Updated name for the trace user_id: ID of the user who initiated the trace @@ -270,17 +237,6 @@ def update_trace( See Also: :func:`langfuse.propagate_attributes`: Recommended replacement """ - warnings.warn( - "update_trace() is deprecated and will be removed in a future version. " - "While it still works (handled server-side), it only sets attributes on a single span, " - "causing gaps in aggregation queries. " - "Migrate to `with langfuse.propagate_attributes(user_id=..., session_id=..., metadata={...})` " - "to propagate attributes to ALL child spans. Call propagate_attributes() as early " - "as possible in your trace for complete coverage. " - "See: https://langfuse.com/docs/sdk/python/decorators#trace-level-attributes", - DeprecationWarning, - stacklevel=2, - ) if not self._otel_span.is_recording(): return self From 854026a00c8571019a3294767d0fa0ecc1944e08 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:53:47 +0100 Subject: [PATCH 19/19] add env --- langfuse/_client/client.py | 2 ++ langfuse/_client/constants.py | 1 + langfuse/_client/propagation.py | 9 +++++ tests/test_propagate_attributes.py | 58 ++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 5cba4e95f..ffdb61d37 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -38,6 +38,7 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes, _serialize from langfuse._client.constants import ( + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, ObservationTypeGenerationLike, ObservationTypeLiteral, ObservationTypeLiteralNoEvent, @@ -2820,6 +2821,7 @@ async def _process_experiment_item( { k: v for k, v in { + LangfuseOtelSpanAttributes.ENVIRONMENT: LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, LangfuseOtelSpanAttributes.EXPERIMENT_DESCRIPTION: experiment_description, LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_EXPECTED_OUTPUT: _serialize( expected_output diff --git a/langfuse/_client/constants.py b/langfuse/_client/constants.py index 8438bff1b..c2d0aa7aa 100644 --- a/langfuse/_client/constants.py +++ b/langfuse/_client/constants.py @@ -9,6 +9,7 @@ LANGFUSE_TRACER_NAME = "langfuse-sdk" +LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT = "sdk-experiment" """Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat""" ObservationTypeGenerationLike: TypeAlias = Literal[ diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index deb5fbec3..49d34a99f 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -23,6 +23,7 @@ ) from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT from langfuse.logger import langfuse_logger PropagatedKeys = Literal[ @@ -305,6 +306,14 @@ def _get_propagated_attributes_from_context( value if isinstance(value, (str, list)) else str(value) ) + if ( + LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID + in propagated_attributes + ): + propagated_attributes[LangfuseOtelSpanAttributes.ENVIRONMENT] = ( + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT + ) + return propagated_attributes diff --git a/tests/test_propagate_attributes.py b/tests/test_propagate_attributes.py index a259c23bb..16a960c1f 100644 --- a/tests/test_propagate_attributes.py +++ b/tests/test_propagate_attributes.py @@ -13,6 +13,7 @@ from langfuse import propagate_attributes from langfuse._client.attributes import LangfuseOtelSpanAttributes, _serialize +from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT from tests.test_otel import TestOTelBase @@ -2361,6 +2362,13 @@ def task_with_child_spans(*, item, **kwargs): _serialize({"item_type": "test", "priority": "high"}), ) + # Environment should be set to sdk-experiment + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Dataset ID should not be set for local data self.verify_missing_attribute( first_root, @@ -2402,6 +2410,13 @@ def task_with_child_spans(*, item, **kwargs): root_observation_id, ) + # Environment should be propagated to children + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Root-only attributes should NOT be present on children self.verify_missing_attribute( child_span, @@ -2539,6 +2554,13 @@ def task_with_children(*, item, **kwargs): _serialize(experiment_metadata), ) + # Environment should be set to sdk-experiment + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Verify child spans have dataset-specific propagated attributes child_spans = self.get_spans_by_name( memory_exporter, "dataset-child-1" @@ -2575,6 +2597,13 @@ def task_with_children(*, item, **kwargs): _serialize({"source": "dataset", "index": 0}), ) + # Environment should be propagated to children + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Root-only attributes should NOT be present on children self.verify_missing_attribute( child_span, @@ -2622,6 +2651,13 @@ def task_with_nested_spans(*, item, **kwargs): LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_ROOT_OBSERVATION_ID ] + # Verify root has environment set + self.verify_span_attribute( + first_root, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Verify all nested children have propagated attributes for span_name in ["child-span", "grandchild-span", "great-grandchild-span"]: span_data = self.get_span_by_name(memory_exporter, span_name) @@ -2643,6 +2679,13 @@ def task_with_nested_spans(*, item, **kwargs): root_observation_id, ) + # Environment should be propagated to all nested children + self.verify_span_attribute( + span_data, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Root-only attributes should NOT be present self.verify_missing_attribute( span_data, @@ -2695,6 +2738,14 @@ def task_with_child(*, item, **kwargs): langfuse_client.flush() time.sleep(0.1) + # Verify root span has environment set + root_span = self.get_span_by_name(memory_exporter, "experiment-item-run") + self.verify_span_attribute( + root_span, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + ) + # Verify child span has both experiment and item metadata propagated child_span = self.get_span_by_name(memory_exporter, "metadata-child") @@ -2711,3 +2762,10 @@ def task_with_child(*, item, **kwargs): LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA, _serialize(item_metadata), ) + + # Verify environment is propagated to child + self.verify_span_attribute( + child_span, + LangfuseOtelSpanAttributes.ENVIRONMENT, + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + )