Skip to content

Commit d3521ef

Browse files
authored
feat: propagate trace attributes onto all child spans on update (#1385)
1 parent 1319ddf commit d3521ef

7 files changed

Lines changed: 425 additions & 47 deletions

File tree

langfuse/_client/attributes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
ObservationTypeGenerationLike,
1919
ObservationTypeSpanLike,
2020
)
21-
2221
from langfuse._utils.serializer import EventSerializer
2322
from langfuse.model import PromptClient
2423
from langfuse.types import MapValue, SpanLevel

langfuse/_client/client.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Any,
1717
Callable,
1818
Dict,
19+
Generator,
1920
List,
2021
Literal,
2122
Optional,
@@ -27,8 +28,15 @@
2728

2829
import backoff
2930
import httpx
30-
from opentelemetry import trace
31-
from opentelemetry import trace as otel_trace_api
31+
from opentelemetry import (
32+
baggage as otel_baggage_api,
33+
)
34+
from opentelemetry import (
35+
context as otel_context_api,
36+
)
37+
from opentelemetry import (
38+
trace as otel_trace_api,
39+
)
3240
from opentelemetry.sdk.trace import TracerProvider
3341
from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
3442
from opentelemetry.util._decorator import (
@@ -39,6 +47,7 @@
3947

4048
from langfuse._client.attributes import LangfuseOtelSpanAttributes
4149
from langfuse._client.constants import (
50+
LANGFUSE_CORRELATION_CONTEXT_KEY,
4251
ObservationTypeGenerationLike,
4352
ObservationTypeLiteral,
4453
ObservationTypeLiteralNoEvent,
@@ -69,7 +78,10 @@
6978
LangfuseSpan,
7079
LangfuseTool,
7180
)
72-
from langfuse._client.utils import run_async_safely
81+
from langfuse._client.utils import (
82+
get_attribute_key_from_correlation_context,
83+
run_async_safely,
84+
)
7385
from langfuse._utils import _get_timestamp
7486
from langfuse._utils.parse_error import handle_fern_exception
7587
from langfuse._utils.prompt_cache import PromptCache
@@ -189,6 +201,7 @@ class Langfuse:
189201
_resources: Optional[LangfuseResourceManager] = None
190202
_mask: Optional[MaskFunction] = None
191203
_otel_tracer: otel_trace_api.Tracer
204+
_host: str
192205

193206
def __init__(
194207
self,
@@ -348,6 +361,83 @@ def start_span(
348361
status_message=status_message,
349362
)
350363

364+
@_agnosticcontextmanager
365+
def correlation_context(
366+
self,
367+
correlation_context: Dict[str, str],
368+
*,
369+
as_baggage: bool = False,
370+
) -> Generator[None, None, None]:
371+
"""Create a context manager that propagates the given correlation_context to all spans within the context manager's scope.
372+
373+
Args:
374+
correlation_context (Dict[str, str]): Dictionary containing key-value pairs to be propagated
375+
to all spans within the context manager's scope. Common keys include user_id, session_id,
376+
and custom metadata. All values must be strings below 200 characters.
377+
as_baggage (bool, optional): If True, stores the values in OpenTelemetry baggage
378+
for cross-service propagation. If False, stores only in local context for
379+
current-service propagation. Defaults to False.
380+
381+
Returns:
382+
Context manager that sets values on all spans created within its scope.
383+
384+
Warning:
385+
When as_baggage=True, the values will be included in HTTP headers of any
386+
outbound requests made within this context. Only use this for non-sensitive
387+
identifiers that are safe to transmit across service boundaries.
388+
389+
Examples:
390+
```python
391+
# Local context only (default) - pass context as dictionary
392+
with langfuse.correlation_context({"session_id": "session_123"}):
393+
with langfuse.start_as_current_span(name="process-request") as span:
394+
# This span and all its children will have session_id="session_123"
395+
child_span = langfuse.start_span(name="child-operation")
396+
397+
# Multiple values in context dictionary
398+
with langfuse.correlation_context({"user_id": "user_456", "experiment": "A"}):
399+
# All spans will have both user_id and experiment attributes
400+
span = langfuse.start_span(name="experiment-operation")
401+
402+
# Cross-service propagation (use with caution)
403+
with langfuse.correlation_context({"session_id": "session_123"}, as_baggage=True):
404+
# session_id will be propagated to external service calls
405+
response = requests.get("https://api.example.com/data")
406+
```
407+
"""
408+
current_context = otel_context_api.get_current()
409+
current_span = otel_trace_api.get_current_span()
410+
411+
current_context = otel_context_api.set_value(
412+
LANGFUSE_CORRELATION_CONTEXT_KEY, correlation_context, current_context
413+
)
414+
415+
for key, value in correlation_context.items():
416+
if len(value) > 200:
417+
langfuse_logger.warning(
418+
f"Correlation context key '{key}' is over 200 characters ({len(value)} chars). Dropping value."
419+
)
420+
continue
421+
422+
attribute_key = get_attribute_key_from_correlation_context(key)
423+
424+
if current_span is not None and current_span.is_recording():
425+
current_span.set_attribute(attribute_key, value)
426+
427+
if as_baggage:
428+
current_context = otel_baggage_api.set_baggage(
429+
key, value, current_context
430+
)
431+
432+
# Activate context, execute, and detach context
433+
token = otel_context_api.attach(current_context)
434+
435+
try:
436+
yield
437+
438+
finally:
439+
otel_context_api.detach(token)
440+
351441
def start_as_current_span(
352442
self,
353443
*,
@@ -1665,6 +1755,11 @@ def update_current_trace(
16651755
span.update(output=response)
16661756
```
16671757
"""
1758+
warnings.warn(
1759+
"update_current_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ",
1760+
DeprecationWarning,
1761+
stacklevel=2,
1762+
)
16681763
if not self._tracing_enabled:
16691764
langfuse_logger.debug(
16701765
"Operation skipped: update_current_trace - Tracing is disabled or client is in no-op mode."
@@ -1809,7 +1904,7 @@ def _create_remote_parent_span(
18091904
is_remote=False,
18101905
)
18111906

1812-
return trace.NonRecordingSpan(span_context)
1907+
return otel_trace_api.NonRecordingSpan(span_context)
18131908

18141909
def _is_valid_trace_id(self, trace_id: str) -> bool:
18151910
pattern = r"^[0-9a-f]{32}$"

langfuse/_client/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
This module defines constants used throughout the Langfuse OpenTelemetry integration.
44
"""
55

6-
from typing import Literal, List, get_args, Union, Any
6+
from typing import Any, List, Literal, Union, get_args
7+
78
from typing_extensions import TypeAlias
89

910
LANGFUSE_TRACER_NAME = "langfuse-sdk"
1011

12+
LANGFUSE_CORRELATION_CONTEXT_KEY = "langfuse.ctx.correlation"
13+
1114

1215
"""Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat"""
1316
ObservationTypeGenerationLike: TypeAlias = Literal[

langfuse/_client/span.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
and scoring integration specific to Langfuse's observability platform.
1414
"""
1515

16+
import warnings
1617
from datetime import datetime
1718
from time import time_ns
18-
import warnings
1919
from typing import (
2020
TYPE_CHECKING,
2121
Any,
@@ -45,10 +45,10 @@
4545
create_trace_attributes,
4646
)
4747
from langfuse._client.constants import (
48-
ObservationTypeLiteral,
4948
ObservationTypeGenerationLike,
50-
ObservationTypeSpanLike,
49+
ObservationTypeLiteral,
5150
ObservationTypeLiteralNoEvent,
51+
ObservationTypeSpanLike,
5252
get_observation_types_list,
5353
)
5454
from langfuse.logger import langfuse_logger
@@ -236,6 +236,11 @@ def update_trace(
236236
tags: List of tags to categorize the trace
237237
public: Whether the trace should be publicly accessible
238238
"""
239+
warnings.warn(
240+
"update_trace is deprecated and will be removed in a future version. Use `with langfuse.correlation_context(...)` instead. ",
241+
DeprecationWarning,
242+
stacklevel=2,
243+
)
239244
if not self._otel_span.is_recording():
240245
return self
241246

langfuse/_client/span_processor.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,29 @@
1515
import os
1616
from typing import Dict, List, Optional
1717

18+
from opentelemetry import baggage
19+
from opentelemetry import context as context_api
20+
from opentelemetry.context import Context
1821
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
19-
from opentelemetry.sdk.trace import ReadableSpan
22+
from opentelemetry.sdk.trace import ReadableSpan, Span
2023
from opentelemetry.sdk.trace.export import BatchSpanProcessor
24+
from opentelemetry.trace import format_span_id
2125

22-
from langfuse._client.constants import LANGFUSE_TRACER_NAME
26+
from langfuse._client.attributes import LangfuseOtelSpanAttributes
27+
from langfuse._client.constants import (
28+
LANGFUSE_CORRELATION_CONTEXT_KEY,
29+
LANGFUSE_TRACER_NAME,
30+
)
2331
from langfuse._client.environment_variables import (
2432
LANGFUSE_FLUSH_AT,
2533
LANGFUSE_FLUSH_INTERVAL,
2634
LANGFUSE_OTEL_TRACES_EXPORT_PATH,
2735
)
28-
from langfuse._client.utils import span_formatter
36+
from langfuse._client.utils import (
37+
correlation_context_to_attribute_map,
38+
get_attribute_key_from_correlation_context,
39+
span_formatter,
40+
)
2941
from langfuse.logger import langfuse_logger
3042
from langfuse.version import __version__ as langfuse_version
3143

@@ -114,6 +126,49 @@ def __init__(
114126
else None,
115127
)
116128

129+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
130+
# Propagate correlation context to span
131+
current_context = parent_context or context_api.get_current()
132+
propagated_attributes = {}
133+
134+
# Propagate correlation context in baggage
135+
baggage_entries = baggage.get_all(context=current_context)
136+
137+
for key, value in baggage_entries.items():
138+
if (
139+
key.startswith(LangfuseOtelSpanAttributes.TRACE_METADATA)
140+
or key in correlation_context_to_attribute_map.values()
141+
):
142+
propagated_attributes[key] = value
143+
144+
# Propagate correlation context in OTEL context
145+
correlation_context = (
146+
context_api.get_value(LANGFUSE_CORRELATION_CONTEXT_KEY, current_context)
147+
or {}
148+
)
149+
150+
if not isinstance(correlation_context, dict):
151+
langfuse_logger.error(
152+
f"Correlation context is not of type dict. Got type '{type(correlation_context)}'."
153+
)
154+
155+
return super().on_start(span, parent_context)
156+
157+
for key, value in correlation_context.items():
158+
attribute_key = get_attribute_key_from_correlation_context(key)
159+
propagated_attributes[attribute_key] = value
160+
161+
# Write attributes on span
162+
if propagated_attributes:
163+
for key, value in propagated_attributes.items():
164+
span.set_attribute(key, str(value))
165+
166+
langfuse_logger.debug(
167+
f"Propagated {len(propagated_attributes)} attributes to span '{format_span_id(span.context.span_id)}': {propagated_attributes}"
168+
)
169+
170+
return super().on_start(span, parent_context)
171+
117172
def on_end(self, span: ReadableSpan) -> None:
118173
# Only export spans that belong to the scoped project
119174
# This is important to not send spans to wrong project in multi-project setups

langfuse/_client/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from opentelemetry.sdk import util
1414
from opentelemetry.sdk.trace import ReadableSpan
1515

16+
from langfuse._client.attributes import LangfuseOtelSpanAttributes
17+
1618

1719
def span_formatter(span: ReadableSpan) -> str:
1820
parent_id = (
@@ -125,3 +127,16 @@ async def my_async_function():
125127
else:
126128
# Loop exists but not running, safe to use asyncio.run()
127129
return asyncio.run(coro)
130+
131+
132+
correlation_context_to_attribute_map = {
133+
"session_id": LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
134+
"user_id": LangfuseOtelSpanAttributes.TRACE_USER_ID,
135+
}
136+
137+
138+
def get_attribute_key_from_correlation_context(correlation_context_key: str) -> str:
139+
return (
140+
correlation_context_to_attribute_map.get(correlation_context_key)
141+
or f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{correlation_context_key}"
142+
)

0 commit comments

Comments
 (0)