DEV Community

Ilya Ploskovitov
Ilya Ploskovitov

Posted on Originally published at pii-shield.com

Zero-code PII redaction for OpenTelemetry Collector logs in Kubernetes

TL;DR: the Collector's redaction processor governs structured attributes, but free-text log bodies still depend on OTTL regexes you have to write and maintain. Run a PII-Shield sidecar so the filelog receiver only ever reads masked identifiers like [HIDDEN:a1b2c3].

The OpenTelemetry Collector is quickly becoming the default telemetry pipeline in Kubernetes: one DaemonSet with a filelog receiver tails /var/log/containers/*.log, enriches every record with pod metadata, and exports to whichever backend you point it at — Elasticsearch, Loki, Datadog, an OTLP endpoint, often several at once.

Which means that when an application logs a user's email or a bearer token, the Collector batches that value and exports it to every configured destination before anyone notices.

The Standard Approach: Redaction and Transform Processors

The Collector ships two contrib processors for this job. The redaction processor works on attributes: keys not on an allow-list are dropped, and attribute values matching blocked patterns are masked:

processors:
  redaction:
    allow_all_keys: false
    allowed_keys:
      - http.method
      - http.status_code
      - k8s.pod.name
    blocked_values:
      - "[0-9]{3}-[0-9]{2}-[0-9]{4}"        # SSN-shaped
      - "4[0-9]{12}(?:[0-9]{3})?"           # Visa-shaped
Enter fullscreen mode Exit fullscreen mode

The harder part is the log body, where most PII actually sits as free text. For that, the standard answer is the transform processor with an OTTL statement per secret shape:

processors:
  transform/redact_body:
    log_statements:
      - context: log
        statements:
          - replace_pattern(body, "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+", "[REDACTED]")
          - replace_pattern(body, "Bearer [A-Za-z0-9\\-_.]+", "Bearer [REDACTED]")
Enter fullscreen mode Exit fullscreen mode

Why this approach breaks down at scale:

  1. The Regex Trap: the redaction processor's allow-list protects attributes, but every secret shape inside the body — API keys, session tokens, your internal ID formats — needs its own OTTL pattern. A shape you didn't anticipate ships in cleartext, and nothing fails or warns; the statement just doesn't match.
  2. Per-Record Pipeline Cost: every OTTL statement runs against every log record on the node's Collector. The cost scales with log volume and with the length of your pattern list — not with how much PII is actually present. Ten patterns means ten regex evaluations per line on every node.
  3. Loss of Context: a static [REDACTED] destroys correlation. If ten log lines all become [REDACTED], you can no longer tell whether one user hit an error ten times or ten users hit it once each.

The Zero-Code Alternative: PII-Shield Sidecar

Instead of growing an OTTL pattern list inside every Collector config, move log sanitization to a dedicated, low-allocation Go sidecar that runs before the filelog receiver ever sees the line: PII-Shield. It detects secrets by entropy and key context rather than by enumerating shapes, so an unfamiliar token format is still caught.

The Result:

// What your app generated:
{"level":"info", "message":"User authenticated", "email":"john.doe@gmail.com", "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

// What the Collector's filelog receiver actually reads and exports:
{"level":"info", "message":"User authenticated", "email":"[HIDDEN:e9f1a2]", "token":"[HIDDEN:a1c7af]"}
Enter fullscreen mode Exit fullscreen mode

The hash is a salted HMAC, deterministic per value — so [HIDDEN:e9f1a2] is the same user across all their log lines, and your queries and dashboards keep working.

How it works:

  1. The App redirects its output to an ephemeral shared volume (an emptyDir) instead of the main stdout.
  2. PII-Shield (Sidecar) tails that file, scrubs it with entropy-based secret detection (no pattern list to maintain for API keys and tokens), and prints the clean logs to its own stdout.
  3. The OTel Collector — already running as a DaemonSet whose filelog receiver reads /var/log/containers/*.log — picks up the sidecar's stdout like any other container's, no Collector config change required.

The transform/redact_body block and its regex list can be deleted from the logs pipeline. The Collector goes back to doing what it does best — enriching and routing already-clean records.

Kubernetes Implementation

The pattern is the same whether your Collector runs as a DaemonSet, a deployment, or via the OpenTelemetry Operator — the Collector never needs to know PII-Shield exists.

apiVersion: v1
kind: Secret
metadata:
  name: pii-shield-secret
type: Opaque
stringData:
  pii-salt: "replace-with-a-long-random-value"
---
apiVersion: v1
kind: Pod
metadata:
  name: billing-service
  labels:
    app: billing
spec:
  containers:
    - name: billing-app
      image: billing-app:v2.1.0
      # The app writes its private output to a shared pipe/file
      command: ["/bin/sh", "-c"]
      args: ["./billing-binary > /var/run/logs/app.log 2>&1"]
      volumeMounts:
        - name: log-volume
          mountPath: /var/run/logs

    - name: pii-shield-sidecar
      image: thelisdeep/pii-shield:2.2.0
      env:
        - name: PII_SALT
          valueFrom:
            secretKeyRef:
              name: pii-shield-secret
              key: pii-salt
      # Scratch image: run the binary directly (no shell/tail). Reads, scrubs,
      # and outputs to stdout for the filelog receiver to pick up.
      command: ["/pii-shield"]
      args: ["--watch-file", "/var/run/logs/app.log"]
      volumeMounts:
        - name: log-volume
          mountPath: /var/run/logs

  volumes:
    - name: log-volume
      emptyDir: {}
Enter fullscreen mode Exit fullscreen mode

Pro tip: The thelisdeep/pii-shield image is multi-arch (amd64/arm64).

Verify It's Actually Working

Before trusting the pipeline, confirm the sidecar's own output is already clean — don't just query your backend, since a misconfigured mount can leave the filelog receiver reading the app container's raw stream instead.

# Read the sidecar's own stdout directly — this is what the filelog receiver sees
kubectl logs billing-service -c pii-shield-sidecar --tail=50

# Confirm no raw emails/tokens survived
kubectl logs billing-service -c pii-shield-sidecar --tail=200 | grep -E "@|Bearer|sk-" | grep -v "\[HIDDEN:"
Enter fullscreen mode Exit fullscreen mode

The second command should return nothing. If it does, that log line's format isn't being caught yet — file it as a scanner bug rather than assuming the pipeline is safe.

Honest Limitations

The sidecar covers the logs path — what containers write to stdout and the filelog receiver picks up. Traces and metrics that your application SDKs send straight to the Collector over OTLP never pass through it, so if span attributes carry PII, keep the redaction processor on those pipelines. The two mechanisms compose: sidecar for log bodies, processor allow-list for attributes.

PII-Shield also doesn't replace input validation or make it safe to log secrets on purpose — it's a last line of defense for what slips through anyway. It can't retroactively clean records the Collector already exported; it only sanitizes what passes through the sidecar from the moment it's deployed. And it's stream-based: always check the sidecar's own output during rollout instead of assuming the mount and file path line up on the first try.


Stop maintaining OTTL regex lists for secrets.

Check out the PII-Shield repository on GitHub and drop a star if this simplifies your OpenTelemetry pipeline!

Top comments (0)