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
7 changes: 7 additions & 0 deletions src/pyfly/logging/redaction/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@

_REDACTED = "<REDACTED>"

# Correlation keys are NEVER redacted, regardless of allow/deny config. A trace id is a
# 32-char hex string whose digit substrings can match generic PII patterns (e.g. PHONE),
# so an unguarded redactor can mutilate it and silently break log↔trace correlation.
_NEVER_REDACT = frozenset({"trace_id", "span_id", "trace_flags", "correlationId"})


def make_structlog_redactor(
redactor: Redactor,
Expand All @@ -35,6 +40,8 @@ def make_structlog_redactor(

def processor(_logger: Any, _method: str, event_dict: dict[str, Any]) -> dict[str, Any]:
for key, value in list(event_dict.items()):
if key in _NEVER_REDACT:
continue
if key in deny:
event_dict[key] = _REDACTED
continue
Expand Down
6 changes: 4 additions & 2 deletions src/pyfly/logging/structlog_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ def configure(self, config: Config) -> None:
fmt = props.format.lower()

# Shared pre-chain applied to BOTH structlog and foreign (stdlib) records.
# Redaction runs BEFORE trace-id injection so the correlation keys are never seen
# by the redactor (defense in depth on top of the NEVER_REDACT allowlist).
timestamper = structlog.processors.TimeStamper(fmt="iso")
shared_pre: list[structlog.types.Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
_add_trace_ids,
timestamper,
]
if redactor is not None:
shared_pre.append(
Expand All @@ -69,6 +69,8 @@ def configure(self, config: Config) -> None:
make_structlog_redactor(redactor, props.redaction.allow_fields, props.redaction.deny_fields),
)
)
shared_pre.append(_add_trace_ids)
shared_pre.append(timestamper)

renderer: structlog.types.Processor = (
structlog.processors.JSONRenderer()
Expand Down
18 changes: 18 additions & 0 deletions tests/logging/test_redaction_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ def test_structlog_allow_fields_limits_scanning():
assert out["note"] == "keep bob@x.io" # not in allow list -> untouched


def test_trace_ids_are_never_redacted_even_with_phone_pattern():
# A trace id with a 7-digit island bounded by hex letters matches the PHONE pattern;
# left unguarded the redactor would mutilate it and silently break log<->trace correlation.
tid = "a1234567ffffffffffffffffffffffff"
r = RegexRedactor(["PHONE"])
assert r.redact(tid) != tid # sanity: PHONE *would* corrupt it if unguarded
# deny_fields lists trace_id on purpose: NEVER_REDACT must win even over an explicit deny.
proc = make_structlog_redactor(r, allow_fields=[], deny_fields=["trace_id"])
out = proc(
None,
"info",
{"event": "call 555 1234 now", "trace_id": tid, "span_id": "1234567abcdef000"},
)
assert out["trace_id"] == tid # correlation key preserved verbatim
assert out["span_id"] == "1234567abcdef000" # ditto, despite its own digit island
assert out["event"] == "call <PHONE> now" # a genuine phone in the message is still redacted


def test_stdlib_filter_redacts_message():
r = RegexRedactor(["EMAIL"])
flt = RedactionFilter(r, allow_fields=[], deny_fields=[])
Expand Down