LinkMesh

Search docs, blog and changelog

ENDE

Snippets · Processors

Mask PII in logs (e-mail, card numbers, IBAN, tokens, IPs)

Personal data reaches logs whatever the logging policy says: an e-mail in an error message, a card number in a payment trace, a bearer token in a logged request header. Once it is in the backend it is indexed and retained. This config rewrites each match to a placeholder (<email>, <card>, <iban>, <token>, <ip>) in the transform processor, so the record still arrives and still makes sense, without the value.

Signals
Logs
Category
Compliance
Source
OTLP
Components
otlpbatchmemory_limitertransformotlphttp
Destination
OTLP/HTTP endpoint

OpenTelemetry Collector

OpenTelemetry Collector YAML
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 25
  # Masks by pattern: in a string body, in the top-level values of a map body,
  # and in every log attribute value. Pattern-based, so it masks what LOOKS
  # like PII; see "What it does not catch" on the page.
  transform/mask-pii:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          # E-mail address
          - replace_pattern(body, "[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}", "<email>")
          - replace_all_patterns(attributes, "value", "[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}", "<email>")
          - replace_all_patterns(body, "value", "[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}", "<email>") where IsMap(body)
          # Visa / Mastercard number, 16 digits, optionally grouped by space or dash
          - replace_pattern(body, "\\b(4[0-9]{3}|5[1-5][0-9]{2}|2[2-7][0-9]{2})([ -]?[0-9]{4}){3}\\b", "<card>")
          - replace_all_patterns(attributes, "value", "\\b(4[0-9]{3}|5[1-5][0-9]{2}|2[2-7][0-9]{2})([ -]?[0-9]{4}){3}\\b", "<card>")
          - replace_all_patterns(body, "value", "\\b(4[0-9]{3}|5[1-5][0-9]{2}|2[2-7][0-9]{2})([ -]?[0-9]{4}){3}\\b", "<card>") where IsMap(body)
          # IBAN (2 letters, 2 check digits, 11 to 30 alphanumerics, optionally grouped by 4)
          - replace_pattern(body, "\\b[A-Z]{2}[0-9]{2}( ?[A-Z0-9]{4}){2,7}( ?[A-Z0-9]{1,3})?\\b", "<iban>")
          - replace_all_patterns(attributes, "value", "\\b[A-Z]{2}[0-9]{2}( ?[A-Z0-9]{4}){2,7}( ?[A-Z0-9]{1,3})?\\b", "<iban>")
          - replace_all_patterns(body, "value", "\\b[A-Z]{2}[0-9]{2}( ?[A-Z0-9]{4}){2,7}( ?[A-Z0-9]{1,3})?\\b", "<iban>") where IsMap(body)
          # Bearer token (20+ token characters after "Bearer ")
          - replace_pattern(body, "(?i)bearer [A-Za-z0-9._~+/=-]{20,}", "Bearer <token>")
          - replace_all_patterns(attributes, "value", "(?i)bearer [A-Za-z0-9._~+/=-]{20,}", "Bearer <token>")
          - replace_all_patterns(body, "value", "(?i)bearer [A-Za-z0-9._~+/=-]{20,}", "Bearer <token>") where IsMap(body)
          # IPv4 address
          - replace_pattern(body, "\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b", "<ip>")
          - replace_all_patterns(attributes, "value", "\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b", "<ip>")
          - replace_all_patterns(body, "value", "\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b", "<ip>") where IsMap(body)
  batch:
    timeout: 5s

exporters:
  otlphttp:
    endpoint: https://your-backend:4318

service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [memory_limiter, transform/mask-pii, batch]
      exporters: [otlphttp]

LinkMesh

Library › Processors › PII Masking Rules

rules
[{"detector":"email@1","mode":"redact","scope":"body"},{"detector":"email@1","mode":"redact","scope":"attributes"},{"detector":"iban@1","mode":"redact","scope":"body"},{"detector":"ipv4@1","mode":"redact","scope":"attributes"}]

What LinkMesh then pushes to the collector, under processors:

Pushed to the collector
transform/route_00000506_pipe_proc_1:
  error_mode: ignore
  log_statements:
    - context: log
      statements:
        - replace_pattern(body, "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "****") where IsString(body)
        - replace_all_patterns(attributes, "value", "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "****")
        - replace_pattern(body, "\\b[A-Z]{2}[0-9]{2}(?:[A-Z0-9]{11,30}|(?:[ ][A-Z0-9]{4}){2,7}(?:[ ][A-Z0-9]{1,4})?)\\b", "****") where IsString(body) and IsMatch(body, "\\b[A-Z]{2}[0-9]{2}")
        - replace_all_patterns(attributes, "value", "\\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b", "****")

The same, as an API call (see automation recipes):

API call
curl -X POST "$LINKMESH/pipelines" \
  -H "Authorization: Bearer $LMSAT" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "PII Masking Rules",
  "processors": [
    {
      "config": {
        "rules": [
          {
            "detector": "email@1",
            "mode": "redact",
            "scope": "body"
          },
          {
            "detector": "email@1",
            "mode": "redact",
            "scope": "attributes"
          },
          {
            "detector": "iban@1",
            "mode": "redact",
            "scope": "body"
          },
          {
            "detector": "ipv4@1",
            "mode": "redact",
            "scope": "attributes"
          }
        ]
      },
      "enabled": true,
      "order": 1,
      "templateSlug": "pii-masking"
    }
  ]
}'

The rest of the collector config above (otlp, memory_limiter, batch, otlphttp) is not part of this template: in LinkMesh those are separate sources, processors and destinations on the route.

Generated from LinkMesh's template catalogue by linkmesh-server snippet-render 1.464.3, not written by hand.

Download config.yamlValidated with otelcol-contrib v0.161.0 on 2026-10-26

Complete config files, each checked with the collector's own validate command (otelcol-contrib v0.161.0). Replace https://your-backend:4318 with your own OTLP endpoint before you run it.

Before and after

Example telemetry in, and what this exact config sent out when we ran it in otelcol-contrib v0.161.0. CI re-runs it on every change and fails if the output differs. Changed values are highlighted.

13 log records in, 13 log records out

  1. Before

    body
    password reset sent to anna.meier@example.ch

    After

    body
    password reset sent to <email>
  2. Before

    body
    health check from user@localhost ok

    After

    body
    health check from user@localhost ok
  3. Before

    body
    payment declined for card 4111 1111 1111 1111

    After

    body
    payment declined for card <card>
  4. Before

    body
    order 1234 5678 9012 3456 shipped

    After

    body
    order 1234 5678 9012 3456 shipped
  5. Before

    body
    refund to IBAN CH93 0076 2011 6238 5295 7 booked

    After

    body
    refund to IBAN <iban> booked
  6. Before

    body
    retrying job DE12 after timeout

    After

    body
    retrying job DE12 after timeout
  7. Before

    body
    GET /api/orders Authorization: Bearer 9f3c2a7d1e8b4c6a0f5d2e9b7a1c3d8e

    After

    body
    GET /api/orders Authorization: Bearer <token>
  8. Before

    body
    the bearer token was rotated

    After

    body
    the bearer token was rotated
  9. Before

    body
    login from 192.168.10.24 accepted

    After

    body
    login from <ip> accepted
  10. Before

    body
    upgraded agent to 1.24.300.1

    After

    body
    upgraded agent to 1.24.300.1
  11. Before

    body
    {"msg":"structured","email":"anna.meier@example.ch"}

    After

    body
    {"msg":"structured","email":"<email>"}
  12. Before

    body
    login
    attributes.client.address
    203.0.113.7
    attributes.user.email
    anna.meier@example.ch
    attributes.http.route
    /users/{id}

    After

    body
    login
    attributes.client.address
    <ip>
    attributes.user.email
    <email>
    attributes.http.route
    /users/{id}
  13. Before

    body
    {"msg":"nested","user":{"email":"anna.meier@example.ch"}}

    After

    body
    {"msg":"nested","user":{"email":"anna.meier@example.ch"}}

The gotcha

replace_pattern only rewrites a string body. Given a structured (map) body it does nothing and reports nothing: in our test the e-mail inside {"email": ...} arrived unmasked, with no error and no warning. That is why this config also runs replace_all_patterns on map bodies. That reaches top-level values only: an e-mail one level deeper, as in {"user": {"email": ...}}, still passed through unmasked, so flatten or parse nested bodies before this processor.

What it does not catch

It masks what looks like PII, by pattern. It does not catch names, postal addresses written as free text, phone numbers, IPv6 addresses, or card numbers other than 16-digit Visa and Mastercard, and the card pattern does not run a Luhn check, so any 16-digit number starting 4, 51-55 or 22-27 is masked. It is a safety net under your logging policy, not a compliance guarantee.

Manage this across a fleet

LinkMesh manages collector configuration across a fleet from one self-hosted control plane, so a change like this is made once rather than host by host. The first 25 Collectors are free after a no-card registration in the OpenSight Customer Portal (5 without one).

Doing this in LinkMesh: Mask PII