Structured JSON Logging — grep Is Not Enough

logging observability devops

Production is on fire. Someone SSHs into a box and greps a 4GB text file for Error. The failing checkout is buried in a multi-line stack dump with no request id. Loki and Datadog can ingest that wall of text, but you cannot filter status=500 AND route=/checkout AND request_id=… efficiently unless you emit structured JSON with stable field names.

SSH-and-grep is fine for a single laptop process. Distributed systems need join keys.

One event, one JSON object

function log(level: string, msg: string, fields: Record<string, unknown>) {
  const event = {
    level,
    msg,
    time: new Date().toISOString(),
    service: process.env.SERVICE_NAME,
    env: process.env.APP_ENV,
    ...fields,
  };
  process.stdout.write(JSON.stringify(event) + "\n");
}

Pretty-printed JSON across multiple lines breaks most shippers. Use NDJSON in production; pretty-print only on a developer machine. Prefer a library (pino, winston JSON mode, zap, slog) over hand-rolled forever — wrappers still belong to you for redaction.

Fields that pay rent

FieldPurpose
trace_id / request_idJoin logs across services
route / method / statusHTTP golden signals
error.type / error.messageAlert without dumping stacks into msg
duration_msLatency breadcrumbs
user_id (internal opaque id)Support — avoid raw email if policy forbids

Propagate ids from the edge:

app.use((req, res, next) => {
  const requestId = req.header("x-request-id") ?? crypto.randomUUID();
  res.setHeader("x-request-id", requestId);
  req.log = (level: string, msg: string, fields?: object) =>
    log(level, msg, { request_id: requestId, route: req.path, ...fields });
  next();
});

Without correlation, JSON is only prettier spaghetti.

PII and secret pitfalls

log("debug", "payload", { body: req.body }) will eventually capture passwords, tokens, or card data. Rules that survive audits:

  • Allowlist fields; never dump entire bodies by default.
  • Redact authorization, cookie, password, otp, PANs.
  • Assume log storage has broader read access than the app DB.
  • Stripe webhook bodies and auth headers do not belong in shared Slack log dumps.

Cardinality: labels vs fields

In Loki/Prometheus-style systems, labels are indexed and expensive. Keep labels low-cardinality: service, env, level. Put user_id and request_id in the JSON body (or structured metadata), not as labels — or the index explodes during a traffic spike.

Levels and noise control

LevelUse
errorNeeds human attention
warnDegraded path / retries
infoCoarse lifecycle events
debugLocal or sampled in prod

Logging every cache hit at info costs more than the cache saves. Sample debug; unlock verbose logging with a per-request header when debugging a single customer.

Query mental model

{service="api", env="prod"} | json | status >= 500 | line_format "{{.request_id}} {{.msg}}"

That workflow does not exist for free-form paragraphs. Teach on-call to query by request_id before downloading log files.

Migration without a big-bang rewrite

  1. Adopt one logger; configure JSON output in prod.
  2. Document a ten-field schema shared across services.
  3. Add request ids at the edge gateway or Worker.
  4. Ban new console.log in server PR review.
  5. Delete noisy hot-path logs as you touch files.

Structured logging is not “use JSON because it is trendy.” It is how your future self finds one failing checkout without grepping the universe — with redaction so the search itself is not a breach.