LinkMesh

Search docs, blog and changelog

ENDE

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.

Signals
LogsTraces
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
  # 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:

Custom processor body (transform)
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

  1. Before

    body
    order placed
    attributes.user.id
    u-4711

    After

    body
    order placed
    attributes.user.id
    7e1784b30065a58d6e31ff4975f4586b5330dad3df32585d04146dfb5b4931b6
  2. Before

    body
    order shipped
    attributes.user.id
    u-4711

    After

    body
    order shipped
    attributes.user.id
    7e1784b30065a58d6e31ff4975f4586b5330dad3df32585d04146dfb5b4931b6
  3. Before

    body
    login
    attributes.user.id
    4711

    After

    body
    login
    attributes.user.id
    8e056fef7ea04c5d1fcea3bad16e2a7f126eae2d1d9a1b3831a098aaf933cfbc
  4. 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.

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