Observability

Structured Logging Best Practices: A Field Guide to Log Hygiene

Structured logging standards are the rules governing how a service emits, labels, and protects its log output. This guide is about why those rules keep getting broken by engineers who already agree with them, at the exact moment the rules exist to protect.

10 min read · 8 Principles ·
Four olive-green curved panels stacked in a spiraling, layered formation.

TL;DR

  • A log record is a row of structured fields, not a sentence - interpolating values into message text makes the record unfilterable and unaggregatable, which defeats the reason log collection exists.
  • Every record needs its coordinates: a correlation identifier tying it to one request, and a service/version/environment tying it to one deployed build, or concurrent operations and past deploys become indistinguishable.
  • The logging framework is the only legal way out of a process - direct stdout writes and local file appenders both bypass structure, redaction, and the platform's collection point.
  • Severity levels are a promise to the on-call engineer, not a description of how the code author felt - reserving error for conditions that need intervention is what keeps that promise true.
  • Log volume is a budget, not a byproduct -logging once per operation instead of once per record is what keeps that budget from being spent by accident.
  • What you log becomes your attack surface - a credential or a whole request body logged for convenience during debugging is a credential or a personal data record sitting in a system built for search, not access control.
  • Redaction can't live at the call site - a rule that depends on every engineer remembering it at every call site, forever, isn't a rule; it's a policy that needs to be enforced somewhere no individual memory has to hold it.

What Structured Logging Standards Actually Protect

Ask five engineers why a credential ended up in a log store and four will describe a reasonable debugging shortcut, not carelessness. Logging the whole request object to see what a client sent, or catching an exception and logging just its message because the object felt redundant, is the fastest way to see what's wrong in the moment someone needs to see it.

None of the seven principles below are really about discipline. They're about the fact that logging correctly is easiest exactly when nothing is on fire, and hardest exactly when something is - which is the one moment none of these rules can be allowed to depend on memory.

ANTI-PATTERN — value interpolated into the message string
logger.info(f"User {user_id} failed checkout: insufficient funds for order {order_id}")
CORRECTED — constant message, variability moved into fields
logger.info("checkout.declined", user_id=user_id, order_id=order_id, reason="insufficient_funds")

The tradeoff: a stable message template means you can't improvise wording mid-incident to add color - you extend the field set instead. That's the correct trade. Expressiveness at the call site costs queryability at the aggregator, and the aggregator is what everyone actually uses six months later.

1. Log Records Are Data, Not Prose

Your on-call engineer is trying to find every instance of a failed checkout this week. The log line reads free text with the user ID and order ID woven in, so there's no field to filter on but a regex. Worse, the next engineer who touches this code rephrases the message slightly, and the same event now produces two different strings in the aggregator - the dashboard built to count this failure silently starts undercounting it the day the wording changed.

ANTI-PATTERN — value interpolated into the message string
logger.info(f"User {user_id} failed checkout: insufficient funds for order {order_id}")
CORRECTED — constant message, variability moved into fields
logger.info("checkout.declined", user_id=user_id, order_id=order_id, reason="insufficient_funds")

The tradeoff: a stable message template means you can't improvise wording mid-incident to add color - you extend the field set instead. That's the correct trade. Expressiveness at the call site costs queryability at the aggregator, and the aggregator is what everyone actually uses six months later.

2. Every Record Needs Its Own Coordinates

Three checkout requests hit your payment service in the same 200-millisecond window. All three fail. The logs interleave with no way to tell which line belongs to which request. Separately, a regression appears the morning after a deploy, but the log lines don't say which build produced them, so the first twenty minutes of the incident are spent confirming which of the last three releases is even running right now.

ANTI-PATTERN — no correlation ID, no service coordinates
const logger = require('pino')();
app.use((req, res, next) => {
  logger.info(`handling ${req.method} ${req.path}`);
  next();
});
CORRECTED — coordinates bound once, inherited by every call
const baseLogger = require('pino')({
  base: { service: 'checkout-api', version: process.env.BUILD_SHA, env: process.env.NODE_ENV }
});
app.use((req, res, next) => {
  req.log = baseLogger.child({ correlationId: req.headers['x-request-id'] ?? crypto.randomUUID() });
  req.log.info('request.received', { method: req.method, path: req.path });
  next();
});

This is the same problem OpenTelemetry's logs specification solves at the platform level, by including trace and span identifiers directly in the log record. The tradeoff: correlation only survives if it's propagated across every hop, including the ones that don't look like hops — a queue message, a scheduled job. Miss one async boundary and the chain breaks silently.

3. Severity Levels Are Promises, Not Feelings

Every declined checkout - a customer with insufficient funds, not a bug, not anyone's fault - gets logged at error level, and error level pages someone. Within a month the on-call rotation learns to treat these alerts as noise, and three weeks later a genuine payment-processor outage gets the same shrug. Separately, a handler logs only the exception's string form, so during the incident this line was meant to help with, there's no stack trace and no cause chain - because the exception object itself was never passed to the logger.

CORRECTED — expected outcome down-leveled, exception object preserved
try:
    charge_card(order)
except PaymentDeclined as e:
    logger.info("checkout.declined", order_id=order.id, reason=e.code)
except Exception:
    logger.exception("checkout.failed", order_id=order.id)

This is the exact discouragement test Google's SRE book describes for alerting design: a good alerting rule should ask whether the responder will ever be able to ignore it, knowing it's benign. An error level nobody can ignore, attached to a condition that happens constantly and requires nothing, fails that test immediately.

The tradeoff: down-leveling expected failures means you lose the ability to alert on their volume using log-based tooling alone. If you need to know when decline rate spikes, that's a metric with a counter, not a log level chosen to make a dashboard light up.

4. Log Volume Is a Budget You Set

A nightly batch job processes two million records and logs one line per record to track progress. On a normal night this is merely wasteful. On the night the job fails partway through - the exact night anyone needs the logs - the volume spike triggers the collector's rate limiter, and the records that would show exactly where processing stopped get dropped before they're indexed. Separately, a debug level added during a hotfix six months ago never got removed, and a production service has been logging at debug volume ever since.

CORRECTED — level resolved from configuration, not hardcoded
logging.getLogger().setLevel(
  os.environ.get("LOG_LEVEL", "INFO")
)
# not: logging.getLogger().setLevel(logging.DEBUG)

Diagnostic worth running: pull up your log ingestion dashboard next to a batch job's record-count metric. If the two lines move in lockstep, every record is generating a log line, and the job you most need visibility into during a slow week is exactly the one whose logs your collector will start dropping first.

The tradeoff: a summary line per operation means losing per-record detail for the rare case you genuinely need it. Solve that with sampling — log one in a thousand records at full detail — rather than reverting to logging every record because sampling felt like more setup work.

5. The Logging Framework Is Your Only Exit

An engineer debugging a cache issue adds a quick print statement to see what's happening, ships it in the same PR, and it sits in production for eight months, breaking the log collector's JSON parser on every request. Separately, someone configures a rotating file handler to get logs on disk for one investigation. Nobody removes it. The container's local disk fills up over the following quarter, and the service starts failing because it can no longer write its own logs.

ANTI-PATTERN — print statement and a local file appender
print(f"Cache miss for key {key}")
# ...
handler = logging.handlers.RotatingFileHandler(
  "/var/log/app/app.log", maxBytes=10_000_000
)
logger.addHandler(handler)
CORRECTED — structured logger, stdout, platform handles the rest
logger.debug("cache.miss", key=key)
# ...
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)

This is the same discipline behind the Twelve-Factor App's logs factor: a well-behaved process treats its logs as an unbuffered event stream written to stdout and never concerns itself with routing or storage. The only real pushback is wanting a readable console format in local development — which is a formatter setting on the same logger, not a reason to open a second, unmanaged exit path out of the process.

6. What You Log Becomes Your Attack Surface

An engineer debugging an authentication issue logs the entire incoming request object, headers included, to see exactly what the client sent. The bug gets fixed. The logging call doesn't. Six months later, a routine access review discovers that every bearer token issued during that window is sitting in plaintext in a log store with a 90-day retention policy and a longer list of people who can query it than the list who can touch the production database. No breach occurred. The credentials were compromised anyway, by the debugging convenience that logged them.

Diagnostic worth running: can someone with only log-viewer access reconstruct a user's password reset flow well enough to hijack the account? If the answer is yes, the problem isn't your access control policy - it's that the reset flow's logging is generating a credential-recovery script for anyone who can read logs.

The OWASP Logging Cheat Sheet frames this as sanitizing event data before it's written, not restricting who can read it afterward. MITRE tracks this exact failure mode as CWE-532, insertion of sensitive information into a log file — a named, catalogued weakness, not an edge case. It makes local debugging slightly less convenient: you decide beforehand which fields matter, instead of dumping the object and sorting it out later.

7. Redaction Can't Live at the Call Site

Six months after "never log a credential" becomes team policy, a scan turns up a dozen violations anyway - not from anyone ignoring the rule, but from a new hire who never read the wiki page, a hotfix shipped under incident pressure that skipped review, and a third-party SDK's debug-request-logging flag someone enabled during a support case and never disabled. Every violation is a reasonable individual decision made by someone who, if you asked them, would agree completely with the rule they just broke.

Rule of thumb: a logging standard that lives in a wiki page enforces nothing on its own; a redaction policy that lives in the logger's serializer enforces itself, on every call site, including the ones nobody's touched since before the policy existed.

NIST's guidance on log management makes the same structural point at the program level: effective log management requires consistent processes across an entire organization, not local decisions at each system. This is the honest hinge in this guide — every other principle can be applied correctly by a disciplined engineer at the moment they write the code. This one can't, because "at every call site, forever" isn't a property any individual's memory can hold at scale. That's what makes this a codebase governance problem instead of a code review problem, and it shows up fastest wherever AI-generated code is landing print statements and full-object logging back into a codebase faster than any human reviewer can catch them one PR at a time.

How Pandorian Enforces This Guide

Writing these seven principles down changes nothing on its own - every team already has some version of them somewhere. What changes outcomes is running them against real code, continuously, on Pandorian's three enforcement surfaces:

Caught at PR level

Every pull request is checked against the full rule set before merge. A string-interpolated log call, a print statement, a hardcoded debug level, a credential passed to a logger - each flagged with the line, the rule, and a fix.

Full scans

An on-demand sweep across the entire codebase establishes the day-one baseline: every existing file appender nobody's removed, every exception logged by message only, every call site where a request body gets logged whole - the violations no PR check could have caught because they predate the guideline.

Scheduled scans

The same full scan, run on a cadence, catches drift introduced outside any pull request - a dependency update that quietly re-enables verbose request logging by default, an infra config change that flips a log level, an SDK's debug flag left on after a support session.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
SEC_77212
A credential, token, or authorization header literal passed directly into a logging call.
OBS_76101
A log statement placed inside a loop iterating over a collection or result set.
OBS_82656
A file or rotating-file appender configured in the logging setup.
OBS_81545
A log level hardcoded as a literal in application code instead of resolved from configuration.

FAQ

What is structured logging?

+

Structured logging is the practice of emitting log events as machine-parseable records with named fields, typically JSON, instead of formatted prose strings. Every event carries the same core keys (timestamp, level, service, trace ID, message) plus event-specific fields, so logs can be queried and aggregated rather than grepped.

The value shows up during an incident. A prose log tells you something happened. A structured log lets you ask which tenants it happened to, in what order, and whether the rate changed.

What is log cardinality, and why does it drive cost?

+

Log cardinality is the number of distinct values a field can take. Low-cardinality fields (level, service name, region) are cheap to index and useful for aggregation. High-cardinality fields (user ID, request ID, full URLs with query strings) explode index size and are what turns a logging bill into a line item someone asks about in a board meeting.

The rule that follows: put high-cardinality values in the log body as searchable fields, but never let them become the dimensions your logging platform indexes by default.

What is the difference between logs, metrics and traces?

+

Logs are discrete events with context, metrics are aggregated numeric measurements over time, and traces are causally linked records of one request moving across services. They answer different questions: metrics tell you something is wrong, traces tell you where, logs tell you what actually happened at that point.

Teams get into trouble by using one to do another's job. Deriving a metric by counting log lines is expensive and lossy. Logging every request to reconstruct a trace produces volume nobody can afford to retain.

Is it ever acceptable to log a full request or response body?

+

Yes, in narrow cases: a debug-level logger that is off by default in production, a sampled subset with fields already redacted at the serializer, or a dedicated audit log with its own retention and access controls. What is not defensible is body logging on the default path in production, because the contents of a request body are unbounded and nobody reviews what a third-party client decides to send.

The honest tradeoff is real. Full bodies are the single most useful artifact when debugging an integration, and removing them makes some incidents genuinely harder. The answer is a deliberate, scoped, redacted mechanism rather than an implicit one.

How do I tell if our logs are actually useful during an incident?

+

Take the last three incidents and check three things. Could you filter to a single request across every service it touched using one identifier? Did the log line that identified root cause contain enough fields to act on, or did someone have to open the code to interpret it? And how many log lines did the on-call engineer scroll past to find it?

A fourth check, less comfortable: count how many distinct log formats exist in your codebase. If more than one serializer is in play, your queries are silently missing events.

Can't a linter enforce logging standards?

+

A linter can catch syntax, not semantics. It can flag a raw print or console.log, but it cannot tell you that a new endpoint logs at the wrong level, omits the trace ID, uses a different field name for the same concept, or emits a message that is unparseable by the schema everything else follows. Those are the failures that make logs useless, and every one of them is valid code.

Enforcement that works has to compare each new log statement against the standard the organization actually wrote, on every pull request, and scan existing repositories for the drift that accumulated before the standard existed.

Get the Guideline Package

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.

From Principles to Enforcement

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.