From ab7789d0a180e2eeaeba87be81457a8aec7a707f Mon Sep 17 00:00:00 2001 From: Neumann Cheng Date: Sat, 10 Oct 2026 00:52:12 +0800 Subject: [PATCH] fix(tracing): do not mask attributes the caller never set `_process_media_and_apply_mask` runs for input, output and metadata on every span creation and update, regardless of whether the caller provided them. An unset attribute is still handed to the user's mask function as `None`. A mask that assumes a dict throws, `_mask_attribute` then fails closed, and the fallback string is not `None` -- so it survives the `v is not None` filters in `create_span_attributes` / `create_generation_attributes`, and lands on the span as an attribute the caller never set. Each unset attribute also logs one ERROR per span, which makes it look like the user's mask function is broken. Repro with a mask that assumes a dict and only `output` set: mask receives [None, None, None, None, {'answer': '42'}, None] span carries langfuse.observation.input / .metadata = '' plus 5 ERROR logs ("'NoneType' object is not a mapping") Return early when `data is None`: there is nothing to process or mask. Attributes that do have a value keep the existing fail-closed behaviour and the per-key metadata fallback. Adds two regression tests asserting that only attributes the caller set reach the mask, and that unset attributes stay unset. --- langfuse/_client/span.py | 7 +++++ tests/unit/test_otel.py | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 21bb9fabb..9cae94f62 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -706,6 +706,13 @@ def _process_media_and_apply_mask( Returns: The processed and masked data """ + # The attribute was not provided, so there is nothing to process or mask. + # Returning early keeps unset attributes unset instead of invoking the + # user's mask function with `None`, which would otherwise fail and write + # a fallback value into an attribute the caller never set. + if data is None: + return None + return self._mask_attribute( data=self._process_media_in_attribute(data=data, field=field), field=field ) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 286a5e4d0..68d985bb4 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -2082,6 +2082,63 @@ def mask(*, data, **kwargs): == self.MASK_FALLBACK ) + def test_unset_attributes_are_never_passed_to_the_mask( + self, configurable_langfuse_client, memory_exporter + ): + """Only attributes the caller set may reach the mask function. + + A mask that assumes it always receives a dict (the common shape) must not + trigger the fallback for attributes the caller never provided. Otherwise + phantom `input`/`metadata` attributes get written to the span. + """ + seen = [] + + def mask(*, data, **kwargs): + seen.append(data) + return {**data, "email": "***"} + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation(name="mask-unset") + span.update(output={"answer": "42"}) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-unset")[0] + attributes = span_data["attributes"] + + assert seen == [{"answer": "42"}] + assert json.loads( + attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] + ) == {"answer": "42", "email": "***"} + assert LangfuseOtelSpanAttributes.OBSERVATION_INPUT not in attributes + assert self.get_metadata_attributes(span_data) == {} + + def test_unset_attributes_stay_unset_when_created_with_metadata_only( + self, configurable_langfuse_client, memory_exporter + ): + """Creating a span with only metadata must not mask a missing input/output.""" + seen = [] + + def mask(*, data, **kwargs): + seen.append(data) + return {**data, "email": "***"} + + langfuse_client = configurable_langfuse_client(mask=mask) + span = langfuse_client.start_observation( + name="mask-unset-start", metadata={"k": 1} + ) + span.end() + + span_data = self.get_spans_by_name(memory_exporter, "mask-unset-start")[0] + attributes = span_data["attributes"] + + assert seen == [{"k": 1}] + assert self.get_metadata_attributes(span_data) == { + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.k": 1, + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.email": "***", + } + assert LangfuseOtelSpanAttributes.OBSERVATION_INPUT not in attributes + assert LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT not in attributes + class TestMultiProjectSetup(TestOTelBase): """Tests for multi-project setup within the same process.