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.