From 22292d5fe13e530a1deb89214e9b3b95ada18d0a Mon Sep 17 00:00:00 2001 From: Ambuj Upadhyay <34904987+lets-order-some-fries@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:43:52 +0530 Subject: [PATCH] fix(tracing): keep propagated tags intact across baggage headers propagate_attributes(tags=..., as_baggage=True) sends tags as the Python list literal "['tag-a', 'tag-b']", because the W3C baggage propagator writes str(value). The receiving service set that string as langfuse.trace.tags instead of a list. Parse tags when reading them from baggage: a JSON array or a Python list literal, falling back to a comma split for the JS SDK's "tag-a,tag-b". The wire format is unchanged, since Langfuse's AI gateway already parses it. Co-Authored-By: Claude Opus 5.5 --- langfuse/_client/propagation.py | 24 +++++++++ tests/unit/test_propagate_attributes.py | 68 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index ecf961e4f..a68c9c6e0 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,6 +5,8 @@ propagate to all child spans within the context. """ +import ast +import json import re from typing import ( Any, @@ -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)) @@ -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 + + return value.split(",") + + def _set_propagated_attribute( *, key: str, diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index cdf4f9351..5d03b02ae 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -6,6 +6,7 @@ """ import concurrent.futures +import json from datetime import datetime import pytest @@ -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."""