Skip to content
Open
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
24 changes: 24 additions & 0 deletions langfuse/_client/propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
propagate to all child spans within the context.
"""

import ast
import json
import re
from typing import (
Any,
Expand Down Expand Up @@ -492,6 +494,12 @@ def _get_propagated_attributes_from_context(
propagated_attributes[span_key] = int(baggage_value)
continue

if span_key == LangfuseOtelSpanAttributes.TRACE_TAGS and isinstance(
baggage_value, str
):
propagated_attributes[span_key] = _parse_baggage_tags(baggage_value)
continue

propagated_attributes[span_key] = (
baggage_value
if isinstance(baggage_value, (str, list))
Expand Down Expand Up @@ -542,6 +550,22 @@ def _get_propagated_attributes_from_context(
return propagated_attributes


def _parse_baggage_tags(value: str) -> List[str]:
# The Python SDK writes str(list), e.g. "['a', 'b']", and the JS SDK writes "a,b".
# JSON goes first because literal_eval does not join JSON's escaped surrogate pairs.
if value.startswith("["):
for parse in (json.loads, ast.literal_eval):
try:
tags = parse(value)
except Exception:
continue

if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags):
return tags
Comment on lines +563 to +564

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 List-shaped tags change meaning A JS-originated single tag whose literal value is ["red","blue"] is parsed as two tags here. The downstream span records red and blue instead of the original tag, making trace tags unreliable for this valid value.

Knowledge Base Used: OpenTelemetry propagation and export

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/propagation.py
Line: 563-564

Comment:
**List-shaped tags change meaning** A JS-originated single tag whose literal value is `["red","blue"]` is parsed as two tags here. The downstream span records `red` and `blue` instead of the original tag, making trace tags unreliable for this valid value.

**Knowledge Base Used:** [OpenTelemetry propagation and export](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/opentelemetry-propagation-and-export.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch on the edge case. A single tag whose text is itself a JSON array is ambiguous on the wire: the AI gateway's parse_tags reads ["red","blue"] as two tags as well, and the JS SDK's comma split already breaks that tag apart today. I kept the reader consistent with the gateway, but I'm happy to narrow it if you'd prefer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That鈥檚 a fair clarification. Given that the wire format is already ambiguous and the gateway鈥檚 parse_tags applies the same interpretation, keeping the reader aligned with the gateway is the least surprising behavior. The JS SDK鈥檚 comma-delimited format also cannot preserve a tag containing commas, so narrowing only the Python reader would not restore round-trip fidelity. I鈥檓 withdrawing this edge-case finding; no change is needed here.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.


return value.split(",")


def _set_propagated_attribute(
*,
key: str,
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/test_propagate_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import concurrent.futures
import json
from datetime import datetime

import pytest
Expand Down Expand Up @@ -1849,6 +1850,73 @@ def test_baggage_survives_context_isolation(self, langfuse_client, memory_export
"cross_process_session",
)

def test_tags_survive_w3c_baggage_header(self, langfuse_client, memory_exporter):
"""Verify tags keep their list shape after crossing a W3C baggage header."""
from opentelemetry import context as otel_context
from opentelemetry.baggage.propagation import W3CBaggagePropagator

propagator = W3CBaggagePropagator()
carrier: dict = {}

with langfuse_client.start_as_current_observation(name="upstream"):
with propagate_attributes(tags=["tag-a", "comma,tag"], as_baggage=True):
propagator.inject(carrier)

# Only the header reaches the downstream service
downstream_context = propagator.extract(carrier, context=otel_context.Context())

token = otel_context.attach(downstream_context)
try:
child = langfuse_client.start_observation(name="downstream")
child.end()
finally:
otel_context.detach(token)

downstream_span = self.get_span_by_name(memory_exporter, "downstream")
self.verify_span_attribute(
downstream_span,
LangfuseOtelSpanAttributes.TRACE_TAGS,
tuple(["tag-a", "comma,tag"]),
)

@pytest.mark.parametrize(
("tags_value", "expected_tags"),
[
# JS SDK
("tag-a,tag-b", ["tag-a", "tag-b"]),
# JSON array; json.dumps escapes the emoji as a surrogate pair
(json.dumps(["tag-a", "\U0001f600"]), ["tag-a", "\U0001f600"]),
],
ids=["js-sdk", "json-array"],
)
def test_tags_read_from_js_and_json_baggage(
self, langfuse_client, memory_exporter, tags_value, expected_tags
):
"""Verify tags from JS SDK or JSON-array baggage are read as a list."""
from urllib.parse import quote_plus

from opentelemetry import context as otel_context
from opentelemetry.baggage.propagation import W3CBaggagePropagator

downstream_context = W3CBaggagePropagator().extract(
{"baggage": f"langfuse_tags={quote_plus(tags_value)}"},
context=otel_context.Context(),
)

token = otel_context.attach(downstream_context)
try:
child = langfuse_client.start_observation(name="downstream")
child.end()
finally:
otel_context.detach(token)

downstream_span = self.get_span_by_name(memory_exporter, "downstream")
self.verify_span_attribute(
downstream_span,
LangfuseOtelSpanAttributes.TRACE_TAGS,
tuple(expected_tags),
)


class TestPropagateAttributesEnvironment(TestPropagateAttributesBase):
"""Tests for first-class Langfuse environment propagation."""
Expand Down