Uh oh!
There was an error while loading. Please reload this page.
feat(masking): batch and async span masking - #1553
Conversation
langfuse/langfuse#11931) Foundation for batch/async span masking. Adds BatchMaskFunction and config (batch_mask, mask_batch_size, use_async_masking), and MaskedAttributeSpanWrapper in span_processor. Follow-up commits will wire the masking exporter and optional async path. Ref: langfuse/langfuse#11931
iamkalio
commented
Mar 7, 2026
Hello @hassiebp I opened a PR with a proposed approach to this issue. Before continuing and adding documentation, I wanted to confirm this aligns with the direction you had in mind. |
| _MASKABLE_ATTR_KEYS = ( | ||
| LangfuseOtelSpanAttributes.TRACE_INPUT, | ||
| LangfuseOtelSpanAttributes.TRACE_OUTPUT, | ||
| LangfuseOtelSpanAttributes.OBSERVATION_INPUT, | ||
| LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, | ||
| LangfuseOtelSpanAttributes.OBSERVATION_METADATA, | ||
| ) |
There was a problem hiding this comment.
TRACE_METADATA missing from maskable keys
LangfuseOtelSpanAttributes.TRACE_METADATA ("langfuse.trace.metadata") exists and is set on trace-level spans (confirmed in attributes.py line 173), but it is absent from _MASKABLE_ATTR_KEYS. This means trace metadata will never be passed to batch_mask, leaving potentially sensitive data unmasked even when batch masking is configured.
| _MASKABLE_ATTR_KEYS= ( | |
| LangfuseOtelSpanAttributes.TRACE_INPUT, | |
| LangfuseOtelSpanAttributes.TRACE_OUTPUT, | |
| LangfuseOtelSpanAttributes.OBSERVATION_INPUT, | |
| LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, | |
| LangfuseOtelSpanAttributes.OBSERVATION_METADATA, | |
| ) | |
| _MASKABLE_ATTR_KEYS= ( | |
| LangfuseOtelSpanAttributes.TRACE_INPUT, | |
| LangfuseOtelSpanAttributes.TRACE_OUTPUT, | |
| LangfuseOtelSpanAttributes.TRACE_METADATA, | |
| LangfuseOtelSpanAttributes.OBSERVATION_INPUT, | |
| LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, | |
| LangfuseOtelSpanAttributes.OBSERVATION_METADATA, | |
| ) |
| timeout_sec = (timeout_millis / 1000.0) if timeout_millis is not None else 10.0 | ||
| if not self._flush_event.wait(timeout=timeout_sec): | ||
| return False | ||
| if hasattr(self._span_exporter, "force_flush"): | ||
| return self._span_exporter.force_flush(timeout_millis) |
There was a problem hiding this comment.
force_flush can wait up to 2x the requested timeout
After waiting up to timeout_sec for the worker's flush event, the code immediately calls self._span_exporter.force_flush(timeout_millis) with the full original timeout. In the worst case, a caller requesting a 5-second flush could wait up to 10 seconds before the call returns. The inner force_flush should receive the remaining time budget.
| timeout_sec= (timeout_millis/1000.0) iftimeout_millisisnotNoneelse10.0 | |
| ifnotself._flush_event.wait(timeout=timeout_sec): | |
| returnFalse | |
| ifhasattr(self._span_exporter, "force_flush"): | |
| returnself._span_exporter.force_flush(timeout_millis) | |
| timeout_sec= (timeout_millis/1000.0) iftimeout_millisisnotNoneelse10.0 | |
| start=time.monotonic() | |
| ifnotself._flush_event.wait(timeout=timeout_sec): | |
| returnFalse | |
| ifhasattr(self._span_exporter, "force_flush"): | |
| elapsed_ms=int((time.monotonic() -start) *1000) | |
| remaining_ms=max(0, timeout_millis-elapsed_ms) iftimeout_millisisnotNoneelseNone | |
| returnself._span_exporter.force_flush(remaining_ms) | |
| returnTrue |
Note: this also requires adding import time at the top of the module.
| if self._queue is None or self._flush_event is None or self._closed: | ||
| return True | ||
| self._flush_event.clear() | ||
| try: | ||
| self._queue.put(_FLUSH_SENTINEL, timeout=self._QUEUE_PUT_TIMEOUT_SEC) | ||
| except Full: | ||
| return False | ||
| timeout_sec = (timeout_millis / 1000.0) if timeout_millis is not None else 10.0 | ||
| if not self._flush_event.wait(timeout=timeout_sec): | ||
| return False | ||
| if hasattr(self._span_exporter, "force_flush"): | ||
| return self._span_exporter.force_flush(timeout_millis) |
There was a problem hiding this comment.
Shared _flush_event causes a race condition under concurrent flushes
_flush_event is a single threading.Event shared by all callers of force_flush. If two threads call force_flush concurrently, the sequence below can cause the first caller to time out even though the flush completed:
- Thread A calls
_flush_event.clear()and enqueues_FLUSH_SENTINEL. - Worker processes Thread A's sentinel and calls
_flush_event.set(). - Thread B calls
_flush_event.clear()— this clears the event before Thread A calls_flush_event.wait(). - Thread A's
_flush_event.wait()now blocks until its own timeout expires.
Consider using per-flush threading.Event objects (e.g. by passing the event alongside the sentinel in the queue) so each caller waits only on their own flush completion signal.
AqueelAhmedV
commented
Mar 7, 2026
@iamkalio looks good! we have something similar running in production (wrapping the internal exporter post Langfuse() init), good to see this supported natively. |
OndrejSlama
commented
Apr 21, 2026
Any update on this? |
manul-ai
commented
May 6, 2026
Hi, it would be really helpful to have it merged |
Summary
First step toward batch/async span masking. Adds types, config, and the span wrapper; does not yet wire batch masking into the export path.
Changes
BatchMaskFunctioninlangfuse/types.pybatch_mask,mask_batch_size,use_async_maskingonLangfuseandLangfuseResourceManagerMaskedAttributeSpanWrapperinlangfuse/_client/span_processor.pyto overlay masked attributes on spans before exportTestMaskedAttributeSpanWrapperintests/test_otel.pyFollow-up (not in this PR)
Fixeslangfuse/langfuse#11931
Disclaimer: Experimental PR review
Greptile Summary
This PR introduces the infrastructure for batch and async span masking in the Langfuse Python SDK. It adds a
BatchMaskFunctionprotocol type, three new configuration parameters (batch_mask,mask_batch_size,use_async_masking) threaded throughLangfuse,LangfuseResourceManager, andLangfuseSpanProcessor, and the coreMaskingSpanExporter+MaskedAttributeSpanWrapperclasses in a newmasking_exporter.pymodule. Whenbatch_maskis configured, the OTLP exporter is wrapped so that span attributes are masked in batches (and optionally off the main thread) before being serialized and sent.Key findings:
LangfuseOtelSpanAttributes.TRACE_METADATAis present in the codebase and set on trace spans, but is missing from_MASKABLE_ATTR_KEYSinmasking_exporter.py. Trace-level metadata will not be passed tobatch_mask, leaving PII potentially exposed.force_flushwaits up totimeout_secfor the worker's flush event and then callsself._span_exporter.force_flush(timeout_millis)with the full original timeout — callers can experience up to 2x the requested timeout._flush_eventis not safe for concurrentforce_flushcallers; one thread clearing the event can race with the worker signaling completion for another thread's flush.chunk_backref) is computed but never referenced in the chunked batch masking loop.from unittest.mock import MagicMockand the masking exporter imports inside the method body instead of at the module level.Confidence Score: 2/5
langfuse/_client/masking_exporter.py— specifically_MASKABLE_ATTR_KEYS,force_flush, and the concurrent flush handling in_worker_loop.Important Files Changed
Sequence Diagram
Last reviewed commit: 9b21a71
(2/5) Greptile learns from your feedback when you react with thumbs up/down!
Context used:
Learnt From
langfuse/langfuse-python#1387