Logging is how applications speak to us about what they’re doing. When I write code, I’m building something that will run on its own. I won’t be there watching it. Logging creates a transcript of events, a record of decisions made and paths taken. It’s not just for when things break. It’s a continuous story about the system’s health and behavior.
Think of it like the dashboard in a car. You don’t just look at it when the engine light comes on. You glance at the speedometer and fuel gauge constantly. Good logging provides that kind of ongoing, at-a-glance health check for your software.
Starting Simple: The Basics of Output
Every programming language has a simple way to print text. In Python, it’s print(). In JavaScript, it’s console.log(). This is where everyone begins.
print("Starting the user registration process...")
user = create_user("Alice")
print(f"User created with ID: {user.id}")
print("Sending welcome email...")
This works, but it quickly becomes messy. The output is just lines of text in a sea of other text. You can’t easily tell how important each message is, where it came from, or filter out the noise. Relying only on this is like trying to fix a complex machine with only a basic hammer.
The First Upgrade: Using Levels of Importance
This is where logging frameworks help. Instead of just printing, you categorize messages by their severity. This is the single most useful habit to develop early.
Most systems use levels like ERROR, WARN, INFO, DEBUG, and sometimes TRACE. Here’s what they mean to me in practice:
- ERROR: Something is broken. A payment failed, a file is missing, a connection died. This needs someone’s attention.
- WARN: Something unexpected happened, but the system dealt with it. A configuration file was missing, so a default was used. An API call was slow but succeeded. It’s not broken, but I should know about it.
- INFO: Normal, healthy operation. “Application started.” “User logged in.” “Report generated.” This tells me the system is doing what it’s supposed to do.
- DEBUG: Detailed information for when I’m actively fixing a problem. The contents of a specific variable, the steps inside a loop, detailed timing.
- TRACE: Extremely fine-grained details, often used to follow the exact flow of execution line by line.
Here’s how it looks in Java:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
// Get a logger for this specific class
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
public void processOrder(Order order) {
// INFO for normal business logic
logger.info("Processing order {} for customer {}", order.getId(), order.getCustomerId());
try {
chargeCustomer(order);
logger.info("Order {} successfully charged.", order.getId());
} catch (PaymentDeclinedException e) {
// ERROR for a real failure
logger.error("Payment for order {} was declined. Customer must update payment method.", order.getId(), e);
throw e;
} catch (NetworkTimeoutException e) {
// WARN for a transient issue we might retry
logger.warn("Temporary network issue charging order {}. Will retry.", order.getId(), e);
scheduleRetry(order);
}
// DEBUG for internal state we don't need in production
logger.debug("Order {} final state: {}", order.getId(), order.getStatus());
}
}
The power here is control. When my application is running in production, I might set the level to INFO. I see the important business events, but not the noisy DEBUG messages. When I’m trying to fix a bug on the test server, I can set the level to DEBUG and see all the internal details. I don’t have to change my code—just a configuration setting.
Telling a Coherent Story: Context is Everything
A log message like “Payment failed” is useless. “Payment failed for order #12345 for customer alice@example.com using card ending in 1234” is useful. Adding context is crucial.
The best way to do this is not by manually stitching strings together, but by using structured logging. Structured logging means each piece of information is a separate, labeled field.
Compare these two approaches in JavaScript:
// The old, messy way (avoid this)
console.log(`User ${userId} from IP ${ipAddress} purchased item ${itemId} for $${amount}. Transaction ID: ${txId}`);
// The structured, better way
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.json(), // Output as JSON!
transports: [new winston.transports.Console()]
});
logger.info('Purchase completed', {
event: 'purchase_success',
user_id: userId,
ip_address: ipAddress,
item_id: itemId,
amount_cents: amount,
transaction_id: txId,
timestamp: new Date().toISOString()
});
The first message is a sentence for a human. The second is data for a machine and a human. I can now search my logs in powerful ways. I can ask my log system: “Show me all purchase_success events where amount_cents > 10000” or “Graph purchase frequency by user_id.” I’ve turned a text stream into a searchable database.
Following a Single Journey: Request Tracing
In modern applications, a single user action (like loading a webpage) can trigger dozens of services. A payment might go from your server, to a gateway, to a fraud check, to a bank. If it fails, which service had the problem?
This is where request IDs or correlation IDs come in. You generate a unique ID at the start of a request and pass it to every service involved. Every log entry from every service includes this ID.
// Go example using context
package main
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
func mainHandler(w http.ResponseWriter, r *http.Request) {
// Create a unique request ID
requestID := uuid.New().String()
// Put it in the request context
ctx := context.WithValue(r.Context(), "request_id", requestID)
// Pass the context to downstream functions
processUserOrder(ctx, userID, orderDetails)
// Include it in the response header for client-side tracing
w.Header().Set("X-Request-ID", requestID)
}
func processUserOrder(ctx context.Context, userID string, order Order) {
// Logger can pull the request_id from context automatically
logger := log.Ctx(ctx)
logger.Info().Msg("Starting order processing")
// This log will automatically include the request_id field
// Output: {"level":"info","request_id":"a1b2c3d4","message":"Starting order processing"}
}
When the payment fails, I can take that request ID from the error message and search my central log system. Instantly, I see every single log entry from every microservice related to that one user’s request. I can see the exact path it took and where it stopped. This turns a needle-in-a-haystack search into a simple lookup.
Handling the Sensitive Stuff
Logs often contain secrets by accident. An API key, a password, a credit card number, a person’s address. Letting this data sit in log files is a major security risk and often illegal.
You must actively filter it out. There are two ways: don’t put it in the log in the first place, or scrub it before it’s stored.
# Python example: Being careful and using a filter
import logging
import re
class RedactingFilter(logging.Filter):
"""A filter that redacts sensitive patterns."""
PATTERNS = {
'api_key': re.compile(r'(?i)(api[_-]?key["\']?\s*[:=]\s*["\']?)([^"\'\s]+)'),
'password': re.compile(r'(?i)(password["\']?\s*[:=]\s*["\']?)([^"\'\s]+)'),
'credit_card': re.compile(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b'),
}
def filter(self, record):
# 'record.msg' contains the log message string
if isinstance(record.msg, str):
for name, pattern in self.PATTERNS.items():
# Replace the sensitive part with [REDACTED]
record.msg = pattern.sub(r'\1[REDACTED]', record.msg)
# Also check the extra arguments for structured logging
if hasattr(record, 'props') and isinstance(record.props, dict):
for key in ['password', 'token', 'creditCard']:
if key in record.props:
record.props[key] = '[REDACTED]'
return True
# Configure the logger
logger = logging.getLogger('myapp')
handler = logging.StreamHandler()
handler.addFilter(RedactingFilter()) # Add the filter to the handler
logger.addHandler(handler)
# Now, even if I mistakenly log a password, it will be redacted.
user_input = {"username": "alice", "password": "SuperSecret123"}
logger.info("User data: %s", user_input) # Output will show password as [REDACTED]
A good rule I follow: never log an entire request or response object from an HTTP call. You almost never need all of it, and it’s almost certain to contain something sensitive. Log only the specific fields you need for diagnosis.
Performance: Don’t Let Logging Slow You Down
Logging seems trivial, but done poorly, it can cripple a fast application. The biggest cost is usually creating the log message itself, especially if you’re building complex strings or converting large objects to text.
A common, costly mistake:
// BAD: This builds the expensive string EVERY time, even if DEBUG logging is off.
logger.debug("Processed user object: " + expensiveToJsonString(user));
// BETTER: This checks the log level before building the string.
if (logger.isDebugEnabled()) {
logger.debug("Processed user object: " + expensiveToJsonString(user));
}
// BEST (with modern loggers): Use parameterized logging. The string template is only built if needed.
logger.debug("Processed user object: {}", expensiveToJsonString(user)); // Still creates the JSON string
logger.debug("Processed user for ID: {}", user.getId()); // This is the ideal
Asynchronous logging is another key technique for high-performance applications. Instead of your application code waiting for the log message to be written to disk or sent over the network, it puts the message into a queue in memory. A separate, background thread handles the actual writing.
# Python using the 'logging' module's QueueHandler
import logging
import logging.handlers
from queue import Queue
# Create a queue
log_queue = Queue(-1) # No limit on size
# Set up a handler that consumes from the queue
queue_handler = logging.handlers.QueueHandler(log_queue)
root_logger = logging.getLogger()
root_logger.addHandler(queue_handler)
# Set up a listener that will take messages from the queue and write them.
# This runs in a separate thread.
listener = logging.handlers.QueueListener(
log_queue,
logging.StreamHandler(), # Or a FileHandler, or HTTP handler
)
listener.start()
# Now, logging calls are non-blocking!
logger = logging.getLogger('myapp')
logger.info("This message goes to the queue instantly, and the background thread writes it out.")
The trade-off with async logging is durability. If your application crashes suddenly, any messages still in the memory queue are lost. For most business logic (INFO level), this is an acceptable risk for the gain in speed. For critical errors, you might want a synchronous, immediate write.
Bringing It All Together: A Practical Example
Let’s imagine I’m building a small web service in Node.js. Here is how I might set up a robust, practical logging system from day one.
// logger.js - Central logging configuration
const winston = require('winston');
const { combine, timestamp, json, errors } = winston.format;
// A format to include the stack trace for error objects
const includeStackTrace = winston.format(info => {
if (info instanceof Error && info.stack) {
info.message = info.stack;
}
return info;
});
const logger = winston.createLogger({
// Set default level from environment variable
level: process.env.LOG_LEVEL || 'info',
format: combine(
errors({ stack: true }), // Makes sure Error objects log their stack trace
timestamp(),
json()
),
transports: [
// In production, log to a file with rotation
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
// If we're not in production, also log to the console in a readable format
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: combine(
winston.format.colorize(),
winston.format.simple() // Simple text format for developers
)
}));
}
// Helper to create a child logger for a specific request
logger.createRequestLogger = (req) => {
const requestId = req.headers['x-request-id'] || require('crypto').randomBytes(8).toString('hex');
return logger.child({
request_id: requestId,
user_agent: req.headers['user-agent'],
path: req.path,
method: req.method
});
};
module.exports = logger;
// app.js - Using the logger in an Express.js route
const express = require('express');
const logger = require('./logger');
const app = express();
app.use((req, res, next) => {
// Attach a request-specific logger to the request object
req.log = logger.createRequestLogger(req);
next();
});
app.post('/api/checkout', async (req, res) => {
const { userId, items } = req.body;
// Use the request logger. All its messages will have the request_id.
req.log.info('Checkout initiated', { user_id: userId, item_count: items.length });
try {
const inventoryCheck = await checkInventory(items);
req.log.debug('Inventory check result', { result: inventoryCheck });
if (!inventoryCheck.allInStock) {
req.log.warn('Checkout failed due to inventory', { out_of_stock: inventoryCheck.outOfStock });
return res.status(400).json({ error: 'Items out of stock' });
}
const charge = await chargeCreditCard(userId, calculateTotal(items));
// Be careful NOT to log the full charge object, which might have sensitive data
req.log.info('Payment processed', {
charge_id: charge.id,
amount: charge.amount,
currency: charge.currency
});
await createOrder(userId, items, charge.id);
req.log.info('Order created successfully');
res.json({ success: true, orderId: charge.id });
} catch (error) {
// This will log the full error stack trace thanks to our formatter
req.log.error('Checkout process failed', error);
res.status(500).json({ error: 'Internal server error' });
}
});
function checkInventory(items) {
// Simulated function
return Promise.resolve({ allInStock: true });
}
This setup gives me:
- Structured JSON logs in production for my log aggregator.
- Readable console output on my local machine.
- Automatic request tracing so I can follow a user’s journey.
- Proper error handling with stack traces.
- Separation of concerns—logging logic is in one place.
The Final Piece: Caring for Your Logs
Creating good logs is only half the job. You need a plan for them.
- Where do they go? In development, the console is fine. In production, they should go to a centralized system like Datadog, Elasticsearch (ELK Stack), AWS CloudWatch, or Grafana Loki. An application’s own server is a terrible place to keep logs.
- How long do you keep them? This depends. Debug logs might be kept for 3 days. Info logs for 30 days. Audit or security-related logs might need to be kept for years for compliance. Set up a retention policy.
- Who looks at them? Developers look at logs when debugging. Operations/SRE teams look at them for system health. Business analysts might look at them for usage patterns. Make sure your log structure serves these different audiences.
Logging feels like a chore at first. But I’ve found that investing time in a solid logging strategy early saves immense time and frustration later. It turns the opaque, running box of your application into something transparent and understandable. When you get a call at 2 a.m. saying the site is down, you won’t be starting from zero. Your logs will be the first place you look, and they’ll have a story to tell you. Make sure it’s a good one.