Skip to content

Logging Best Practices for Production Systems

Structured logging, log levels, and correlation IDs — the patterns that turn noisy log output into a searchable, actionable observability layer.

3 min read
Structured JSON log output in a terminal showing request correlation IDs

Most production logging falls into two categories: too much noise to be useful, or too little information to debug anything. Good logging is a design decision, not an afterthought. It requires choosing the right format, the right level, and the right context for every log statement.

Structured Logs Over Plain Text

Plain text logs are human-readable in a terminal and useless everywhere else. Log aggregation tools like Datadog, Elastic, and CloudWatch need structured data to filter, search, and alert on.

tstypescript
// ❌ Plain text — impossible to parse or filter at scale
console.log(`User ${userId} placed order ${orderId} for $${total}`);
// "User cust_123 placed order ord_456 for $99.50"
 
// ✅ Structured JSON — every field is searchable
logger.info("Order placed", {
  userId: "cust_123",
  orderId: "ord_456",
  total: 99.5,
  currency: "USD",
  itemCount: 3,
});
// {"level":"info","msg":"Order placed","userId":"cust_123","orderId":"ord_456","total":99.5,"currency":"USD","itemCount":3,"timestamp":"2020-02-19T14:30:00Z"}

With structured logs, you can query "show me all orders over $100 from user cust_123" without regex gymnastics.

Log Levels That Mean Something

Most teams use log levels inconsistently. Define clear semantics and enforce them in code review.

LevelWhen to useExample
errorSomething failed and needs human attentionPayment charge failed, database connection lost
warnSomething unexpected but handledRate limit approaching, deprecated API called
infoSignificant business eventsOrder placed, user signed up, deployment started
debugInternal details for troubleshootingCache hit/miss, query timing, intermediate state
tstypescript
// ❌ Wrong levels — error used for non-errors, info used for noise
logger.error("User not found"); // This is expected behavior, not an error
logger.info(`Cache key: ${key}`); // Debug-level detail flooding production
 
// ✅ Correct levels — each level has clear semantics
logger.warn("User not found", { userId, endpoint: "/api/profile" });
logger.debug("Cache lookup", { key, hit: false, latencyMs: 2 });

In production, set the minimum level to info. Enable debug temporarily when investigating a specific issue. Never run debug permanently — it generates too much volume and costs real money in log storage.

Correlation IDs for Request Tracing

When a single user request touches multiple services, correlation IDs tie the entire chain together.

tstypescript
import { randomUUID } from "crypto";
 
function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"] ?? randomUUID();
 
  // Attach to response for client debugging
  res.setHeader("x-request-id", requestId);
 
  // Attach to request context for downstream logging
  req.requestId = requestId;
 
  next();
}
tstypescript
// Every log in the request lifecycle includes the same correlationId
function createRequestLogger(requestId: string) {
  return {
    info: (msg: string, data?: Record<string, unknown>) =>
      logger.info(msg, { ...data, requestId }),
    warn: (msg: string, data?: Record<string, unknown>) =>
      logger.warn(msg, { ...data, requestId }),
    error: (msg: string, data?: Record<string, unknown>) =>
      logger.error(msg, { ...data, requestId }),
  };
}
 
// In a route handler:
app.post("/api/orders", async (req, res) => {
  const log = createRequestLogger(req.requestId);
 
  log.info("Order creation started", { userId: req.userId });
 
  const order = await createOrder(req.body, log);
  log.info("Order created", { orderId: order.id });
 
  await chargePayment(order, log);
  log.info("Payment charged", { orderId: order.id });
 
  res.json(order);
});

When something fails, search by requestId to see the complete request timeline across all services.

What to Log (and What Not To)

Always log

  • Request start and end (with duration)
  • Business events (order placed, payment processed, user action)
  • Errors and exceptions with full stack traces
  • External API calls (with response time and status)

Never log

  • Passwords, tokens, API keys, or secrets
  • Full credit card numbers or SSNs
  • Request bodies containing sensitive user data
  • Health check requests (they drown out real traffic)
tstypescript
// ❌ Logging sensitive data
logger.info("User login", { email, password: req.body.password });
 
// ✅ Redact sensitive fields
logger.info("User login", { email, passwordProvided: !!req.body.password });

Error Logging with Context

An error log without context is almost useless. Include everything needed to reproduce the issue.

tstypescript
// ❌ Bare error message — who was affected? What were the inputs?
logger.error("Payment failed");
 
// ✅ Full context — actionable without opening a debugger
try {
  await paymentGateway.charge(amount, paymentMethodId);
} catch (error) {
  logger.error("Payment charge failed", {
    userId,
    orderId,
    amount,
    paymentMethodId,
    gateway: "stripe",
    errorCode: error.code,
    errorMessage: error.message,
    stack: error.stack,
  });
  throw error;
}

The goal is that someone reading the log at 3 AM can understand what happened, who was affected, and what to investigate — without deploying new logging.

Performance Logging

Log slow operations proactively. Don't wait for users to report latency.

tstypescript
function withTiming<T>(
  name: string,
  fn: () => Promise<T>,
  log: Logger,
  thresholdMs = 1000,
): Promise<T> {
  const start = performance.now();
 
  return fn().finally(() => {
    const durationMs = Math.round(performance.now() - start);
 
    if (durationMs > thresholdMs) {
      log.warn("Slow operation detected", { operation: name, durationMs });
    } else {
      log.debug("Operation completed", { operation: name, durationMs });
    }
  });
}
 
// Usage
const users = await withTiming("getActiveUsers", () => getActiveUsers(), log);

This pattern surfaces performance regressions before they become incidents.

Key Takeaways

  1. Use structured JSON logs — plain text is unqueryable at scale
  2. Define log levels consistently — error means "needs human attention," not "something unexpected happened"
  3. Correlation IDs tie an entire request lifecycle together across services
  4. Never log sensitive data — passwords, tokens, and PII are security liabilities in log stores
  5. Include full context in error logs — make 3 AM debugging possible without code changes
  6. Log slow operations proactively — performance thresholds catch regressions early
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX