Every team that locks down its logging still watches PII resurface in error reports, traces, and query logs six months later. This guide is about why closing one observability surface doesn't close the others, and what has to change about how the rules get enforced, not just written.
A PII leak into logs rarely surfaces as an incident on its own. It surfaces during a SOC 2 evidence request, a GDPR data subject access request, or the forensic review after an unrelated breach, when someone discovers that a field a customer asked to have deleted has been sitting in a searchable log index, an error aggregator, or an APM trace store for years, readable by every engineer, contractor, and support agent who ever had log access.
None of the seven principles below are really about developer carelessness. They're about the fact that every observability surface - logs, error reporters, metrics, traces - is built by default to capture more context than any single call site controls, and that gap is exactly what discipline alone can't close.
Somewhere in your codebase there's a line that logs a whole user or order object. It works fine today, because today the object only has a display name and a timezone. Six sprints from now, someone adds a taxId or a dateOfBirth column to that same model, and every log line that has ever serialized it starts leaking that field too, without anyone touching the logging call. Never pass a whole request body, response body, entity, or domain object into a logging call — log a hand-picked, reviewed set of fields, referenced by the record's identifier, every time.
logger.info('user profile updated', { user });
// `user` is a Prisma `User` record — every column the ORM
// knows about, including any field added next sprint, gets serialized.logger.info('user profile updated', {
userId: user.id,
fieldsChanged: ['displayName', 'timezone'],
});This makes call sites more verbose, and it puts a decision in front of whoever adds a new field to the model: is this one safe to log. That friction is the point - a log statement that can't silently absorb new sensitive fields can't quietly start leaking six months from now.
The same instinct that logs whole objects for debuggability also logs emails and phone numbers directly, on the theory that it's faster to grep a log for an address than to look up a user ID. The cost shows up somewhere that developer never looks: access logs, CDN logs, and reverse proxy logs record full request URLs by default, so a password-reset link built with an email in the query string puts that address into every logging system between the client and the application, at every hop.
GET /password-reset?email=alice@acme.com&token=9fA31c...
POST /password-reset
{ "token": "9fA31c..." }
// resolved server-side to a record — no identifying
// value ever appears in the request line that gets loggedThe same logic applies to CSP violation reports, webhook callback URLs, and any other place a request line gets built and then logged by infrastructure the application team never configured.
Credentials get a separate rule from other identifiers because the failure mode is worse: leaking an email is a privacy problem, leaking a session token is an active account takeover waiting for whoever reads the log next. The most common way this happens isn't a developer deliberately logging a password - it's a debug line that dumps the whole header collection or cookie jar during an integration issue, and gets left in past the incident it was written for. Log the principal and the outcome, never the credential that authenticated it.
logger.info('request authenticated', {
userId: req.user?.id,
authMethod: 'bearer',
});OWASP's Logging Cheat Sheet lists exactly this pattern — full header or cookie dumps left in past a debugging session — among the most common ways credentials end up somewhere they were never meant to be.
You lose the ability to replay a malformed header from general-purpose logs alone. That's worth losing. A team that genuinely needs raw header capture belongs in a separate, short-retention, access-controlled debug channel, not in logs every service reads from.
The first three principles all rely on the same thing: someone, at every call site, remembering the rule, every time, forever, across every service and every refactor. That fails statistically, not because engineers are careless, but because discipline re-applied correctly at hundreds of call sites by dozens of people over years will fail at some fraction of them. The mechanism that holds is a redaction policy declared once, on the logging framework's configuration, covering known credential and personal-data field names - paired with a ban on any diagnostic path, like a direct console.log, that bypasses the framework entirely.
const logger = pino({
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.email', '*.ssn'],
censor: '[REDACTED]',
},
});This is the same centralization argument our structured logging standards guide makes for log hygiene generally — here it's the specific mechanism that keeps PII out.
Someone now owns the field-name list, and that list rots the moment a new service names the same concept contactInfo instead of email. That's a real cost - it's far cheaper than re-auditing every call site every time a new field ships.
A team can get every logging call site right and still leak through the door right next to it. The exception handler returns the raw exception message straight to the API caller, or the error-reporting SDK gets installed on default settings, which capture the full request body, cookies, and local variable values and ship them to a third-party service that never went through the redaction policy. Callers get a generic message and a correlation ID; exception messages never get a field value interpolated into them.
throw new ValidationError(`Invalid national ID: ${nationalId}`);
// logged, forwarded to the error aggregator, and often
// surfaced directly in support toolingthrow new ValidationError('Invalid nationalId format', { userId: user.id });Diagnostic worth running: check sendDefaultPii on your error-reporting SDK - if it's unset or true, and beforeSend doesn't strip cookies and auth headers, your error pipeline is shipping exactly what your logging policy blocks.
Metrics and traces get built after logging and error handling are already in decent shape, which is why they tend to repeat the same mistakes with a straight face. Setting a span attribute to a user's email feels like a debugging convenience, not a policy violation, right up until someone realizes the APM backend indexes every attribute value under a different retention policy than the primary datastore. The same happens with query logging: a team turns on bound-parameter capture to debug one slow query, and every value bound to every parameterized query since then sits in the observability backend.
Diagnostic worth running: grep your trace backend for high-cardinality attributes named email, name, or phone - if you get hits, personal data is sitting in a system with weaker access controls than your primary datastore.
OpenTelemetry's own semantic conventions for database instrumentation say the same thing directly: bound parameter values shouldn't be captured by default, because they may contain PII.
Every principle above reduces how much PII reaches your logs, errors, and telemetry. None of them reduce it to zero, and treating any of them as if they could is the actual mistake. A library instruments something on its own initiative, a field gets added the week a redaction review was skipped, a debug statement ships by accident and survives three deploys. The real failure isn't that PII occasionally reaches an observability system - it will, repeatedly. The failure is that nobody put an expiration date on it.
Rule of thumb: if a log group, index, or sink doesn't have an explicit retention period set, its actual retention policy is indefinite, whether or not anyone chose that on purpose.
This is the setting an individual team, deciding well for itself, still gets wrong at the org level — GDPR's Article 32 and NIST's own log management guidance both treat retention as a deliberate control, not a platform default, for exactly this reason.
Every principle above survives a design review. Few survive six months of independent team decisions, because the violations aren't visible from inside any one pull request - they're visible only in the aggregate. Pandorian enforces this guide across three surfaces:
A new logging call that serializes a whole request object, an error-reporting SDK left without PII scrubbing, a span attribute set to an email - each is a checkable code shape, flagged with the line, the rule, and a fix before it merges.
New code discipline doesn't retroactively fix the service that's been logging entity objects since before this guideline existed. A full scan establishes the baseline: every existing call site that logs a whole object, every header dump, every query-logging config that already captures bound parameters, every log group without a retention period.
The leaks that matter most are rarely introduced through a reviewed pull request. A shared middleware default gets flipped to log request bodies and never flipped back. An SDK upgrade quietly changes a default capture setting. Scheduled scans catch drift PR checks, by construction, never see.
Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.
PII leakage in telemetry is personal data ending up in logs, error reports, traces or metrics, where it lands in systems with different access controls and much longer retention than the database it came from. It usually arrives indirectly: a serialized request object, an exception message containing the input that caused it, or a trace attribute holding a full URL with an email in the query string.
The reason it is treated as its own discipline is scope. Your database has row-level controls and a retention policy. Your log platform is readable by every engineer on call and keeps data for a year.
Redaction removes the value entirely, masking replaces part of it while keeping a recognizable shape (j***@acme.com), hashing replaces it with a deterministic irreversible digest, and tokenization swaps it for a random reference that can be reversed only through a separate vault. They trade debuggability against exposure in that order.
Choose by what the log is actually for. If the field exists so an engineer can correlate events, a hash or token preserves that. If it exists because someone serialized an object without thinking, redact it.
More than most engineers expect. GDPR defines personal data as any information relating to an identified or identifiable person, which covers IP addresses, device identifiers, cookie IDs, user agent strings in combination, and internal user IDs that can be resolved back to a person. A log line does not have to contain a name to be personal data.
Two consequences follow for telemetry: pseudonymized identifiers are still personal data and still in scope, and a data subject deletion request covers your logging platform, not just your production database. This is general information rather than legal advice, and your DPO or counsel should confirm scope for your jurisdiction.
Usually not. An unsalted hash of an email is trivially reversible by anyone with a list of candidate emails, which means it functions as a pseudonym rather than as anonymization. Under GDPR, pseudonymized data remains personal data and stays in scope for access and deletion rights.
Hashing is still worth doing, because it changes what a leak of the log itself exposes and preserves the ability to correlate events for the same user. Just do not let it change your compliance posture. If the goal is genuine anonymization rather than reduced exposure, a salted keyed hash where the key is rotated and held separately gets closer, and dropping the field entirely gets you all the way.
Query the log platform rather than reading the code, because the code is where you would have caught it already. Run regex searches across the last 30 days for email patterns, national ID formats relevant to your markets, credit card patterns, bearer tokens and long base64 strings. Then check the highest-volume error types, since stack traces carry the inputs that caused them.
Two places that are almost always missed: trace and span attributes, where full URLs with query strings get captured automatically by instrumentation, and third-party error trackers, which serialize local variables at the point of failure by design.
A scrubber at the ingestion layer catches the patterns it knows, after the data has already left your process and crossed a network boundary. It matches emails and card numbers reliably, and misses everything shaped like your own domain: an internal customer reference, a free-text support note, a serialized object whose field names it has never seen. And it does nothing about the third-party SDK reporting directly to a vendor without passing through your pipeline.
Preventing the write is a different control from filtering the read. It means a rule on what may be passed to a logger, an error reporter or a span attribute, checked on every pull request, with full scans finding the call sites that already violate it.
You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.
Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.