Skip to content

Commit 5d949cf

Browse files
authored
feat(tracing): support prompt linking via propagate_attributes (#1750)
1 parent ea97c6d commit 5d949cf

3 files changed

Lines changed: 420 additions & 6 deletions

File tree

langfuse/_client/propagation.py

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@
66
"""
77

88
import re
9-
from typing import Any, Dict, Generator, List, Literal, Optional, TypedDict, Union, cast
9+
from typing import (
10+
Any,
11+
Dict,
12+
Generator,
13+
List,
14+
Literal,
15+
Mapping,
16+
Optional,
17+
Tuple,
18+
TypedDict,
19+
Union,
20+
cast,
21+
)
1022

1123
from opentelemetry import (
1224
baggage,
@@ -29,6 +41,7 @@
2941
from langfuse._client.attributes import LangfuseOtelSpanAttributes
3042
from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT
3143
from langfuse.logger import langfuse_logger
44+
from langfuse.model import PromptClient
3245

3346
PropagatedKeys = Literal[
3447
"user_id",
@@ -38,6 +51,8 @@
3851
"tags",
3952
"trace_name",
4053
"environment",
54+
"prompt_name",
55+
"prompt_version",
4156
]
4257

4358
InternalPropagatedKeys = Literal[
@@ -58,6 +73,8 @@
5873
"tags",
5974
"trace_name",
6075
"environment",
76+
"prompt_name",
77+
"prompt_version",
6178
"experiment_id",
6279
"experiment_name",
6380
"experiment_metadata",
@@ -103,6 +120,7 @@ def propagate_attributes(
103120
tags: Optional[List[str]] = None,
104121
trace_name: Optional[str] = None,
105122
environment: Optional[str] = None,
123+
prompt: Optional[Union[PromptClient, Mapping[str, Any]]] = None,
106124
as_baggage: bool = False,
107125
) -> _AgnosticContextManager[Any]:
108126
"""Propagate trace-level attributes to all spans created within this context.
@@ -139,6 +157,17 @@ def propagate_attributes(
139157
tags: List of tags to categorize the group of observations
140158
trace_name: Name to assign to the trace. Must be US-ASCII string, ≤200 characters.
141159
Use this to set a consistent trace name for all spans created within this context.
160+
prompt: Langfuse prompt to link to generations created within this context.
161+
Accepts a `PromptClient` returned by `langfuse.get_prompt(...)` or any
162+
object/dict exposing `name` (string) and `version` (integer) — e.g.
163+
`{"name": "my-prompt", "version": 3}`. This is the recommended way to
164+
link prompts to generations emitted by auto-instrumentation libraries
165+
(e.g. LiteLLM's `langfuse_otel`, OpenAI Agents SDK, OpenInference)
166+
where you don't create the generation via the Langfuse SDK yourself.
167+
The prompt link is only applied to generation-type observations by the
168+
Langfuse backend. Fallback prompts are never linked. An explicit
169+
`prompt` passed to `start_observation` / `update_current_generation`
170+
takes precedence over the propagated one.
142171
environment: Langfuse environment to assign to spans created in this context.
143172
Must be a lowercase alphanumeric string with optional hyphens or underscores,
144173
must be ≤40 characters, and must not start with "langfuse". This maps to
@@ -184,6 +213,24 @@ def propagate_attributes(
184213
...
185214
```
186215
216+
Prompt linking with auto-instrumented libraries:
217+
218+
```python
219+
from langfuse import Langfuse
220+
221+
langfuse = Langfuse()
222+
prompt = langfuse.get_prompt("my-prompt")
223+
224+
with langfuse.propagate_attributes(prompt=prompt):
225+
# Generations emitted by auto-instrumentation (LiteLLM langfuse_otel,
226+
# OpenAI Agents SDK, OpenInference, ...) within this context are
227+
# linked to the prompt version.
228+
completion = litellm.completion(
229+
model="gpt-4o",
230+
messages=prompt.compile(topic="chickens"),
231+
)
232+
```
233+
187234
Late propagation (anti-pattern):
188235
189236
```python
@@ -244,6 +291,7 @@ def propagate_attributes(
244291
tags=tags,
245292
trace_name=trace_name,
246293
environment=environment,
294+
prompt=prompt,
247295
as_baggage=as_baggage,
248296
)
249297

@@ -258,6 +306,7 @@ def _propagate_attributes(
258306
tags: Optional[List[str]] = None,
259307
trace_name: Optional[str] = None,
260308
environment: Optional[str] = None,
309+
prompt: Optional[Union[PromptClient, Mapping[str, Any]]] = None,
261310
as_baggage: bool = False,
262311
experiment: Optional[PropagatedExperimentAttributes] = None,
263312
) -> Generator[Any, Any, Any]:
@@ -273,6 +322,25 @@ def _propagate_attributes(
273322
"environment": environment,
274323
}
275324

325+
prompt_info = _extract_propagated_prompt(prompt) if prompt is not None else None
326+
if prompt_info is not None:
327+
prompt_name, prompt_version = prompt_info
328+
329+
context = _set_propagated_attribute(
330+
key="prompt_name",
331+
value=prompt_name,
332+
context=context,
333+
span=current_span,
334+
as_baggage=as_baggage,
335+
)
336+
context = _set_propagated_attribute(
337+
key="prompt_version",
338+
value=prompt_version,
339+
context=context,
340+
span=current_span,
341+
as_baggage=as_baggage,
342+
)
343+
276344
propagated_metadata_attributes: Dict[str, Optional[Dict[str, Any]]] = {
277345
"metadata": metadata,
278346
}
@@ -336,10 +404,51 @@ def _propagate_attributes(
336404
_detach_context_token_safely(token)
337405

338406

407+
def _extract_propagated_prompt(
408+
prompt: Union[PromptClient, Mapping[str, Any]],
409+
) -> Optional[Tuple[str, int]]:
410+
"""Extract and validate (name, version) from a prompt-like value.
411+
412+
Accepts a PromptClient or any mapping/object exposing `name` and `version`.
413+
Returns None (with a warning) if the value is invalid or a fallback prompt.
414+
"""
415+
if isinstance(prompt, Mapping):
416+
name = prompt.get("name")
417+
version = prompt.get("version")
418+
is_fallback = bool(prompt.get("is_fallback", False))
419+
else:
420+
name = getattr(prompt, "name", None)
421+
version = getattr(prompt, "version", None)
422+
is_fallback = bool(getattr(prompt, "is_fallback", False))
423+
424+
if is_fallback:
425+
langfuse_logger.debug(
426+
"Propagated prompt is a fallback prompt. Skipping prompt linking."
427+
)
428+
return None
429+
430+
if not isinstance(name, str) or not name:
431+
langfuse_logger.warning(
432+
"Propagated 'prompt' has no valid 'name' (non-empty string required). Dropping prompt link."
433+
)
434+
return None
435+
436+
if isinstance(version, str) and version.isdigit():
437+
version = int(version)
438+
439+
if not isinstance(version, int) or isinstance(version, bool):
440+
langfuse_logger.warning(
441+
"Propagated 'prompt' has no valid 'version' (integer required). Dropping prompt link."
442+
)
443+
return None
444+
445+
return name, version
446+
447+
339448
def _get_propagated_attributes_from_context(
340449
context: otel_context_api.Context,
341-
) -> Dict[str, Union[str, List[str]]]:
342-
propagated_attributes: Dict[str, Union[str, List[str]]] = {}
450+
) -> Dict[str, Union[str, int, List[str]]]:
451+
propagated_attributes: Dict[str, Union[str, int, List[str]]] = {}
343452

344453
# Handle baggage
345454
baggage_entries = baggage.get_all(context=context)
@@ -362,6 +471,14 @@ def _get_propagated_attributes_from_context(
362471
propagated_attributes[span_key] = validated_environment
363472
continue
364473

474+
if (
475+
span_key == LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION
476+
and isinstance(baggage_value, str)
477+
and baggage_value.isdigit()
478+
):
479+
propagated_attributes[span_key] = int(baggage_value)
480+
continue
481+
365482
propagated_attributes[span_key] = (
366483
baggage_value
367484
if isinstance(baggage_value, (str, list))
@@ -398,7 +515,7 @@ def _get_propagated_attributes_from_context(
398515
span_key = _get_propagated_span_key(key)
399516

400517
propagated_attributes[span_key] = (
401-
value if isinstance(value, (str, list)) else str(value)
518+
value if isinstance(value, (str, int, list)) else str(value)
402519
)
403520

404521
if (
@@ -415,7 +532,7 @@ def _get_propagated_attributes_from_context(
415532
def _set_propagated_attribute(
416533
*,
417534
key: str,
418-
value: Union[str, List[str], Dict[str, str]],
535+
value: Union[str, int, List[str], Dict[str, str]],
419536
context: otel_context_api.Context,
420537
span: otel_trace_api.Span,
421538
as_baggage: bool,
@@ -472,7 +589,9 @@ def _set_propagated_attribute(
472589
)
473590
else:
474591
context = otel_baggage_api.set_baggage(
475-
name=baggage_key, value=value, context=context
592+
name=baggage_key,
593+
value=str(value) if isinstance(value, int) else value,
594+
context=context,
476595
)
477596

478597
return context
@@ -622,6 +741,8 @@ def _get_propagated_span_key(key: str) -> str:
622741
"trace_name": LangfuseOtelSpanAttributes.TRACE_NAME,
623742
"environment": LangfuseOtelSpanAttributes.ENVIRONMENT,
624743
"metadata": LangfuseOtelSpanAttributes.TRACE_METADATA,
744+
"prompt_name": LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
745+
"prompt_version": LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,
625746
"experiment_id": LangfuseOtelSpanAttributes.EXPERIMENT_ID,
626747
"experiment_name": LangfuseOtelSpanAttributes.EXPERIMENT_NAME,
627748
"experiment_metadata": LangfuseOtelSpanAttributes.EXPERIMENT_METADATA,

langfuse/_client/span_processor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None
144144
context = parent_context or context_api.get_current()
145145
propagated_attributes = _get_propagated_attributes_from_context(context)
146146

147+
# An explicit prompt set at span creation takes precedence over a propagated one
148+
existing_attributes = span.attributes or {}
149+
if LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME in existing_attributes:
150+
propagated_attributes = {
151+
key: value
152+
for key, value in propagated_attributes.items()
153+
if key
154+
not in (
155+
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
156+
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,
157+
)
158+
}
159+
147160
if propagated_attributes:
148161
span.set_attributes(propagated_attributes)
149162

0 commit comments

Comments
 (0)