Skip to content

fix(tracing): do not mask attributes the caller never set - #1973

Open
Neumanncheng wants to merge 1 commit into
langfuse:prepare-v5-releasefrom
Neumanncheng:fix/mask-unset-attributes
Open

Neumanncheng wants to merge 1 commit into
langfuse:prepare-v5-releasefrom
Neumanncheng:fix/mask-unset-attributes

Conversation

@Neumanncheng

@Neumanncheng Neumanncheng commented Oct 9, 2026 •

Copy link
Copy Markdown

Problem

_process_media_and_apply_mask is called for input, output and metadata on every span creation and update, regardless of whether the caller provided them. When an attribute is not set, data is None — and it is still handed to the user's mask function.

A mask function that assumes a dict (a natural, common shape) raises on None. _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 _set_span_attributes_within_limit. Result:

  • attributes the caller never set — langfuse.observation.input, langfuse.observation.metadata — turn up on the span with the value <fully masked due to failed mask function>
  • one ERROR log per unset attribute per span, which reads as "your mask function is broken"

Repro

from langfuse import Langfuse

langfuse = Langfuse(mask=lambda *, data, **kwargs: {**data, "email": "***"})

span = langfuse.start_observation(name="x")
span.update(output={"answer": "42"})  # input and metadata were never set
span.end()

Observed with a recording mask:

mask received: [None, None, None, None, {'answer': '42'}, None]
span attributes:
  langfuse.observation.output   = '{"answer": "42", "email": "***"}'   (expected)
  langfuse.observation.input    = '<fully masked due to failed mask function>'   (never set by the caller)
  langfuse.observation.metadata = '<fully masked due to failed mask function>'   (never set by the caller)
5 x ERROR Masking error: ... 'NoneType' object is not a mapping

Without a mask configured, neither input nor metadata appears on the span at all — so the phantom attributes come from the mask call, not from another path.

Expected: mask is only invoked for attributes the caller actually set; unset attributes stay unset.

Fix

Return early in _process_media_and_apply_mask when data is None — there is nothing to process or mask.

This keeps unset attributes None, so the existing v is not None filters drop them as intended. Attributes that do have a value keep the current fail-closed behaviour and the per-key metadata fallback from #1956 unchanged.

Tests

Two regression tests added to tests/unit/test_otel.py::TestMetadataHandling, next to the existing masking tests:

  • test_unset_attributes_are_never_passed_to_the_mask
  • test_unset_attributes_stay_unset_when_created_with_metadata_only

They assert that the mask sees exactly the values the caller set ([{"answer": "42"}], no None), that unset attributes do not appear on the span, and that set attributes are still masked.

Without the fix both fail with 'NoneType' object is not a mapping; with it both pass. Full tests/unit/test_otel.py: 69 passed, 2 skipped. ruff check, ruff format --check and mypy langfuse all pass.

RetriggerConfidence Score: 5/5

The PR appears safe to merge; omitted attributes stay absent while supplied values still get masked.

Summary

_process_media_and_apply_mask now returns early for None, so omitted attributes do not reach the user's mask or acquire fallback values.

  • Set values keep the existing media and masking path.
  • Two regression tests check mask calls and absent attributes during creation and updates.
  • No actionable issues found. Tests were inspected, not run.

Reviews (1) · Last reviewed commit: "fix(tracing): do not mask attributes the..." · Reviewed by Greptile

`_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 =
        '<fully masked due to failed mask function>'
    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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CLAassistant

CLAassistant commented Oct 9, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Neumanncheng approving: the early return covers every mask call site, since _process_media_and_apply_mask is the only caller of _mask_attribute, and both new tests fail without it.

$ pytest tests/unit/test_otel.py -q      # head ab7789d, Python 3.14
69 passed, 2 skipped
$ pytest tests/unit/test_otel.py -q      # same, `if data is None` block removed
FAILED ...::test_unset_attributes_are_never_passed_to_the_mask
FAILED ...::test_unset_attributes_stay_unset_when_created_with_metadata_only
2 failed, 67 passed, 2 skipped
$ ruff check langfuse/_client/span.py tests/unit/test_otel.py   # ruff 0.15.7
All checks passed!

The fix also helps masks that never raise, where the bug is silent. A redacting mask and a single update(output=...):

$ python probe.py   # base edf44eb, mask=lambda *, data, **kw: "[REDACTED]"
langfuse.observation.input = [REDACTED]
langfuse.observation.metadata = [REDACTED]
langfuse.observation.output = [REDACTED]
$ python probe.py   # head ab7789d
langfuse.observation.output = [REDACTED]

@hassiebp this looks ready to me. Is there anything you want before merging?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants