structlog for Structured Logging and PII Protection¶
Einordnung ins Overtime-Projekt
Für Abrechnung könnte Overtime hypothetisch bei Voice- oder Videochat Logs erfassen, wer wann mit wem und wie lange über welches Medium gesprochen hat. Keine dieser Informationen sollten ungefiltert geloggt werden oder im zentralen Log-System landen.
1. Kontext & Problemstellung (Deutsch)¶
Applikations-Logs müssen maschinenlesbar sein, um in Systemen wie OpenSearch oder FluentBit aggregiert und ausgewertet werden zu können. Gleichzeitig dürfen sie keinesfalls unmaskierte personenbezogene Daten (PII – Personally Identifiable Information) wie Namen, Telefonnummern oder E-Mail-Adressen enthalten.
Die Standard-Logging-Bibliothek (logging) von Python basiert stark auf String-Interpolation. Wenn ein Log-Eintrag den Handler erreicht, sind sensible Daten bereits fest in einen einzigen, undurchsichtigen String "eingebacken". Es gibt ab diesem Punkt keine verlässliche Möglichkeit mehr, diese Daten programmatisch zu entfernen, ohne fehleranfällige und performance-intensive reguläre Ausdrücke (Regex) über Freitext laufen zu lassen.
Ein dict-basiertes (strukturiertes) Logging-Konzept löst dieses Problem: Jedes Feld bleibt ein separates Schlüssel-Wert-Paar, das gezielt inspiziert, transformiert oder verworfen werden kann, bevor die eigentliche Serialisierung (z.B. in JSON) stattfindet.
2. Architecture Decision Record (English)¶
ADR-011: structlog for Structured Logging and PII Protection¶
Status¶
Accepted
Context and Problem Statement¶
Application logs must be machine-parseable for aggregation in OpenSearch/FluentBit and must not contain PII (names, phone numbers, email addresses, etc.). Python's stdlib logging uses string interpolation: by the time a log record reaches any handler, PII is already baked into a single opaque string. There is no reliable way to remove it programmatically without error-prone regex over free text. A dict-based logging approach keeps each field as a named key-value pair that can be inspected, transformed, or dropped before serialisation.
Decision Drivers¶
- Independent Field Accessibility: Each log field must remain independently accessible (e.g., for OpenSearch dashboards analyzing numeric fields like
attachment_size_mb). - Deterministic PII Scrubbing: PII must be removable at the processor level — not via post-hoc regex over rendered strings.
- Unified Output Schema: Django internals, Gunicorn access/error logs, and application logs must emit identical JSON schemas to the same stdout stream.
Considered Options¶
- stdlib
loggingwith a JSON formatter — All fields are flattened into onemessagestring by%sformatting before any processor can inspect them. PII masking requires fragile regex over free text. - structlog — Processors operate on a mutable
event_dict(plain Python dictionary) before serialisation. Each keyword argument stays as a named field until theJSONRendereris called.
Decision Outcome¶
Chosen: structlog with a two-layer PII processor chain.
Why dict-based logging enables PII control¶
With stdlib f-strings or %-formatting, the data is concatenated before any handler runs:
# ❌ PII is baked into the string — no processor can extract it safely
log.info(f"User {user_id} {name} sent a message in {channel}")
# emits: {"message": "User 42 Peter Müller sent a message in #general"}
With structlog kwargs, each field stays separate until the JSONRenderer:
# ✅ Processors see 'name' as a key and can drop or redact it
log.info("message sent", user_id=user_id, channel=channel, name=name)
# after processors: {"event": "message sent", "user_id": 42, "channel": "#general"}
# 'name' was dropped by drop_pii_keys_processor before render
Consequences & Trade-offs¶
Positive Consequences¶
- Queryable Documents: OpenSearch receives a JSON document with typed, queryable fields per log line — dashboards on response times or IDs are trivial to build.
- Scalable Privacy: Adding a new PII field to
_PII_KEYStakes one line and zero call-site changes. - High Testability: The processor chain is easily testable (pass a dict in, assert on the dict out) without requiring log handler mocks.
Negative Consequences / Trade-offs¶
- Exception Redaction Limits:
pii_scrub_processorruns afterExceptionRenderer. While emails/phones in exceptions are scrubbed, names and free-text PII are not. Stack trace source lines are also untouched. PII must not be embedded inraisestatements (enforced via Semgrepstructlog-no-fstring-eventrule). Also one must create/ implement custom rules. - Format Bypasses:
f-string,%-format, andstr.format()log calls bypass the processor chain entirely. This must be strictly banned by SAST rules.
3. Implementation & Code Snippets (English)¶
The implementation spans across the configuration and filter logic, ensuring both app code and framework internals utilize the exact same processing pipelines.
PII Configuration Example (config/structlog_config.py)¶
The core of the PII protection relies on two lightweight functions processing the event_dict before it gets serialized.
import logging
import re
import structlog
_PHONE_RE = re.compile(r"\+(\d)\d+(\d)(?!\d)")
_PHONE_REDACTED = r"+\1***\2"
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
_EMAIL_REDACTED = "[email redacted]"
def _scrub(value: str) -> str:
value = _PHONE_RE.sub(_PHONE_REDACTED, value)
value = _EMAIL_RE.sub(_EMAIL_REDACTED, value)
return value
# Keys whose values must never appear in logs regardless of format.
_PII_KEYS = frozenset({"kunde", "name", "email"})
def drop_pii_keys_processor(logger, method, event_dict):
"""Drop known PII fields from the event dict before rendering."""
for key in _PII_KEYS:
event_dict.pop(key, None)
return event_dict
def pii_scrub_processor(logger, method, event_dict):
"""Redact phone numbers and email addresses from every remaining string value."""
for key, value in list(event_dict.items()):
if isinstance(value, str):
event_dict[key] = _scrub(value)
return event_dict
4. Compliance Einordnung (Deutsch)¶
Das detaillierte Mapping auf BSI IT-Grundschutz, DSGVO und CIS Benchmarks wird später ergänzt.