Skip to content

· 8 min read

Structured Logging Changed How I Debug

Moving from console.log to structured, queryable logs transformed how I investigate production issues. Here's the practical shift and why it matters.

By Tanner Goins

Dark server racks with tangled orange and teal network cables and glowing green status lights

For most of my career, my debugging workflow looked like this:

  1. Something breaks in production
  2. SSH into the server or open the log viewer
  3. Grep through walls of unstructured text
  4. Hope the relevant console.log happens to contain enough context
  5. Add more console.log statements, deploy, wait for it to happen again

This worked for small projects. It stopped working the moment I had more than one service, more than a few hundred requests per minute, or more than one person investigating the same issue.

Structured logging changed all of that. Not because it’s a revolutionary concept (it’s just JSON instead of strings), but because the downstream effects on debugging, monitoring, and incident response are enormous.


What Structured Logging Actually Is

Unstructured log:

2026-05-14 10:23:45 ERROR Failed to process payment for user 12345: timeout after 30s

Structured log:

{
  "timestamp": "2026-05-14T10:23:45.123Z",
  "level": "error",
  "message": "Failed to process payment",
  "userId": "12345",
  "orderId": "ord_abc789",
  "paymentProvider": "stripe",
  "error": "timeout",
  "timeoutMs": 30000,
  "requestId": "req_xyz456",
  "service": "payment-service",
  "environment": "production"
}

Same information, fundamentally different utility. The unstructured log is a sentence for humans to read. The structured log is data for machines to query.

With the structured version, I can:

  • Find every payment failure in the last hour: level:error AND message:"Failed to process payment"
  • See if it’s one user or many: group by userId
  • Check if it’s one payment provider: filter by paymentProvider
  • Trace the full request: search by requestId
  • Compare environments: filter by environment

With the unstructured version, I’m writing regex and hoping the format is consistent.


The Before and After

Before: Investigating a Spike in Errors

Step 1: Open the log viewer. See a wall of text.

Step 2: Search for “error” or “failed.” Get hundreds of results: some relevant, some not, some from other services.

Step 3: Manually scan through lines trying to spot a pattern. Are the errors all for the same endpoint? The same user? The same time window?

Step 4: Copy individual log lines into a text file and try to correlate them manually.

Step 5: Give up and add more log statements. Deploy. Wait.

Time to root cause: 45 minutes to several hours.

After: Same Investigation With Structured Logs

Step 1: Open the log dashboard. Filter: level:error, last 1 hour.

Step 2: Group by message. See that payment failures spiked at 10:15.

Step 3: Filter to payment failures. Group by paymentProvider. All failures are Stripe.

Step 4: Filter to Stripe errors. Group by error. All are timeouts.

Step 5: Check Stripe’s status page. Confirmed: partial outage started at 10:14.

Time to root cause: 3 minutes.

That’s not an exaggeration. Structured logs turn investigation from a manual reading exercise into a query exercise. And querying is something engineers are very good at.


How to Start

Pick a Logging Library

Don’t build your own structured logger. Use one that’s battle-tested:

Node.js:

  • Pino: fast, JSON-native, low overhead. My go-to for Node projects.
  • Winston: more configurable, wider ecosystem.

Go:

  • Zerolog: zero-allocation JSON logger. What I used when I rewrote my backend in Go.
  • Zap: Uber’s structured logger. Slightly more features, slightly more complex.

Python:

  • structlog wraps the standard library with structured output.

All of these produce JSON logs by default and support adding contextual fields.

Replace console.log With the Logger

This is the mechanical part. Everywhere you have console.log, replace it with a structured log call:

Before:

console.log(`User ${userId} placed order ${orderId} for $${amount}`);

After:

logger.info("Order placed", {
  userId,
  orderId,
  amount,
  currency: "USD",
});

The key shift: the message is a fixed string, and the data goes in fields. Don’t interpolate variables into the message. Keep the message stable so you can group and filter by it.

"Order placed" is the same every time. The varying data (who, what, how much) lives in the structured fields.

Add Context at Boundaries

The most valuable structured fields are the ones that let you correlate across a request or workflow:

  • requestId: a unique ID assigned at the start of every request. Include it in every log line for that request. When something goes wrong, search by requestId and you see the complete story.
  • userId: if the request is authenticated, include this everywhere.
  • service: which service emitted this log. Essential when you have more than one.
  • environment: production, staging, development. Prevents confusion.

Most logging libraries support a “child logger” or “context” pattern that automatically includes these fields in every subsequent log call:

// At the start of a request
const reqLogger = logger.child({
  requestId: req.id,
  userId: req.user?.id,
  path: req.path,
});

// Every log in this request includes the context
reqLogger.info("Processing payment", { orderId, amount });
reqLogger.info("Payment succeeded", { orderId, transactionId });

Choose a Log Aggregator

Structured logs need a place to live where you can query them. Options range from free to expensive:

Self-hosted / free:

  • Grafana + Loki, the open-source stack. Loki stores logs, Grafana queries them. Good if you’re already in the Grafana ecosystem.

Managed / paid:

  • Datadog: excellent query UX, pricey at scale.
  • Axiom: generous free tier, good for small projects.
  • Better Stack (formerly Logtail): simple, affordable, good for small teams.

Cloud-native:

  • AWS CloudWatch Logs, if you’re already on AWS.
  • Google Cloud Logging, if you’re on GCP.

For personal projects and small teams, Axiom or Better Stack offer the best value. For larger teams already invested in a cloud provider, use the native offering.


Patterns That Pay Off

Log Levels That Mean Something

Most teams log everything at info and error. Use the full spectrum:

LevelUse forExample
debugDetailed info for development. Off in production.“Cache miss for key user:123”
infoNormal operations worth recording.“Order placed”, “User logged in”
warnSomething unexpected that didn’t fail.“Payment retry attempt 2 of 3”
errorSomething failed that needs attention.“Payment failed: timeout”

The distinction between warn and error is particularly valuable. Warnings are things that self-recovered or degraded gracefully. Errors are things that failed and need investigation. If everything is an error, you get alert fatigue. If warnings exist, you can monitor them separately for patterns.

Duration Logging

For any operation that hits an external service (database queries, API calls, cache lookups), log the duration:

const start = Date.now();
const result = await db.query(sql);
logger.info("Database query completed", {
  query: "getOrdersByUser",
  durationMs: Date.now() - start,
  rowCount: result.length,
});

Over time, this data reveals performance trends that would be invisible otherwise. A database query that usually takes 5ms but occasionally takes 500ms is a problem you can only find with duration logging.

Error Context

When logging errors, include everything someone would need to reproduce or diagnose the issue:

try {
  await processPayment(order);
} catch (error) {
  logger.error("Payment processing failed", {
    orderId: order.id,
    userId: order.userId,
    amount: order.amount,
    paymentMethod: order.paymentMethod,
    errorMessage: error.message,
    errorCode: error.code,
    stack: error.stack,
  });
}

The goal is that someone reading this log entry has enough context to understand what happened without needing to look at the code. This is the same principle behind the observability approach for AI systems: make the system’s decisions auditable after the fact.

Sensitive Data

Never log passwords, API keys, credit card numbers, or personally identifiable information (PII) that you’re not authorized to store. This isn’t just good practice. It’s often a legal requirement.

Mask or omit sensitive fields:

logger.info("User authenticated", {
  userId: user.id,
  email: maskEmail(user.email), // "t***@example.com"
  authMethod: "password",
  // Never log the password itself
});

What Changes When You Have Good Logs

Incidents get shorter. The time from “something is broken” to “here’s what’s broken” drops from hours to minutes. Most of that time was previously spent finding the relevant information. Structured logs make finding instant.

Monitoring becomes proactive. Instead of waiting for users to report problems, you can set alerts on structured fields. “Alert me if level:error AND service:payment-service exceeds 10 in 5 minutes.” That’s a one-line query, not a complex monitoring setup.

Debugging becomes collaborative. When logs are queryable, you can share a link to a filtered view: “Here’s every log line for request req_xyz456.” Your teammate sees exactly what you see. No more “can you check the logs on server 3?”

Performance insights emerge. Duration logging across services reveals where time is spent. You might discover that your “slow API” is actually fast, and it’s the database query behind it that’s slow. Without structured duration data, you’d be guessing.


The Migration Path

If you have an existing application with unstructured logging, here’s a realistic migration plan:

Week 1: Install a structured logging library. Configure it to output JSON. Replace console.log in your main request handler with structured calls. Add requestId to every request.

Week 2: Set up a log aggregator (start with a free tier). Pipe your structured logs to it. Run your first query against production data.

Week 3: Migrate the highest-traffic code paths to structured logging. Add duration logging to external calls.

Week 4: Set up your first alert based on structured fields. Add error context to your catch blocks.

You don’t need to convert every console.log at once. Start with the code paths that break most often and expand from there.


Start With One Log Line

If this all feels like a lot, start with one thing: the next time you write a console.log, make it structured instead.

// Instead of this
console.log(`Processed ${items.length} items in ${duration}ms`);

// Write this
logger.info("Batch processing complete", {
  itemCount: items.length,
  durationMs: duration,
  batchId,
});

One log line won’t transform your debugging. But once you’ve queried that log line in a real investigation and found the answer in seconds instead of minutes, you’ll want to do it everywhere.

That’s how it starts. One structured log line at a time.