Skip to content

Scrub spans and logs lazily in the exporter thread - #2187

Closed
Kludex wants to merge 1 commit into
mainfrom
scrub-lazily-in-exporter-thread
Closed

Scrub spans and logs lazily in the exporter thread#2187
Kludex wants to merge 1 commit into
mainfrom
scrub-lazily-in-exporter-thread

Conversation

@Kludex

@Kludex Kludex commented Aug 4, 2026

Copy link
Copy Markdown
Member

Scrubbing ran in MainSpanProcessorWrapper.on_end and MainLogProcessorWrapper.on_emit, i.e. in whichever thread ended the span or emitted the log - often an application's event loop.

Spans and log records are now wrapped in LazilyScrubbedReadableSpan / LazilyScrubbedLogRecord, which scrub on first read of their contents. That read happens inside SpanExporter.export(), which for the Logfire exporter runs on the batch processor's background thread. The result is cached under a lock, so it's computed once no matter how many exporters read the span. Every exporter still receives scrubbed data, including user-provided additional_span_processors - only the timing moved.

Measurements

Time spent inside logfire.info() - the part that runs on the event loop - with a 200-item nested payload, 50 calls:

mean p95 max
before 7.2ms 7.8ms 13.3ms
after 2.4ms 2.7ms 3.8ms

Tagging each scrubbing callback invocation with threading.current_thread().name confirms it moved from MainThread to OtelBatchSpanRecordProcessor.

Caveat: measuring event-loop heartbeat lag instead gives 12.1ms after vs 12.9ms before - essentially unchanged. Scrubbing is pure Python, so on the worker thread it still holds the GIL. What changed is that the work is now preemptible at sys.getswitchinterval() granularity instead of one monolithic block, and it's off the critical path of the coroutine that logged. For an app genuinely CPU-bound on scrubbing this redistributes the cost rather than eliminating it.

Notes

  • With SimpleSpanProcessor (the console exporter, TestExporter) the read still happens in the calling thread - unavoidable, since those export synchronously.
  • With sampling=SamplingOptions(tail=...), TailSamplingProcessor reads span.attributes to get the level, which triggers the scrub inline again. Not addressed here.
  • An exporter that never reads span.attributes now skips scrubbing entirely. The real OTLP path serializes every attribute, so it's unaffected.
  • force_flush() scrubs on whatever thread calls it, since the pending batch is drained there.
  • The scrubbing callback now runs on a background thread, so it can't rely on the state of the thread that created the span. Documented in docs/how-to-guides/scrubbing.md.
  • One assertion in test_otel_logs.py compared the log record by identity; it now compares body and attributes.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Review in cubic

Scrubbing ran in `MainSpanProcessorWrapper.on_end` and
`MainLogProcessorWrapper.on_emit`, i.e. in whichever thread ended the span
or emitted the log - often an application's event loop.

Spans and log records are now wrapped in `LazilyScrubbedReadableSpan` /
`LazilyScrubbedLogRecord`, which scrub on first read of their contents.
That read happens inside `SpanExporter.export()`, which for the Logfire
exporter runs on the batch processor's background thread. The result is
cached under a lock, so it's computed once no matter how many exporters
read the span.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Kludex, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd26392f-6fa4-4e4b-af67-646121b1bd9b

📥 Commits

Reviewing files that changed from the base of the PR and between 455279e and 48368b4.

📒 Files selected for processing (6)
  • docs/how-to-guides/scrubbing.md
  • logfire/_internal/exporters/logs.py
  • logfire/_internal/exporters/processor_wrapper.py
  • logfire/_internal/scrubbing.py
  • tests/test_otel_logs.py
  • tests/test_secret_scrubbing.py

Comment @coderabbitai help to get the list of available commands.

self._scrubbed = self._scrubber.scrub_log(self._unscrubbed)
return self._scrubbed

@property

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High _internal/scrubbing.py:420

LazilyScrubbedLogRecord exposes attributes and body as read-only @property getters, so any downstream LogRecord processor that assigns to log_record.attributes = ... or log_record.body = ... now raises AttributeError instead of updating the record. This breaks processors configured via advanced.log_record_processors that enrich or transform logs, preventing the log from being emitted. The proxy delegates other attributes through __getattr__, but @property setters are not defined, so mutation of these fields is impossible. Consider making these fields settable (e.g. adding property setters that forward to the underlying record), or caching the scrubbed result in the original LogRecord instead of wrapping it in a read-only proxy.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @logfire/_internal/scrubbing.py around line 420:

`LazilyScrubbedLogRecord` exposes `attributes` and `body` as read-only `@property` getters, so any downstream `LogRecord` processor that assigns to `log_record.attributes = ...` or `log_record.body = ...` now raises `AttributeError` instead of updating the record. This breaks processors configured via `advanced.log_record_processors` that enrich or transform logs, preventing the log from being emitted. The proxy delegates other attributes through `__getattr__`, but `@property` setters are not defined, so mutation of these fields is impossible. Consider making these fields settable (e.g. adding property setters that forward to the underlying record), or caching the scrubbed result in the original `LogRecord` instead of wrapping it in a read-only proxy.

if self._scrub_done:
return
self._scrub_done = True
with handle_internal_errors:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Scrubbing failures export the original span

handle_internal_errors suppresses exceptions from the scrubbing callback, after _scrub_done has already been set. The code then copies the original attributes, events, and links into the span, so an attacker can cause sensitive telemetry to be exported unredacted by producing a scrub match when a callback raises—for example, because a context variable available on the request thread is absent on the exporter thread. Fail closed by dropping the span or replacing its sensitive fields with empty/redacted values when scrubbing does not complete.

@veria-ai

veria-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR overview

This pull request moves span and log scrubbing to occur lazily in the exporter thread. It updates the scrubbing flow for telemetry attributes, events, and links.

One security issue remains open: if a scrubbing callback raises in the exporter thread, the original telemetry can still be exported instead of being redacted or dropped. An attacker able to trigger this failure could cause sensitive span data to reach the telemetry backend unredacted, so the scrubbing path currently fails open.

Open issues (1)

Fixed/addressed: 0 · PR risk: 7/10

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 6 files

Confidence score: 2/5

  • In logfire/_internal/scrubbing.py, error handling around span scrubbing can leave the “scrubbed” flag set after a callback failure, then copy unsanitized originals back into _attributes/_events, which risks leaking data that should be redacted — make the flag/update sequence atomic or reset state on exception.
  • In logfire/_internal/scrubbing.py, lazy log scrubbing calls scrub_log() without the internal error guard, so callback exceptions can escape the export path and fail log export entirely — wrap the lazy scrub_log() call in handle_internal_errors (or equivalent) to contain failures.
  • In logfire/_internal/scrubbing.py, LazilyScrubbedLogRecord exposes read-only attributes/body, so downstream processors that mutate log records may raise at runtime and break processing chains — add compatible setters or a mutable adapter to preserve processor interoperability.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="logfire/_internal/scrubbing.py">

<violation number="1" location="logfire/_internal/scrubbing.py:370">
P1: If the scrubbing callback or `scrub_span()` raises, `handle_internal_errors` suppresses the exception, but this flag has already been set and the code then copies the original values back into `_attributes`, `_events`, and `_links`. Subsequent exporter reads skip scrubbing and receive the unsanitized span, which can leak exactly the sensitive data this wrapper is intended to protect. The completion flag should only be committed after a successful scrub, and the failure path should not expose the original span contents.</violation>

<violation number="2" location="logfire/_internal/scrubbing.py:417">
P1: A scrubbing callback exception can now bubble out of log export and fail the export path, because lazy log scrubbing calls `scrub_log()` without the internal error guard. Wrapping this call with `handle_internal_errors` and falling back to the original record would keep exporter-thread failures from breaking log delivery.</violation>

<violation number="3" location="logfire/_internal/scrubbing.py:421">
P2: LazilyScrubbedLogRecord defines `attributes` and `body` as read-only properties with no setters. Any downstream log record processor (e.g. one configured via advanced.log_record_processors) that tries to assign `log_record.attributes = ...` or `log_record.body = ...` to enrich/transform a log will now raise AttributeError instead of updating the record, unlike the previous plain LogRecord which allowed mutation. Consider adding setters that forward to the underlying record, or caching the scrubbed result on the original LogRecord instead of wrapping it in a read-only proxy.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

def _scrub(self) -> LogRecord:
with self._scrub_lock:
if self._scrubbed is None:
self._scrubbed = self._scrubber.scrub_log(self._unscrubbed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: A scrubbing callback exception can now bubble out of log export and fail the export path, because lazy log scrubbing calls scrub_log() without the internal error guard. Wrapping this call with handle_internal_errors and falling back to the original record would keep exporter-thread failures from breaking log delivery.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At logfire/_internal/scrubbing.py, line 417:

<comment>A scrubbing callback exception can now bubble out of log export and fail the export path, because lazy log scrubbing calls `scrub_log()` without the internal error guard. Wrapping this call with `handle_internal_errors` and falling back to the original record would keep exporter-thread failures from breaking log delivery.</comment>

<file context>
@@ -347,6 +349,83 @@ def _redact(self, match: ScrubMatch) -> Any:
+    def _scrub(self) -> LogRecord:
+        with self._scrub_lock:
+            if self._scrubbed is None:
+                self._scrubbed = self._scrubber.scrub_log(self._unscrubbed)
+            return self._scrubbed
+
</file context>
Suggested change
self._scrubbed = self._scrubber.scrub_log(self._unscrubbed)
with handle_internal_errors:
self._scrubbed = self._scrubber.scrub_log(self._unscrubbed)
self._scrubbed = self._scrubbed or self._unscrubbed

with self._scrub_lock:
if self._scrub_done:
return
self._scrub_done = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: If the scrubbing callback or scrub_span() raises, handle_internal_errors suppresses the exception, but this flag has already been set and the code then copies the original values back into _attributes, _events, and _links. Subsequent exporter reads skip scrubbing and receive the unsanitized span, which can leak exactly the sensitive data this wrapper is intended to protect. The completion flag should only be committed after a successful scrub, and the failure path should not expose the original span contents.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At logfire/_internal/scrubbing.py, line 370:

<comment>If the scrubbing callback or `scrub_span()` raises, `handle_internal_errors` suppresses the exception, but this flag has already been set and the code then copies the original values back into `_attributes`, `_events`, and `_links`. Subsequent exporter reads skip scrubbing and receive the unsanitized span, which can leak exactly the sensitive data this wrapper is intended to protect. The completion flag should only be committed after a successful scrub, and the failure path should not expose the original span contents.</comment>

<file context>
@@ -347,6 +349,83 @@ def _redact(self, match: ScrubMatch) -> Any:
+        with self._scrub_lock:
+            if self._scrub_done:
+                return
+            self._scrub_done = True
+            with handle_internal_errors:
+                self._scrubber.scrub_span(self._span_dict)
</file context>

return self._scrubbed

@property
def attributes(self) -> otel_types._ExtendedAttributes | None: # pyright: ignore[reportPrivateUsage]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: LazilyScrubbedLogRecord defines attributes and body as read-only properties with no setters. Any downstream log record processor (e.g. one configured via advanced.log_record_processors) that tries to assign log_record.attributes = ... or log_record.body = ... to enrich/transform a log will now raise AttributeError instead of updating the record, unlike the previous plain LogRecord which allowed mutation. Consider adding setters that forward to the underlying record, or caching the scrubbed result on the original LogRecord instead of wrapping it in a read-only proxy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At logfire/_internal/scrubbing.py, line 421:

<comment>LazilyScrubbedLogRecord defines `attributes` and `body` as read-only properties with no setters. Any downstream log record processor (e.g. one configured via advanced.log_record_processors) that tries to assign `log_record.attributes = ...` or `log_record.body = ...` to enrich/transform a log will now raise AttributeError instead of updating the record, unlike the previous plain LogRecord which allowed mutation. Consider adding setters that forward to the underlying record, or caching the scrubbed result on the original LogRecord instead of wrapping it in a read-only proxy.</comment>

<file context>
@@ -347,6 +349,83 @@ def _redact(self, match: ScrubMatch) -> Any:
+            return self._scrubbed
+
+    @property
+    def attributes(self) -> otel_types._ExtendedAttributes | None:  # pyright: ignore[reportPrivateUsage]
+        return self._scrub().attributes
+
</file context>

@Kludex

Kludex commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

It doesn't seem this solves the problem I was trying to solve.

@Kludex Kludex closed this Aug 4, 2026
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.

1 participant