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 GuideNobody 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 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.
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 |
CVV_NUMBER, treat it as an incident, not a cleanup ticket. See the PCI DSS cardholder data discovery guide.
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.
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'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.
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.
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.
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
}'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]"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);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}")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.
anonymized_text previews the API returns.
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.
Lessons from teams that have taken logging platforms from "PII everywhere" to continuously clean.
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.
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.
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.
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.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