feat(tracing): support prompt linking via propagate_attributes#1750
Conversation
Add a `prompt` parameter to `propagate_attributes` that links a Langfuse prompt to all generations created within the context, including those emitted by third-party auto-instrumentation (LiteLLM langfuse_otel, OpenAI Agents SDK, OpenInference, ...). Accepts a PromptClient, a dict, or any object exposing `name` and `version`. The prompt name/version are propagated as the `langfuse.observation.prompt.name` / `.version` span attributes via the existing OTel context propagation, with baggage support. Fallback prompts are never linked, and an explicit per-observation prompt takes precedence over the propagated one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@claude review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: afcb25d42a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if isinstance(version, str) and version.isdigit(): | ||
| version = int(version) |
There was a problem hiding this comment.
🟡 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)
continuestr.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):
- User writes
with langfuse.propagate_attributes(prompt={"name": "x", "version": "²"}): _propagate_attributescalls_extract_propagated_prompt({"name":"x","version":"²"})(propagation.py:325).- Line 436:
isinstance("²", str) and "²".isdigit()→True. - Line 437:
int("²")raisesValueError. - The exception propagates out of the context-manager
__enter__, so thewithblock 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):
- Any incoming request carrying
Baggage: langfuse_prompt_version=²populates OTel baggage on the request context. - When any span is created,
LangfuseSpanProcessor.on_startruns (span_processor.py:145) and calls_get_propagated_attributes_from_context(context)with no try/except wrapper. - The baggage branch (line 474-480) hits
baggage_value.isdigit() == True, thenint("²")raisesValueError. - OTel's
SynchronousMultiSpanProcessor.on_startdispatch does not catch processor exceptions, so theValueErrorpropagates up throughSpan.start→Tracer.start_spanand 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 theint()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 rejectsTrue/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_startcall 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 fromSpanProcessor.on_starton every span in the affected request.
Fix
Either of these at both sites:
- 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.)
- Or wrap in try/except and let the existing fall-through handle it:
if isinstance(version, str):
try:
version = int(version)
except ValueError:
passOption 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.
| 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, | ||
| as_baggage=as_baggage, | ||
| ) | ||
| context = _set_propagated_attribute( | ||
| key="prompt_version", | ||
| value=prompt_version, | ||
| context=context, | ||
| span=current_span, | ||
| as_baggage=as_baggage, |
There was a problem hiding this comment.
🟡 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 ...
passstart_as_current_generation(prompt=prompt_a)startsgen.on_startsees empty propagated context. Immediately after,create_generation_attributessetsOBSERVATION_PROMPT_NAME='prompt-a',OBSERVATION_PROMPT_VERSION=1ongen(attributes.py:141-146).propagate_attributes(prompt=prompt_b)calls_propagate_attributes, which extracts('prompt-b', 2)and calls_set_propagated_attributetwice withspan=current_span=gen.- Inside
_set_propagated_attribute,gen.is_recording()is True, so it hits the else branch at line 580 and callsgen.set_attribute(OBSERVATION_PROMPT_NAME, 'prompt-b')— overwriting'prompt-a'— thengen.set_attribute(OBSERVATION_PROMPT_VERSION, 2)— overwriting1. - When
genends and is exported, its prompt link points toprompt-b, not the explicitly-passedprompt-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).
Summary
Adds a
promptparameter topropagate_attributes, enabling prompt↔trace linking for generations created by auto-instrumentation integrations (LiteLLMlangfuse_otel, OpenAI Agents SDK, OpenInference, ...) where the user never creates the generation via the Langfuse SDK. Previously this required a hand-rolled OTelSpanProcessorper prompt (see langfuse/langfuse#9345).Details
promptaccepts aPromptClient, a dict, or any object exposingname(str) andversion(int) — e.g.{"name": "my-prompt", "version": 3}. Digit-string versions are coerced to int; invalid values are dropped with a warning; fallback prompts are never linked (consistent with existing linking behavior).langfuse.observation.prompt.name/langfuse.observation.prompt.versionspan attributes, includingas_baggage=Truecross-service propagation (version restored as int on read).start_observation(..., prompt=...)) wins over the propagated one (guard inLangfuseSpanProcessor.on_start).propagate_attributes(prompt=...)preserves an outersession_id/user_idcontext; same-key nesting shadows within scope and restores on exit (covered by tests).Server-side companion
Since propagation stamps every span in scope, the backend gates prompt linking to GENERATION observations: langfuse/langfuse#14962. This PR should be released after that change is deployed.
Tests
tests/unit/test_propagate_attributes.py(PromptClient/dict/duck-typed inputs, fallback skip, invalid name/version, explicit-over-propagated precedence, nested shadowing, baggage round-trip, composition with outer attributes).test_prompt.pyfixture errors on Python 3.14 reproduce on a clean tree, unrelated).🤖 Generated with Claude Code
Greptile Summary
This PR adds a
promptparameter topropagate_attributes, letting users link a Langfuse prompt to all generations emitted by auto-instrumentation libraries (LiteLLMlangfuse_otel, OpenAI Agents SDK, OpenInference) without touching the SDK's observation creation API._extract_propagated_promptvalidates the prompt input (PromptClient, dict, or duck-typed object), skips fallback prompts, coerces digit-string versions to int, and drops invalid inputs with a warning.isdigit()on read-back).LangfuseSpanProcessor.on_start, an explicitpromptset at span-creation time takes precedence over the propagated one by filtering it from the propagated dict beforeset_attributesis called.Confidence Score: 4/5
Safe to merge once the companion backend change (langfuse/langfuse#14962) is deployed; the SDK changes themselves introduce no regressions in the existing propagation path.
The core logic is sound and well-tested. Two small issues were found: the public type hint for the
promptparameter omits the duck-typed object case that is explicitly documented and tested, and the test helper_make_prompt_clientplaces two imports inside the method body rather than at module level.langfuse/_client/propagation.py— type annotation for thepromptparameter;tests/unit/test_propagate_attributes.py— inline imports in_make_prompt_client.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant User participant PA as propagate_attributes() participant EPP as _extract_propagated_prompt() participant SPA as _set_propagated_attribute() participant OTel as OTel Context / Baggage participant Span as Auto-instrumented Span participant LSP as LangfuseSpanProcessor.on_start() participant GPAC as _get_propagated_attributes_from_context() User->>PA: "propagate_attributes(prompt=prompt, as_baggage=False)" PA->>EPP: _extract_propagated_prompt(prompt) EPP-->>PA: (name, version) or None (fallback/invalid) alt prompt valid PA->>SPA: set prompt_name + prompt_version SPA->>OTel: set_value(context_key, value) [or set_baggage(str(version))] end User->>Span: auto-instrumentation creates span (e.g. LiteLLM) Span->>LSP: on_start(span, parent_context) LSP->>GPAC: _get_propagated_attributes_from_context(context) GPAC->>OTel: get context values / baggage entries OTel-->>GPAC: prompt_name, prompt_version (int restored via isdigit() if baggage) GPAC-->>LSP: propagated_attributes dict alt span has explicit OBSERVATION_PROMPT_NAME LSP->>LSP: drop prompt_name + prompt_version from propagated_attributes end LSP->>Span: span.set_attributes(propagated_attributes) Note over LSP,Span: Backend gates prompt linking to GENERATION observations%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant User participant PA as propagate_attributes() participant EPP as _extract_propagated_prompt() participant SPA as _set_propagated_attribute() participant OTel as OTel Context / Baggage participant Span as Auto-instrumented Span participant LSP as LangfuseSpanProcessor.on_start() participant GPAC as _get_propagated_attributes_from_context() User->>PA: "propagate_attributes(prompt=prompt, as_baggage=False)" PA->>EPP: _extract_propagated_prompt(prompt) EPP-->>PA: (name, version) or None (fallback/invalid) alt prompt valid PA->>SPA: set prompt_name + prompt_version SPA->>OTel: set_value(context_key, value) [or set_baggage(str(version))] end User->>Span: auto-instrumentation creates span (e.g. LiteLLM) Span->>LSP: on_start(span, parent_context) LSP->>GPAC: _get_propagated_attributes_from_context(context) GPAC->>OTel: get context values / baggage entries OTel-->>GPAC: prompt_name, prompt_version (int restored via isdigit() if baggage) GPAC-->>LSP: propagated_attributes dict alt span has explicit OBSERVATION_PROMPT_NAME LSP->>LSP: drop prompt_name + prompt_version from propagated_attributes end LSP->>Span: span.set_attributes(propagated_attributes) Note over LSP,Span: Backend gates prompt linking to GENERATION observationsPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat(tracing): support prompt linking vi..." | Re-trigger Greptile