Snippets · Processors
Pseudonymise user IDs with a salted hash
You need to follow one user through logs and traces, and you are not allowed to store who that user is. Hashing keeps the join: the same user.id always produces the same value, so per-user dashboards and trace searches still work, but the ID never reaches the backend. A numeric ID and the same ID as a string hash to the same value.
- 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
# Replaces user IDs with a salted SHA-256: the same user always gets the same
# value, so per-user queries still work, but the ID itself never leaves.
# Set USER_ID_SALT to the same secret on every collector that hashes.
transform/hash-user-ids:
error_mode: ignore
log_statements:
- context: log
statements:
- set(attributes["user.id"], SHA256(Concat(["${env:USER_ID_SALT}", attributes["user.id"]], ""))) where attributes["user.id"] != nil
trace_statements:
- context: span
statements:
- set(attributes["user.id"], SHA256(Concat(["${env:USER_ID_SALT}", attributes["user.id"]], ""))) where attributes["user.id"] != nil
batch:
timeout: 5s
exporters:
otlphttp:
endpoint: https://your-backend:4318
service:
pipelines:
logs:
receivers: [otlp]
processors: [memory_limiter, transform/hash-user-ids, batch]
exporters: [otlphttp]
traces:
receivers: [otlp]
processors: [memory_limiter, transform/hash-user-ids, batch]
exporters: [otlphttp]
LinkMesh
LinkMesh has no built-in template for this job. Add it as a custom processor in raw mode, with transform as the component type and this body - the same lines as under transform/hash-user-ids in the collector config:
error_mode: ignore
log_statements:
- context: log
statements:
- set(attributes["user.id"], SHA256(Concat(["${env:USER_ID_SALT}", attributes["user.id"]], ""))) where attributes["user.id"] != nil
trace_statements:
- context: span
statements:
- set(attributes["user.id"], SHA256(Concat(["${env:USER_ID_SALT}", attributes["user.id"]], ""))) where attributes["user.id"] != nil
How custom components are validated and rolled out: create a custom processor.
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.
4 log records in, 4 log records out
Before
- body
- order placed
- attributes.user.id
- u-4711
After
- body
- order placed
- attributes.user.id
- 7e1784b30065a58d6e31ff4975f4586b5330dad3df32585d04146dfb5b4931b6
Before
- body
- order shipped
- attributes.user.id
- u-4711
After
- body
- order shipped
- attributes.user.id
- 7e1784b30065a58d6e31ff4975f4586b5330dad3df32585d04146dfb5b4931b6
Before
- body
- login
- attributes.user.id
- 4711
After
- body
- login
- attributes.user.id
- 8e056fef7ea04c5d1fcea3bad16e2a7f126eae2d1d9a1b3831a098aaf933cfbc
Before
- body
- anonymous visit
After
- body
- anonymous visit
The gotcha
An unsalted hash of a short ID is not pseudonymous: hashing a million IDs of the form u-0 to u-999999 took 0.3 seconds on a laptop, so anyone holding the backend data can reverse it. This config salts with USER_ID_SALT, and if that variable is unset the collector still starts - it logs one warning and then emits plain, unsalted hashes. Set the same salt on every collector that hashes, or the same user gets different values on different hosts.
Used in these pipelines
What it does not catch
It only touches user.id on log records and spans. User IDs inside the log body, in other attribute names, or on resource attributes are left as they are.
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.
- Mask PII in logs (e-mail, card numbers, IBAN, tokens, IPs)Rewrite e-mail addresses, card numbers, IBANs, bearer tokens and IPs to placeholders on the collector, before the data leaves your network.
- 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.