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
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:
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):
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
Before
- body
- password reset sent to anna.meier@example.ch
After
- body
- password reset sent to <email>
Before
- body
- health check from user@localhost ok
After
- body
- health check from user@localhost ok
Before
- body
- payment declined for card 4111 1111 1111 1111
After
- body
- payment declined for card <card>
Before
- body
- order 1234 5678 9012 3456 shipped
After
- body
- order 1234 5678 9012 3456 shipped
Before
- body
- refund to IBAN CH93 0076 2011 6238 5295 7 booked
After
- body
- refund to IBAN <iban> booked
Before
- body
- retrying job DE12 after timeout
After
- body
- retrying job DE12 after timeout
Before
- body
- GET /api/orders Authorization: Bearer 9f3c2a7d1e8b4c6a0f5d2e9b7a1c3d8e
After
- body
- GET /api/orders Authorization: Bearer <token>
Before
- body
- the bearer token was rotated
After
- body
- the bearer token was rotated
Before
- body
- login from 192.168.10.24 accepted
After
- body
- login from <ip> accepted
Before
- body
- upgraded agent to 1.24.300.1
After
- body
- upgraded agent to 1.24.300.1
Before
- body
- {"msg":"structured","email":"anna.meier@example.ch"}
After
- body
- {"msg":"structured","email":"<email>"}
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}
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.
Used in these pipelines
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
Related snippets
- Drop sensitive attributes before exportDelete named attributes, and every captured HTTP request header, from logs, spans and metrics before they are exported.
- Pseudonymise user IDs with a salted hashReplace user.id with a salted SHA-256 on logs and spans - same user, same value, so per-user queries still join.
- Enforce required resource attributes (semantic conventions)Refuse telemetry that cannot be attributed - drop anything without service.name, service.namespace and deployment.environment.name.
- Redact everything except an attribute allowlistTurn masking around - name the attributes you keep, and the redaction processor removes everything else.