Skip to content
Merged
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
2 changes: 2 additions & 0 deletions langfuse/_client/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT = "sdk-experiment"

MASK_FALLBACK_VALUE = "<fully masked due to failed mask function>"

"""Note: this type is used with .__args__ / get_args in some cases and therefore must remain flat"""
ObservationTypeGenerationLike: TypeAlias = Literal[
"generation",
Expand Down
21 changes: 18 additions & 3 deletions langfuse/_client/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
create_trace_attributes,
)
from langfuse._client.constants import (
MASK_FALLBACK_VALUE,
ObservationTypeGenerationLike,
ObservationTypeLiteral,
ObservationTypeLiteralNoEvent,
Expand Down Expand Up @@ -706,17 +707,25 @@ def _process_media_and_apply_mask(
The processed and masked data
"""
return self._mask_attribute(
data=self._process_media_in_attribute(data=data, field=field)
data=self._process_media_in_attribute(data=data, field=field), field=field
)

def _mask_attribute(self, *, data: Any) -> Any:
def _mask_attribute(
self,
*,
data: Any,
field: Optional[
Union[Literal["input"], Literal["output"], Literal["metadata"]]
] = None,
) -> Any:
"""Apply the configured mask function to data.

Internal method that applies the client's configured masking function to
the provided data, with error handling and fallback.

Args:
data: The data to mask
field: The attribute the data belongs to

Returns:
The masked data, or the original data if no mask is configured
Expand All @@ -733,7 +742,13 @@ def _mask_attribute(self, *, data: Any) -> Any:
e,
)

return "<fully masked due to failed mask function>"
# Dict metadata is written per key, so mask each key instead of
# writing a plain string to the bare metadata attribute. An empty dict
# has no keys to mask, so it keeps the plain string to stay visible
if field == "metadata" and isinstance(data, dict) and data:
return {key: MASK_FALLBACK_VALUE for key in data}

return MASK_FALLBACK_VALUE

def _process_media_in_attribute(
self,
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,108 @@ def update_random_metadata(thread_id):
assert "version" in system_data
assert "features" in system_data

MASK_FALLBACK = "<fully masked due to failed mask function>"

def get_metadata_attributes(self, span_data: dict) -> dict:
prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA
return {
key: value
for key, value in span_data["attributes"].items()
if key == prefix or key.startswith(f"{prefix}.")
}

def test_failed_mask_on_update_masks_each_metadata_key(
self, configurable_langfuse_client, memory_exporter
):
def mask(*, data, **kwargs):
if isinstance(data, dict) and "secret" in data:
raise ValueError("mask failed")
return data

langfuse_client = configurable_langfuse_client(mask=mask)
span = langfuse_client.start_observation(name="mask-fail", metadata={"a": 1})
span.update(metadata={"secret": "pw", "b": 2})
span.end()

span_data = self.get_spans_by_name(memory_exporter, "mask-fail")[0]
prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA
assert self.get_metadata_attributes(span_data) == {
f"{prefix}.a": 1,
f"{prefix}.secret": self.MASK_FALLBACK,
f"{prefix}.b": self.MASK_FALLBACK,
}

def test_failed_mask_on_start_masks_each_metadata_key(
self, configurable_langfuse_client, memory_exporter
):
def mask(*, data, **kwargs):
if isinstance(data, dict):
raise ValueError("mask failed")
return data

langfuse_client = configurable_langfuse_client(mask=mask)
span = langfuse_client.start_observation(
name="mask-fail-start", metadata={"secret": "pw", "b": 2}
)
span.end()

span_data = self.get_spans_by_name(memory_exporter, "mask-fail-start")[0]
prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA
assert self.get_metadata_attributes(span_data) == {
f"{prefix}.secret": self.MASK_FALLBACK,
f"{prefix}.b": self.MASK_FALLBACK,
}

def test_failed_mask_keeps_string_fallback_for_empty_dict_metadata(
self, configurable_langfuse_client, memory_exporter
):
def mask(*, data, **kwargs):
if isinstance(data, dict):
raise ValueError("mask failed")
return data

langfuse_client = configurable_langfuse_client(mask=mask)
span = langfuse_client.start_observation(
name="mask-fail-empty-dict", metadata={}
)
span.end()

span_data = self.get_spans_by_name(memory_exporter, "mask-fail-empty-dict")[0]
assert self.get_metadata_attributes(span_data) == {
LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK,
}

def test_failed_mask_keeps_string_fallback_for_non_dict_values(
self, configurable_langfuse_client, memory_exporter
):
def mask(*, data, **kwargs):
if data is not None:
raise ValueError("mask failed")
return data

langfuse_client = configurable_langfuse_client(mask=mask)
span = langfuse_client.start_observation(
name="mask-fail-non-dict",
input={"secret": "pw"},
output={"secret": "pw"},
metadata="plain-string",
)
span.end()

span_data = self.get_spans_by_name(memory_exporter, "mask-fail-non-dict")[0]
attributes = span_data["attributes"]
assert self.get_metadata_attributes(span_data) == {
LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK,
}
assert (
attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT]
== self.MASK_FALLBACK
)
assert (
attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]
== self.MASK_FALLBACK
)


class TestMultiProjectSetup(TestOTelBase):
"""Tests for multi-project setup within the same process.
Expand Down
Loading