Most applications log too much useless information and too little useful information. They dump raw strings into files that nobody reads until something breaks — and then the logs don’t have what you need.

Good logging is the difference between “we found the bug in 5 minutes” and “we spent 3 days reproducing a production issue.” Let’s fix your logs.

The Problem with String Logging

// This is what most logging looks like
console.log("Processing order");
console.log("User: " + userId);
console.log("Order total: " + total);
console.log("Payment succeeded");
console.log("Error: " + error.message);

This is almost useless in production:

  • No timestamps (when did this happen?)
  • No structure (how do you search/filter these?)
  • No context (which request? which user? which server?)
  • No levels (is this info, a warning, or a critical error?)
  • Mixed with every other log line from every other request

Structured Logging

Structured logs are key-value pairs, not free-form strings. They’re searchable, filterable, and parseable by machines:

// Instead of: console.log("User [email protected] placed order #123 for $99.99")
// Do:
logger.info("Order placed", {
  userId: "usr-42",
  email: "[email protected]",
  orderId: "ord-123",
  total: 99.99,
  currency: "USD",
  itemCount: 3,
  paymentMethod: "credit_card",
});

Output (JSON):

{
  "level": "info",
  "message": "Order placed",
  "timestamp": "2024-03-15T14:30:00.123Z",
  "userId": "usr-42",
  "email": "[email protected]",
  "orderId": "ord-123",
  "total": 99.99,
  "currency": "USD",
  "itemCount": 3,
  "paymentMethod": "credit_card",
  "service": "order-service",
  "hostname": "prod-web-03"
}

Now you can query: “Show me all orders over $50 from user usr-42 in the last hour.” That’s impossible with string logs.

Setting Up Structured Logging

TypeScript with Pino

import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  formatters: {
    level: (label) => ({ level: label }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});

// Create child loggers with persistent context
const orderLogger = logger.child({ module: "orders" });

orderLogger.info({ orderId: "ord-123", total: 99.99 }, "Order placed");
// Output: {"level":"info","time":"2024-03-15T14:30:00Z","module":"orders","orderId":"ord-123","total":99.99,"msg":"Order placed"}

Python with structlog

import structlog

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.JSONRenderer(),
    ],
)

logger = structlog.get_logger()

logger.info("order_placed", order_id="ord-123", total=99.99, user_id="usr-42")
# Output: {"event": "order_placed", "order_id": "ord-123", "total": 99.99, "user_id": "usr-42", "level": "info", "timestamp": "2024-03-15T14:30:00Z"}

Log Levels: Use Them Correctly

Most codebases misuse log levels. Here’s what each one actually means:

// ERROR: Something broke. A human needs to investigate.
// - Failed payment processing
// - Database connection lost
// - Unhandled exception in a request handler
logger.error({ orderId, error: err.message, stack: err.stack }, "Payment processing failed");

// WARN: Something unexpected happened, but the system recovered.
// - Retry succeeded after failure
// - Approaching rate limit
// - Deprecated API called
logger.warn({ retryCount: 3, endpoint: "/api/users" }, "Request succeeded after retries");

// INFO: Significant business events. The stuff you want in production.
// - User signed up
// - Order placed
// - Deployment completed
logger.info({ userId, plan: "premium" }, "User upgraded subscription");

// DEBUG: Detailed technical information for troubleshooting.
// - SQL queries executed
// - Cache hit/miss
// - Request/response payloads
// OFF in production by default. Turn on per-module when debugging.
logger.debug({ query, params, duration: "45ms" }, "Database query executed");

// TRACE: Extremely detailed. Function entry/exit, loop iterations.
// Almost never enabled, even in development.
logger.trace({ input, step: 3, intermediateResult }, "Transform step completed");

The test for each level:

  • ERROR → Would you wake someone up at 3 AM? Then it’s an error.
  • WARN → Would you want to know about this in a morning review? Warning.
  • INFO → Would you want this in a dashboard or audit trail? Info.
  • DEBUG → Only useful when actively debugging a specific issue? Debug.

Correlation IDs: Connecting the Dots

A single user action generates logs across multiple services. Without a correlation ID, you can’t connect them:

import { randomUUID } from "crypto";
import { AsyncLocalStorage } from "async_hooks";

const requestContext = new AsyncLocalStorage<{ correlationId: string }>();

// Middleware: assign a correlation ID to every request
function correlationMiddleware(req: Request, res: Response, next: NextFunction): void {
  const correlationId = req.headers["x-correlation-id"] as string ?? randomUUID();
  res.setHeader("x-correlation-id", correlationId);

  requestContext.run({ correlationId }, () => next());
}

// Logger automatically includes the correlation ID
function getLogger() {
  const context = requestContext.getStore();
  return logger.child({ correlationId: context?.correlationId ?? "unknown" });
}

// Now every log in this request shares the same correlation ID:
app.post("/orders", async (req, res) => {
  const log = getLogger();
  log.info({ items: req.body.items }, "Creating order");
  // correlationId: "a1b2c3d4-..." automatically included

  const order = await orderService.create(req.body);
  log.info({ orderId: order.id }, "Order created");
  // Same correlationId — you can trace the entire request flow
});

Python equivalent:

import contextvars
import uuid

correlation_id: contextvars.ContextVar[str] = contextvars.ContextVar("correlation_id")

def correlation_middleware(request, call_next):
    cid = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
    correlation_id.set(cid)
    response = call_next(request)
    response.headers["X-Correlation-ID"] = cid
    return response

def get_logger():
    return logger.bind(correlation_id=correlation_id.get("unknown"))

What NOT to Log

This is just as important as what to log:

Never Log Secrets

// ❌ NEVER do this
logger.info({ password: user.password }, "User login");
logger.debug({ token: authToken }, "API call");
logger.info({ ssn: customer.ssn }, "Customer created");
logger.debug({ creditCard: paymentInfo.cardNumber }, "Processing payment");

// ✅ Redact or omit sensitive fields
logger.info({ userId: user.id }, "User login successful");
logger.debug({ tokenPrefix: authToken.slice(0, 8) + "..." }, "API call");
logger.info({ customerId: customer.id }, "Customer created");

Automated Redaction

Build it into your logger so developers can’t accidentally leak secrets:

const SENSITIVE_KEYS = new Set([
  "password", "token", "secret", "apiKey", "authorization",
  "ssn", "creditCard", "cardNumber", "cvv",
]);

function redact(obj: Record<string, unknown>): Record<string, unknown> {
  const clean: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(obj)) {
    if (SENSITIVE_KEYS.has(key.toLowerCase())) {
      clean[key] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      clean[key] = redact(value as Record<string, unknown>);
    } else {
      clean[key] = value;
    }
  }
  return clean;
}

Don’t Log PII in Plain Text

GDPR, CCPA, and similar regulations apply to logs too. If your logs contain emails, phone numbers, or IP addresses, they’re subject to data retention and deletion requirements.

// ❌ Full PII in logs
logger.info({ email: "[email protected]", ip: "192.168.1.100" }, "Login");

// ✅ Hash or mask PII
import { createHash } from "crypto";
const hashPii = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 12);

logger.info({ emailHash: hashPii("[email protected]"), ipPrefix: "192.168.x.x" }, "Login");

Don’t Log Request/Response Bodies in Production

// ❌ In production, this dumps megabytes of data per request
logger.debug({ requestBody: req.body, responseBody: result }, "API call");

// ✅ Log summaries instead
logger.debug({
  method: req.method,
  path: req.path,
  bodySize: JSON.stringify(req.body).length,
  responseStatus: res.statusCode,
  durationMs: Date.now() - startTime,
}, "API call completed");

Logging Anti-Patterns

Log and Throw

// ❌ Double logging — the error gets logged here AND wherever it's caught
try {
  await processPayment(order);
} catch (error) {
  logger.error({ error }, "Payment failed"); // Logged here
  throw error; // And logged again by the global error handler
}

// ✅ Choose one: log it or throw it, not both
try {
  await processPayment(order);
} catch (error) {
  // Add context, then re-throw. Let the top-level handler log it.
  throw new PaymentError(`Payment failed for order ${order.id}`, { cause: error });
}

Logging in a Loop

// ❌ 10,000 log lines for processing 10,000 items
for (const item of items) {
  logger.info({ itemId: item.id }, "Processing item");
  await process(item);
  logger.info({ itemId: item.id }, "Item processed");
}

// ✅ Log the batch, not each item
logger.info({ itemCount: items.length }, "Starting batch processing");
const results = await Promise.allSettled(items.map(process));
const failures = results.filter(r => r.status === "rejected");
logger.info({ total: items.length, succeeded: items.length - failures.length, failed: failures.length }, "Batch processing completed");

A Practical Logging Strategy

  1. Use structured logging from day one — retrofitting is painful
  2. Set production log level to info — enable debug per-module when investigating
  3. Include correlation IDs in every request — trace requests across services
  4. Log business events at info — user actions, state transitions, important outcomes
  5. Log errors with full context — stack trace, input data (redacted), and what was attempted
  6. Automate secret redaction — don’t rely on developers remembering
  7. Set up log aggregation — ELK, Datadog, Grafana Loki, or similar

Your logs are your forensic evidence. When production breaks at 3 AM, they’re the only witness to what happened. Make them structured, searchable, and complete — but not a firehose of noise. The best log is one that tells you exactly what went wrong, who was affected, and where to look, in under 60 seconds.