Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 127 additions & 6 deletions langfuse/_client/propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@
"""

import re
from typing import Any, Dict, Generator, List, Literal, Optional, TypedDict, Union, cast
from typing import (
Any,
Dict,
Generator,
List,
Literal,
Mapping,
Optional,
Tuple,
TypedDict,
Union,
cast,
)

from opentelemetry import (
baggage,
Expand All @@ -29,6 +41,7 @@
from langfuse._client.attributes import LangfuseOtelSpanAttributes
from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT
from langfuse.logger import langfuse_logger
from langfuse.model import PromptClient

PropagatedKeys = Literal[
"user_id",
Expand All @@ -38,6 +51,8 @@
"tags",
"trace_name",
"environment",
"prompt_name",
"prompt_version",
]

InternalPropagatedKeys = Literal[
Expand All @@ -58,6 +73,8 @@
"tags",
"trace_name",
"environment",
"prompt_name",
"prompt_version",
"experiment_id",
"experiment_name",
"experiment_metadata",
Expand Down Expand Up @@ -103,6 +120,7 @@
tags: Optional[List[str]] = None,
trace_name: Optional[str] = None,
environment: Optional[str] = None,
prompt: Optional[Union[PromptClient, Mapping[str, Any]]] = None,
as_baggage: bool = False,
) -> _AgnosticContextManager[Any]:
"""Propagate trace-level attributes to all spans created within this context.
Expand Down Expand Up @@ -139,6 +157,17 @@
tags: List of tags to categorize the group of observations
trace_name: Name to assign to the trace. Must be US-ASCII string, ≤200 characters.
Use this to set a consistent trace name for all spans created within this context.
prompt: Langfuse prompt to link to generations created within this context.
Accepts a `PromptClient` returned by `langfuse.get_prompt(...)` or any
object/dict exposing `name` (string) and `version` (integer) — e.g.
`{"name": "my-prompt", "version": 3}`. This is the recommended way to
link prompts to generations emitted by auto-instrumentation libraries
(e.g. LiteLLM's `langfuse_otel`, OpenAI Agents SDK, OpenInference)
where you don't create the generation via the Langfuse SDK yourself.
The prompt link is only applied to generation-type observations by the
Langfuse backend. Fallback prompts are never linked. An explicit
`prompt` passed to `start_observation` / `update_current_generation`
takes precedence over the propagated one.
environment: Langfuse environment to assign to spans created in this context.
Must be a lowercase alphanumeric string with optional hyphens or underscores,
must be ≤40 characters, and must not start with "langfuse". This maps to
Expand Down Expand Up @@ -184,6 +213,24 @@
...
```

Prompt linking with auto-instrumented libraries:

```python
from langfuse import Langfuse

langfuse = Langfuse()
prompt = langfuse.get_prompt("my-prompt")

with langfuse.propagate_attributes(prompt=prompt):
# Generations emitted by auto-instrumentation (LiteLLM langfuse_otel,
# OpenAI Agents SDK, OpenInference, ...) within this context are
# linked to the prompt version.
completion = litellm.completion(
model="gpt-4o",
messages=prompt.compile(topic="chickens"),
)
```

Late propagation (anti-pattern):

```python
Expand Down Expand Up @@ -244,6 +291,7 @@
tags=tags,
trace_name=trace_name,
environment=environment,
prompt=prompt,
as_baggage=as_baggage,
)

Expand All @@ -258,6 +306,7 @@
tags: Optional[List[str]] = None,
trace_name: Optional[str] = None,
environment: Optional[str] = None,
prompt: Optional[Union[PromptClient, Mapping[str, Any]]] = None,
as_baggage: bool = False,
experiment: Optional[PropagatedExperimentAttributes] = None,
) -> Generator[Any, Any, Any]:
Expand All @@ -273,6 +322,25 @@
"environment": environment,
}

prompt_info = _extract_propagated_prompt(prompt) if prompt is not None else None
if prompt_info is not None:
prompt_name, prompt_version = prompt_info

context = _set_propagated_attribute(
key="prompt_name",
value=prompt_name,
context=context,
span=current_span,
Comment thread
hassiebp marked this conversation as resolved.
as_baggage=as_baggage,
)
context = _set_propagated_attribute(
key="prompt_version",
value=prompt_version,
context=context,
span=current_span,
as_baggage=as_baggage,

Check warning on line 341 in langfuse/_client/propagation.py

View check run for this annotation

Claude / Claude Code Review

Propagated prompt overwrites explicit prompt on current span

The docstring on `propagate_attributes` promises "An explicit prompt passed to start_observation / update_current_generation takes precedence over the propagated one," but this is only enforced for child spans via the `on_start` guard in `span_processor.py`. When `propagate_attributes(prompt=Y)` is entered while a generation created with an explicit `prompt=X` is the current active span, `_set_propagated_attribute` unconditionally calls `span.set_attribute` on that current span (propagation.py:3
Comment on lines +325 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The docstring on propagate_attributes promises "An explicit prompt passed to start_observation / update_current_generation takes precedence over the propagated one," but this is only enforced for child spans via the on_start guard in span_processor.py. When propagate_attributes(prompt=Y) is entered while a generation created with an explicit prompt=X is the current active span, _set_propagated_attribute unconditionally calls span.set_attribute on that current span (propagation.py:325-341 → 570-580), silently overwriting the explicit prompt link with the propagated one on export. Adding a symmetric guard in _propagate_attributes — skip the current-span writes for prompt_name/prompt_version when OBSERVATION_PROMPT_NAME is already present — would restore the documented precedence.

Extended reasoning...

What breaks

In _propagate_attributes (propagation.py:325-341), when a prompt is passed, _set_propagated_attribute is called with span=current_span for both prompt_name and prompt_version. That helper (propagation.py:570-580) checks only span is not None and span.is_recording() and then unconditionally calls span.set_attribute(key=span_key, value=value). OTel set_attribute overwrites any prior value under the same key, so an OBSERVATION_PROMPT_NAME/OBSERVATION_PROMPT_VERSION already set on the current span by create_generation_attributes (attributes.py:141-146) is silently replaced by the propagated prompt.

Why the existing precedence guard does not help

The on_start guard added in this PR (span_processor.py:147-158) inspects span.attributes at span-creation time and drops propagated prompt keys if the new child already has an explicit prompt. That guard only fires for new spans starting inside the propagation context. The outer generation was created before propagate_attributes is entered — its on_start already ran with empty propagated context, and its explicit prompt was written by set_attributes afterwards. The direct span.set_attribute inside _set_propagated_attribute never goes through on_start again, so there is nothing to enforce precedence on the current/parent span.

Other propagated attributes (user_id, session_id, tags, …) do not have this problem because they cannot be set at observation-construction time — only prompt has this dual write path.

Step-by-step proof

Given:

prompt_a = langfuse.get_prompt("prompt-a")   # version 1
prompt_b = langfuse.get_prompt("prompt-b")   # version 2

with langfuse.start_as_current_generation(name="outer", prompt=prompt_a) as gen:
    with langfuse.propagate_attributes(prompt=prompt_b):
        # ... auto-instrumented sub-call ...
        pass
  1. start_as_current_generation(prompt=prompt_a) starts gen. on_start sees empty propagated context. Immediately after, create_generation_attributes sets OBSERVATION_PROMPT_NAME='prompt-a', OBSERVATION_PROMPT_VERSION=1 on gen (attributes.py:141-146).
  2. propagate_attributes(prompt=prompt_b) calls _propagate_attributes, which extracts ('prompt-b', 2) and calls _set_propagated_attribute twice with span=current_span=gen.
  3. Inside _set_propagated_attribute, gen.is_recording() is True, so it hits the else branch at line 580 and calls gen.set_attribute(OBSERVATION_PROMPT_NAME, 'prompt-b') — overwriting 'prompt-a' — then gen.set_attribute(OBSERVATION_PROMPT_VERSION, 2) — overwriting 1.
  4. When gen ends and is exported, its prompt link points to prompt-b, not the explicitly-passed prompt-a.

The included test test_explicit_prompt_takes_precedence_over_propagated only covers the case where the explicit prompt is set on a child span created inside the propagation context — that path goes through on_start and is protected. The parent-span variant is not covered.

Suggested fix

Mirror the on_start guard inside _propagate_attributes — before writing prompt attributes to current_span, check whether current_span already carries OBSERVATION_PROMPT_NAME and, if so, skip the direct span.set_attribute for both prompt keys (still write them to the OTel context so child spans without an explicit prompt inherit them).

)

propagated_metadata_attributes: Dict[str, Optional[Dict[str, Any]]] = {
"metadata": metadata,
}
Expand Down Expand Up @@ -336,10 +404,51 @@
_detach_context_token_safely(token)


def _extract_propagated_prompt(
prompt: Union[PromptClient, Mapping[str, Any]],
) -> Optional[Tuple[str, int]]:
"""Extract and validate (name, version) from a prompt-like value.

Accepts a PromptClient or any mapping/object exposing `name` and `version`.
Returns None (with a warning) if the value is invalid or a fallback prompt.
"""
if isinstance(prompt, Mapping):
name = prompt.get("name")
version = prompt.get("version")
is_fallback = bool(prompt.get("is_fallback", False))
else:
name = getattr(prompt, "name", None)
version = getattr(prompt, "version", None)
is_fallback = bool(getattr(prompt, "is_fallback", False))

if is_fallback:
langfuse_logger.debug(
"Propagated prompt is a fallback prompt. Skipping prompt linking."
)
return None

if not isinstance(name, str) or not name:
langfuse_logger.warning(
"Propagated 'prompt' has no valid 'name' (non-empty string required). Dropping prompt link."
)
return None

if isinstance(version, str) and version.isdigit():
version = int(version)

Check warning on line 437 in langfuse/_client/propagation.py

View check run for this annotation

Claude / Claude Code Review

Unicode digit strings crash prompt version coercion

Version coercion in `_extract_propagated_prompt` (propagation.py:436-437) and the baggage-decode branch of `_get_propagated_attributes_from_context` (propagation.py:474-480) gate `int(version)` on `str.isdigit()`, which returns True for Unicode digit-like characters (superscripts '²', '¹', '⁷', etc.) that `int()` rejects with `ValueError`. Passing `version='²'` at context-manager entry — or receiving a crafted W3C baggage header like `langfuse_prompt_version=²` in `LangfuseSpanProcessor.on_start
Comment on lines +436 to +437

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Version coercion in _extract_propagated_prompt (propagation.py:436-437) and the baggage-decode branch of _get_propagated_attributes_from_context (propagation.py:474-480) gate int(version) on str.isdigit(), which returns True for Unicode digit-like characters (superscripts '²', '¹', '⁷', etc.) that int() rejects with ValueError. Passing version='²' at context-manager entry — or receiving a crafted W3C baggage header like langfuse_prompt_version=² in LangfuseSpanProcessor.on_start — raises an uncaught ValueError instead of dropping the value with a warning per the docstring contract. Fix by replacing the gate with .isdecimal() plus an ASCII check (or re.fullmatch(r'-?[0-9]+', version)), or wrapping the int() call in try/except ValueError at both sites.

Extended reasoning...

The bug

The PR introduces two identical version-coercion sites that both use the same too-permissive gate:

Site 1_extract_propagated_prompt (propagation.py:436-437):

if isinstance(version, str) and version.isdigit():
    version = int(version)

Site 2_get_propagated_attributes_from_context baggage branch (propagation.py:474-480):

if (
    span_key == LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION
    and isinstance(baggage_value, str)
    and baggage_value.isdigit()
):
    propagated_attributes[span_key] = int(baggage_value)
    continue

str.isdigit() is a strict superset of what int(str) accepts. int() accepts characters where .isdecimal() is True (ASCII 0-9 plus other decimal scripts like Arabic-Indic '٠'-'٩'), but rejects .isdigit()-only characters such as superscripts.

Step-by-step proof

Verified on Python 3.11:

>>> '²'.isdigit()
True
>>> int('²')
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: '²'

Same for '¹', '⁷', and other superscript/Unicode digit chars.

Scenario A (user input):

  1. User writes with langfuse.propagate_attributes(prompt={"name": "x", "version": "²"}):
  2. _propagate_attributes calls _extract_propagated_prompt({"name":"x","version":"²"}) (propagation.py:325).
  3. Line 436: isinstance("²", str) and "²".isdigit()True.
  4. Line 437: int("²") raises ValueError.
  5. The exception propagates out of the context-manager __enter__, so the with block never runs — the user's request crashes.

This contradicts the docstring contract stated on the same function: "Returns None (with a warning) if the value is invalid" — and the class docstring: "invalid values are dropped with a warning". The intended fall-through already exists at line 439-443; the gate simply lets the wrong things past it.

Scenario B (cross-service baggage):

  1. Any incoming request carrying Baggage: langfuse_prompt_version=² populates OTel baggage on the request context.
  2. When any span is created, LangfuseSpanProcessor.on_start runs (span_processor.py:145) and calls _get_propagated_attributes_from_context(context) with no try/except wrapper.
  3. The baggage branch (line 474-480) hits baggage_value.isdigit() == True, then int("²") raises ValueError.
  4. OTel's SynchronousMultiSpanProcessor.on_start dispatch does not catch processor exceptions, so the ValueError propagates up through Span.startTracer.start_span and aborts span creation for every span in the trace.

Why existing code doesn't prevent this

  • The isinstance(version, int) fall-through at line 439-443 would gracefully drop an invalid version — but it's only reached if the int() conversion succeeds or is skipped. With the current gate, malformed input takes the raising branch instead.
  • The type check isinstance(version, bool) right below correctly rejects True/False, showing the author was thinking about edge cases — the Unicode-digit case was just missed.
  • On the baggage path there's no guard at all: the on_start call site is a bare method call with no exception handling.

Impact

  • Scenario A: low probability trigger (users don't normally type superscripts), but a real contract violation. Any user who feeds a non-ASCII string through their prompt-version pipeline hits an uncaught exception where they were promised a warning.
  • Scenario B: also low probability in practice — legitimate Langfuse→Langfuse baggage propagation uses str(int) on the sender side, so no legitimate producer emits '²'. But baggage is untrusted header data in general; a peer service or crafted request could inject arbitrary strings, and the resulting exception surfaces from SpanProcessor.on_start on every span in the affected request.

Fix

Either of these at both sites:

  1. Tighten the gate:
if isinstance(version, str) and re.fullmatch(r"-?[0-9]+", version):
    version = int(version)

(or version.isdecimal() and version.isascii() — beware that plain .isdecimal() still accepts non-ASCII decimal digits like '٠', which int() does accept but which are almost certainly not what a Langfuse prompt version should look like.)

  1. Or wrap in try/except and let the existing fall-through handle it:
if isinstance(version, str):
    try:
        version = int(version)
    except ValueError:
        pass

Option 1 is preferable at the baggage site because it keeps the code exception-free rather than relying on catch-and-continue in a hot path.

Severity

Marking as nit: the trigger requires unusual (superscript characters) or actively hostile (crafted baggage headers) input, and the fix is a two-line change confined to already-changed code. Worth cleaning up in this PR since both sites are new; not worth blocking merge over.


if not isinstance(version, int) or isinstance(version, bool):
langfuse_logger.warning(
"Propagated 'prompt' has no valid 'version' (integer required). Dropping prompt link."
)
return None

return name, version


def _get_propagated_attributes_from_context(
context: otel_context_api.Context,
) -> Dict[str, Union[str, List[str]]]:
propagated_attributes: Dict[str, Union[str, List[str]]] = {}
) -> Dict[str, Union[str, int, List[str]]]:
propagated_attributes: Dict[str, Union[str, int, List[str]]] = {}

# Handle baggage
baggage_entries = baggage.get_all(context=context)
Expand All @@ -362,6 +471,14 @@
propagated_attributes[span_key] = validated_environment
continue

if (
span_key == LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION
and isinstance(baggage_value, str)
and baggage_value.isdigit()
):
propagated_attributes[span_key] = int(baggage_value)
continue

propagated_attributes[span_key] = (
baggage_value
if isinstance(baggage_value, (str, list))
Expand Down Expand Up @@ -398,7 +515,7 @@
span_key = _get_propagated_span_key(key)

propagated_attributes[span_key] = (
value if isinstance(value, (str, list)) else str(value)
value if isinstance(value, (str, int, list)) else str(value)
)

if (
Expand All @@ -415,7 +532,7 @@
def _set_propagated_attribute(
*,
key: str,
value: Union[str, List[str], Dict[str, str]],
value: Union[str, int, List[str], Dict[str, str]],
context: otel_context_api.Context,
span: otel_trace_api.Span,
as_baggage: bool,
Expand Down Expand Up @@ -472,7 +589,9 @@
)
else:
context = otel_baggage_api.set_baggage(
name=baggage_key, value=value, context=context
name=baggage_key,
value=str(value) if isinstance(value, int) else value,
context=context,
)

return context
Expand Down Expand Up @@ -622,6 +741,8 @@
"trace_name": LangfuseOtelSpanAttributes.TRACE_NAME,
"environment": LangfuseOtelSpanAttributes.ENVIRONMENT,
"metadata": LangfuseOtelSpanAttributes.TRACE_METADATA,
"prompt_name": LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
"prompt_version": LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,
"experiment_id": LangfuseOtelSpanAttributes.EXPERIMENT_ID,
"experiment_name": LangfuseOtelSpanAttributes.EXPERIMENT_NAME,
"experiment_metadata": LangfuseOtelSpanAttributes.EXPERIMENT_METADATA,
Expand Down
13 changes: 13 additions & 0 deletions langfuse/_client/span_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None
context = parent_context or context_api.get_current()
propagated_attributes = _get_propagated_attributes_from_context(context)

# An explicit prompt set at span creation takes precedence over a propagated one
existing_attributes = span.attributes or {}
if LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME in existing_attributes:
Comment thread
hassiebp marked this conversation as resolved.
propagated_attributes = {
key: value
for key, value in propagated_attributes.items()
if key
not in (
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,
)
}

if propagated_attributes:
span.set_attributes(propagated_attributes)

Expand Down
Loading