Observability & Privacy

Scanning Application Logs for PII

Logs are where privacy programs quietly fail: emails in request URLs, names in stack traces, tokens in debug output — copied to every index, dashboard, and backup. This guide shows how PII gets into logs, how to scan ELK, Datadog, and CloudWatch pipelines, and how to build scrubbing middleware that stops the leak at the source.

Explore the Guide

Why PII Ends Up in Logs — Even in Careful Teams

Nobody designs a system to log personal data. It happens anyway, through a handful of thoroughly predictable mechanisms. The most common is error-path serialization: an exception handler dumps the offending request body or database row into the log "for debugging", and that row contains a customer's name, email, and card number. The happy path was reviewed; the error path was written at 2 a.m. during an incident and never revisited.

Close behind are URL and query-string logging (access logs faithfully record [email protected]&dob=1987-03-03 on every request), verbose framework defaults (ORMs echoing bound SQL parameters, HTTP clients logging full request/response bodies at DEBUG level), and developer breadcrumbs — the temporary logger.info(user) that shipped to production inside an object whose toString() prints every field. Third-party SDKs contribute too: payment, auth, and CRM libraries frequently log their own payloads under their own logger names, outside your code review entirely.

What makes log leakage uniquely damaging is amplification. A single leaked value does not stay in one file: it is shipped by the collector agent to the aggregation platform, indexed for search, mirrored into dashboards and alerts, copied into long-term archive storage, and included in backups. Log platforms are also among the most widely readable systems in a company — every engineer, many support staff, and often external monitoring vendors have query access. A GDPR right-to-erasure request that is trivial in your primary database becomes practically impossible across six months of immutable, indexed log archives.

The strategy that works is defense at two points. At the source, scrubbing middleware inside the logging framework masks entities before a line is ever emitted. In the pipeline and at rest, scanning jobs detect what slipped through, quantify the exposure, and drive cleanup. Both layers use the same call to the PII Detection API, which returns every entity found in a log line with its type, exact position, confidence score, and a ready-made masked version of the line. The rest of this guide builds both layers.

Structured vs Unstructured Logs: Two Different Scanning Problems

Structured logs — JSON lines with named fields — are the easier half of the problem, because field names carry intent. Some fields are deny-by-design: if your events contain email, ssn, or card_number keys, no ML is needed — drop or hash those fields in the emitter. Detection earns its keep on the fields that should be safe but are not: message, error, stack_trace, url, and any free-text field a developer or an exception can write into. The scanning pattern is: concatenate the free-text fields, send them to the detector, and map the returned offsets back to the source fields for masking.

Unstructured logs — classic multiline application output, syslog, stack traces — offer no field names to lean on. Everything is prose, and PII appears mid-sentence: "Failed to send receipt to Maria Kovač <[email protected]> for order 8841, card ending 4242, retrying". Regex-based scrubbers do poorly here because names, addresses, and free-form identifiers have no fixed shape, and because log text mangles formats (line wraps inside a card number, locale-specific dates, concatenated key=value fragments). Context-aware NER is the only approach that holds up, and it is exactly the case the API's transformer models are built for — including 60+ languages, which matters because user-generated strings inside logs arrive in whatever language your users type.

There is a third, hybrid case worth calling out: semi-structured lines such as nginx/Apache access logs and audit trails. The line format is fixed, but individual columns (URL, referrer, user agent) embed arbitrary user input. Here the efficient pattern is columnar: extract only the risky columns, batch many values into one detection request separated by newlines, and use exclude_entities to skip types that are legitimately pervasive in that column — you almost certainly want to keep URL and USER_AGENT findings out of an access-log scan while keeping EMAIL_ADDRESS, PHONE_NUMBER, and SSN.

Design principle: treat field-level hygiene (drop known-sensitive keys) and content-level detection (scan free text) as complementary. The first is cheap and deterministic; the second catches the leaks nobody declared. Teams that rely on only one of the two always end up surprised by the other half.

Where PII Hides in the Logging Stack

Prioritize scanning by where leaks concentrate. This map, drawn from real remediation projects, pairs each log source with the entity types most often found there and the scrubbing point that works best.

Log Source Typical Leaked Entities How It Gets There Best Control Point
Web/access logs EMAIL_ADDRESS, PHONE_NUMBER, AUTH_TOKEN, IP_ADDRESS PII in query strings, tokens in URLs, session IDs in referrers Gateway config + columnar pipeline scan
Application error logs PERSON_NAME, EMAIL_ADDRESS, ADDRESS, CREDIT_CARD_NUMBER Exception handlers serializing request bodies and DB rows Logging-framework scrubbing filter
Debug/trace output PASSWORD, API_KEY, DATABASE_CONNECTION_STRING, SSH_KEY Verbose SDK logging, config dumps at startup, curl traces Level policy in prod + secrets-scoped scan
Audit & security logs PERSON_NAME, EMAIL_ADDRESS, IP_ADDRESS, DEVICE_ID By design — but often over-collected and over-retained Field minimization + retention limits
Payment/checkout services CREDIT_CARD_NUMBER, CVV_NUMBER, IBAN_CODE, ZIP_CODE Gateway request/response logging, failed-transaction dumps Blocking scrub filter (PCI DSS scope)
Healthcare/clinical apps MEDICAL_RECORD_NUMBER, DIAGNOSIS, DATE_OF_BIRTH, HEALTH_INSURANCE_ID HL7/FHIR payload logging, appointment reminders, job queues Middleware scrub + archive audit (HIPAA)
Chat/support transcripts in logs PERSON_NAME, PHONE_NUMBER, ADDRESS, SSN Bot platforms and ticket systems logging full user messages Detection at ingestion, before indexing
PCI note: card numbers or CVVs in log files put the entire logging platform inside PCI DSS scope — collectors, indexes, dashboards, and backups included. Requirement 3 forbids storing CVV after authorization anywhere, logs included. If a scan of your payment-service logs finds even one CVV_NUMBER, treat it as an incident, not a cleanup ticket. See the PCI DSS cardholder data discovery guide.

Scanning Pipelines for ELK, Datadog, and CloudWatch

Every major log platform has a natural interception point where a detection call fits. The pattern is always the same — intercept, scan, mask or tag, forward — only the hook differs.

ELK / OpenSearch

Intercept in Logstash with a ruby or http filter, or in an ingest pipeline processor, calling the detection API on the message field and replacing it with anonymized_text before indexing. Tag events with the detected entity types (pii.types, pii.count) so Kibana can dashboard leak rates per service. For Filebeat-only setups, run the scrubber as a small sidecar between Beats and Elasticsearch.

Datadog

Datadog's built-in scanners handle fixed patterns; route the free-text remainder through detection before shipment. The clean hook is the Agent's log processing or, better, a Vector/FluentBit stage ahead of the Datadog intake that calls the API and forwards the masked line. Emit a pii_detected metric per service so leak regressions page the owning team instead of accumulating silently.

AWS CloudWatch

Attach a subscription filter to each log group that invokes a Lambda; the function decodes the batch, scans each event via the API, writes masked events onward (Firehose to S3, or a clean log group), and pushes entity-count metrics to CloudWatch Metrics. The same Lambda doubles as your historical auditor when pointed at exported archives — code below.

Kafka / Vector / Fluentd

If logs transit a bus, scrub at the bus: a consumer-producer pair (or Vector transform) reads raw topics, calls detection in micro-batches, and writes to a "sanitized" topic that downstream sinks subscribe to. This gives one enforcement point for every platform downstream. The streaming variant of this pattern is covered in PII detection in ETL and streaming pipelines.

Log-Scrubbing Middleware That Stops Leaks Before Emission

The cheapest leak to clean up is the one that never reaches disk. Start by verifying what the API does with a genuinely leaky log line — this is a request you can paste straight into a terminal (or run in the interactive demo):

# Scan one leaky log line
curl -X POST https://piidetectionapi.com/api/moderate.php \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "YOUR_API_KEY",
    "api_type": "pii_detection",
    "text": "ERROR PaymentSvc: retry failed for Maria Kovac <[email protected]>, card 4242 4242 4242 4242, ip 203.0.113.7",
    "entities": ["PERSON_NAME", "EMAIL_ADDRESS", "CREDIT_CARD_NUMBER",
                 "PHONE_NUMBER", "IP_ADDRESS", "API_KEY", "PASSWORD"],
    "mask_mode": "replace",
    "threshold": 0.5
  }'
cURL — single-line scan

The response's anonymized_text is the line you actually want in your index: "ERROR PaymentSvc: retry failed for [NAME] <[EMAIL]>, card [CREDIT_CARD_NUMBER], ip [IP_ADDRESS]". In Python, the idiomatic place to apply this is a logging.Filter installed on the root logger. The filter below fails open (never loses a log line), applies a fast local pre-filter so obviously clean lines skip the network call, and masks everything else:

import logging, re, requests

API_URL = "https://piidetectionapi.com/api/moderate.php"
API_KEY = "YOUR_API_KEY"

# Cheap pre-filter: only lines that MIGHT contain PII go to the API
SUSPECT = re.compile(r"[@\d]|user|customer|card|token|auth", re.I)

class PiiScrubFilter(logging.Filter):
    def filter(self, record):
        msg = record.getMessage()
        if not SUSPECT.search(msg):
            return True          # clearly clean, emit as-is
        try:
            r = requests.post(API_URL, json={
                "api_key": API_KEY,
                "api_type": "pii_detection",
                "text": msg,
                "entities": ["PERSON_NAME", "EMAIL_ADDRESS", "PHONE_NUMBER",
                             "CREDIT_CARD_NUMBER", "SSN", "ADDRESS",
                             "API_KEY", "PASSWORD", "AUTH_TOKEN"],
                "mask_mode": "replace",
                "threshold": 0.6,
            }, timeout=5)
            data = r.json()
            if data.get("entities_detected", 0) > 0:
                record.msg = data["anonymized_text"]
                record.args = ()
        except Exception:
            pass                     # fail open: better raw than lost
        return True

logging.getLogger().addFilter(PiiScrubFilter())
logging.warning("Password reset for [email protected] from 10.1.4.2")
# emitted: "Password reset for [EMAIL] from [IP_ADDRESS]"
Python — logging.Filter scrubber

In Node.js the equivalent hook is a formatter (Winston) or a transport wrapper (Pino). Because Node logging is synchronous by convention, the robust design queues lines and scrubs them in batches on the way to the transport — one API call for dozens of lines, joined with newline separators and split back apart using the returned offsets:

// Node.js — batch scrubber between the app and the transport
const queue = [];
const FLUSH_MS = 400, MAX_BATCH = 40;

function scrubbedLog(line) { queue.push(line); }

async function flush() {
  if (!queue.length) return;
  const batch = queue.splice(0, MAX_BATCH);
  try {
    const resp = await fetch("https://piidetectionapi.com/api/moderate.php", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        api_key: process.env.PII_API_KEY,
        api_type: "pii_detection",
        text: batch.join("\n"),
        entities: ["PERSON_NAME", "EMAIL_ADDRESS", "PHONE_NUMBER",
                   "CREDIT_CARD_NUMBER", "SSN", "AUTH_TOKEN", "API_KEY"],
        mask_mode: "replace",
        threshold: 0.6
      })
    });
    const data = await resp.json();
    data.anonymized_text.split("\n")
        .forEach(clean => process.stdout.write(clean + "\n"));
  } catch {
    batch.forEach(raw => process.stdout.write(raw + "\n")); // fail open
  }
}
setInterval(flush, FLUSH_MS);
JavaScript — batched Node.js scrubber

Auditing the Logs You Already Have

Middleware protects the future; the archive is the past, and the past is usually where the compliance exposure lives. A retroactive audit answers three questions: which services leak, which entity types, and how far back. The efficient shape is a batch job that walks exported log files (S3 exports from CloudWatch, Elasticsearch snapshots, plain files), scans them in chunks under the 50,000-character request limit, and aggregates counts per service and entity type — without writing any raw PII into its own report.

import requests, gzip, glob, collections

API_URL = "https://piidetectionapi.com/api/moderate.php"
API_KEY = "YOUR_API_KEY"
CHUNK = 45000

def scan_chunk(text):
    r = requests.post(API_URL, json={
        "api_key": API_KEY,
        "api_type": "pii_detection",
        "text": text,
        "exclude_entities": ["URL", "USER_AGENT"],
        "threshold": 0.7,
    }, timeout=60)
    return r.json().get("detected_entities", [])

report = collections.Counter()
for path in glob.glob("/exports/*/*.log.gz"):
    service = path.split("/")[2]
    buf = []
    size = 0
    for line in gzip.open(path, "rt", errors="ignore"):
        buf.append(line)
        size += len(line)
        if size >= CHUNK:
            for e in scan_chunk("".join(buf)):
                report[(service, e["type"])] += 1
            buf, size = [], 0
    if buf:
        for e in scan_chunk("".join(buf)):
            report[(service, e["type"])] += 1

for (service, etype), n in report.most_common():
    print(f"{service:24} {etype:24} {n}")
Python — archive audit with per-service report

Run the audit with a higher threshold (0.7 rather than 0.5) — for a prioritization report you want precision, and you can rescan the worst offenders at a lower threshold later. The output ranks services by leak volume, which converts directly into a remediation backlog: fix the emitter (top of this page), then decide what to do with the contaminated history — rewrite, re-index masked copies, or accelerate deletion under your retention policy. Findings here also feed test-environment hygiene: logs are one of the main routes by which production PII contaminates test and development data.

Reporting hygiene: notice the audit stores only counts and types, never matched text. A PII audit report containing the PII it found is a new copy of the problem. If reviewers need examples, store the anonymized_text previews the API returns.

Retention, Erasure, and What Regulators Expect of Logs

Log data enjoys no regulatory exemption. Under GDPR, an email address in a log line is personal data with the same status as one in your CRM: it needs a lawful basis, it counts in a breach, it falls under access and erasure rights, and storage limitation applies. The pragmatic reading regulators have converged on is that short, purpose-bound retention of operational logs is defensible (security monitoring and debugging are legitimate interests), but indefinite retention of PII-bearing logs is not. That makes your retention timer a compliance control — and scrubbing what enters the archive is what lets you keep useful logs longer.

The layered policy that works in practice: hot logs (7–30 days) may contain residual PII under tight access control because incident response sometimes genuinely needs raw context; warm and cold storage (30 days to years) should hold only scrubbed lines, which is exactly what pipeline masking produces; archives kept for audit (often 1–7 years under SOX, PCI DSS 10.5.1's one year, or sector rules) should be scrubbed and access-logged. HIPAA adds a wrinkle: audit controls require six years of activity records, which is only compatible with privacy principles if those records identify actors (who accessed what) without embedding patient content — a distinction scrubbing enforces mechanically.

Erasure requests are where unscrubbed logs hurt most. Deleting one user from six months of compressed, indexed, backed-up log archives is somewhere between expensive and impossible — which is why the sustainable answer is to ensure identifiable data never persists there. If you must handle erasure in existing archives, the detection API's hash mask mode is useful for the middle ground: consistent hashes preserve line-to-line correlation for debugging ("the same user hit this error five times") while removing the identifier itself. The GDPR PII detection guide covers the legal analysis in more depth.

Finally, document the control. Auditors respond well to a one-page statement: which pipelines scrub, which entity types, at what threshold, the per-service leak metrics from your scans, and the retention ladder above. The structured output of the API — types, counts, confidence — is precisely the evidence format that makes this reporting a query rather than a project.

Log Scrubbing Best Practices

Lessons from teams that have taken logging platforms from "PII everywhere" to continuously clean.

Scrub Early, Verify Late

Mask at the emitter or the first pipeline hop, then run a low-frequency detection scan on the indexed data as a verification loop. The downstream scan should find near zero entities; when it does not, the delta tells you exactly which service or SDK has a new leak. Alert on the trend, and treat a leak-rate regression like a failing test, owned by the team that owns the service.

  • Emitter-level filters as the primary control
  • Daily sampled scans of indexes as the verifier
  • Per-service leak metrics wired to ownership

Engineer for the Log Path

Logging is high-volume and latency-tolerant — design for that. Batch lines into single requests (newline-joined, well under the 50,000-character cap), pre-filter obviously clean lines locally, cache verdicts for repeated identical messages, and always fail open so scrubbing can never lose telemetry. A 400 ms batching window adds no meaningful delay to log delivery and cuts API volume by an order of magnitude.

  • Batch + pre-filter + cache before every network call
  • Fail open; queue and retry on transient errors
  • Keep scrubbing out of the request hot path (async)

Treat Secrets as Severity One

Personal data in logs is a compliance problem; credentials in logs are an active attack path — anyone with log-search access can harvest them. Scope a dedicated scan for API_KEY, PASSWORD, AUTH_TOKEN, cloud credentials, and connection strings at a low threshold, rotate anything found, and page on new occurrences. This single policy has ended more real incidents than any other log control.

  • Separate secrets policy with immediate paging
  • Automatic rotation workflow for confirmed finds
  • Pre-prod scanning in CI to catch debug logging

Frequently Asked Questions

Won't calling an API from my logging path slow the application down?
Not if scrubbing is kept off the request thread. The standard designs are: an async handler/queue inside the app (log calls return immediately, a background worker scrubs and ships), or scrubbing at the collector/pipeline stage where latency is invisible to the application. With batching — dozens of lines per request — the per-line overhead is typically under 5 ms amortized, and detection itself runs in ~150–250 ms per batched call.
Why not just use regex scrubbers built into Logstash or Datadog?
Keep them — they are free and catch rigid formats like well-formed card numbers. But logs are dominated by free text where regex fails: names, addresses, foreign-language user input, identifiers with unusual spacing, stack traces that wrap values across lines. Context-aware NER catches those and also avoids regex's classic false positives (order IDs flagged as SSNs, timestamps flagged as phone numbers). The practical setup is regex for cheap first-pass structure plus the detection API for everything free-text. See NER vs regex compared.
Should I mask, redact, or hash PII in logs?
Use replace (typed placeholders like [EMAIL]) as the default — it keeps lines readable and preserves the debugging signal of what kind of value was there. Use hash when you need correlation without identity: the same user's email hashes identically across lines, so you can still count "how many errors hit this one user" without knowing who they are. Reserve redact (removal) for high-sensitivity archives where even placeholders reveal too much structure. All three are one parameter on the same API call.
Is an IP address in a log file really personal data?
In the EU, generally yes — the CJEU held (Breyer, C-582/14) that even dynamic IPs are personal data when the operator has lawful means to link them to a person, and GDPR Recital 30 names IP addresses and cookie IDs as online identifiers. That does not mean you must strip IPs from security logs — security monitoring is a recognized legitimate interest — but it does mean IP-bearing logs need retention limits and access control. The API detects IP_ADDRESS, MAC_ADDRESS, DEVICE_ID, and COOKIE so you can decide per pipeline. Details in how to detect IP addresses and device identifiers.
How do I handle multi-line stack traces and non-English log content?
Send the whole multi-line event as one text block rather than line-by-line — the model uses surrounding context, and a value split across a wrapped line is only recoverable when the detector sees both halves together. Language is handled automatically: detection works across 60+ languages without configuration, which matters because the riskiest log content is often quoted user input in the user's own language. See the supported languages list.
What does a rollout look like, and what does it cost at log volume?
Typical sequence: week 1 — audit a sample of archives to find the leaky services; week 2 — deploy pipeline scrubbing for those services' log streams; weeks 3–4 — emitter-level filters in the worst offenders plus the verification scan. Cost is controlled by the pre-filter (most lines never call the API), batching, and sampling verification scans rather than scanning every line at rest. Volume pricing tiers are on the pricing page, and you can validate detection quality on your own log samples in the live demo first.

Find Out What's Hiding in Your Logs

Paste a real (sanitized) log line into the live demo and watch the entities light up — then wire the same call into your pipeline. Volume pricing scales to log-sized workloads.

Try the Live Demo View Pricing