20.5. Error Logging
Table of Contents
Why Error Logging Matters
When something goes wrong in your backend, users usually see only a generic error message. You see nothing, unless you log it.
Error logging is the process of recording information about problems that happen in your application. Without it, you are effectively blind in production. With it, you can:
- Detect bugs and failures quickly.
- Reproduce and fix issues that are hard to trigger locally.
- Measure how stable your system is.
- Prove that an incident is resolved because related errors stop appearing.
Important: A backend in production must always have structured, centralized error logging. Relying on print statements or local log files is not enough.
This chapter focuses on logging errors specifically, not general application logging or metrics, which are covered elsewhere.
What Counts as an Error?
Not everything that looks bad is an error, and not every error is equally important. It helps to classify problems so you can react correctly.
Types of errors
Some common categories:
| Category | Example | Should you log? |
|---|---|---|
| Programming bugs | ZeroDivisionError, KeyError, AttributeError | Always |
| Data validation | User sends invalid email or missing required field | Usually, at lower level |
| External failures | Database timeout, Redis connection error, email server | Always |
| Client misuse | 404 on random path, invalid API key | Often, but not as errors |
| Security-related | Many failed logins, suspicious inputs, SQLi attempts | Yes, and monitor closely |
Many frameworks attach a log level to each log entry:
DEBUGfor internal details.INFOfor normal operations.WARNINGfor something odd but not critical.ERRORfor a failure in the current operation.CRITICALorFATALfor complete breakdowns.
For error logging, you mostly work with ERROR and CRITICAL.
Rule: Use ERROR when a request or job fails but the system still runs. Use CRITICAL only when the whole application or a key component is unusable.
Logging Exceptions Correctly
Errors in backend code often appear as exceptions. It is not enough to log "Error happened". You need:
- The exception type, message, and full stack trace.
- Context like which request caused it.
Example: naive vs proper exception logging
Imagine a route that fails:
def divide(a, b):
return a / b
try:
result = divide(1, 0)
except Exception:
print("Something went wrong")This tells you nothing. A better version:
import logging
logger = logging.getLogger(__name__)
try:
result = divide(1, 0)
except Exception:
logger.exception("Error while dividing numbers")
logger.exception:
- Automatically logs at
ERRORlevel. - Captures the full stack trace.
- Records the exception type and message.
Equivalent pattern if you already have the exception object:
except Exception as exc:
logger.error("Error while dividing numbers", exc_info=exc)In a web framework, you often do this in middleware or error handlers instead of every route.
Example: logging errors in a request handler
Pseudo code for a generic HTTP handler:
def handle_request(request):
try:
return process_request(request)
except Exception:
logger.exception("Unhandled error in request handler")
return make_500_response()This pattern:
- Logs all unhandled exceptions with full details.
- Returns a safe HTTP 500 response without leaking internals.
Including Context in Error Logs
A stack trace is helpful, but context turns a log into a real debugging tool.
What context should you include?
For a typical web request, useful fields are:
- Request path, method, and query.
- Correlation or request ID.
- Authenticated user ID, if available.
- Client IP (carefully, see privacy).
- Relevant parameters, but never sensitive data.
Example of a structured context dictionary:
context = {
"path": request.path,
"method": request.method,
"query": dict(request.query_params),
"user_id": request.user.id if request.user else None,
"request_id": request.headers.get("X-Request-ID"),
}Then:
logger.exception("Unhandled error in HTTP request", extra=context)If you use JSON logging, these context keys become fields in the JSON object.
Example: missing context problem
Log without context:
ERROR:root:Database error
Traceback (most recent call last):
...
psycopg2.OperationalError: timeoutYou know the database failed, but not which request. With context:
{
"level": "ERROR",
"msg": "Database error",
"exception": "psycopg2.OperationalError: timeout",
"path": "/orders/checkout",
"method": "POST",
"user_id": 42,
"request_id": "8a1f6b8c-...-e2f1"
}Now you can:
- Filter errors by
pathoruser_id. - Search logs using
request_idthat is also present in access logs.
Separating Client Errors from Server Errors
Not every failed request is a server error. This matters for both HTTP status codes and logging.
Mapping HTTP codes to log levels
A common pattern:
| HTTP status | Meaning | Log level suggestion |
|---|---|---|
| 2xx | Success | Usually no error log |
| 3xx | Redirect | INFO at most |
| 4xx (client error) | Client input or behavior | INFO or WARNING |
| 5xx (server error) | Server failed | ERROR or CRITICAL |
For example:
- User sends invalid data, you return 400 with a clear message. This is normal use, log at
INFOor not at all, or aggregate metrics. - Your database is down and a 500 error is sent. This is a server error worth logging at
ERRORor higher.
Example: logging 4xx vs 5xx
Imagine a validation error:
def create_user(request):
data = request.json()
if "email" not in data:
logger.info("User creation failed, missing email", extra={"path": "/users"})
return json_response({"error": "email is required"}, status=400)
...And a server error:
def create_user(request):
try:
...
db.save(user)
except DatabaseTimeoutError:
logger.exception("Database timeout when creating user")
return json_response({"error": "Internal server error"}, status=500)This keeps your error logs focused on things you must fix on the server side.
Avoiding Sensitive Data in Logs
Error logs often capture request information and data values. That is useful, but it is dangerous if you log sensitive data.
Sensitive data examples:
- Passwords and password hashes.
- Access tokens, refresh tokens, API keys.
- Full credit card numbers and CVV codes.
- Personal identifiable information (PII), such as passport numbers.
- Sensitive health or financial details.
Rule: Never log passwords, tokens, or full payment details. Mask or remove sensitive fields before logging.
Example: masking sensitive fields
Suppose you receive this JSON:
{
"email": "user@example.com",
"password": "secret123",
"credit_card": "4111111111111111"
}Do not log it directly. Instead, sanitize it:
def sanitize_payload(payload: dict) -> dict:
masked = {}
for key, value in payload.items():
if key.lower() in {"password", "token", "api_key"}:
masked[key] = "***"
elif key.lower() in {"credit_card", "card_number"} and isinstance(value, str):
masked[key] = value[:4] + "****" * 3
else:
masked[key] = value
return maskedThen in your error logging:
try:
process_payment(payload)
except Exception:
safe_payload = sanitize_payload(payload)
logger.exception("Payment processing failed", extra={"payload": safe_payload})This keeps logs useful but safe to share with teammates and support staff.
Centralized Error Logging
In a real system, you typically have many instances of your backend running on multiple servers or containers. Looking at local log files is not practical.
Instead, you send error logs to a centralized logging or error tracking system.
What centralized error logging gives you
- A single place to search and filter errors from all instances.
- Grouping of similar errors into issues.
- Dashboards showing error rates over time.
- Alerts when error rates exceed thresholds.
- Ability to add tags like version or environment.
Common patterns:
- Use a logging backend (like Elasticsearch with Kibana, Loki, or a SaaS log service) for all logs.
- Use a dedicated error tracking service (like Sentry, Rollbar, etc.) specifically for exceptions.
Example: attaching environment and version
When logging errors, attach metadata:
logger.exception(
"Unhandled error",
extra={
"env": "production",
"service": "orders-api",
"version": "1.3.5",
}
)In a centralized system, this lets you:
- Filter by environment, for example see only
productionerrors. - See if an error started after version
1.3.5. - Check if a fix in
1.3.6reduced the error count.
Error Aggregation and Deduplication
In production, a single bug can generate thousands of identical error logs. You need ways to handle that volume.
Grouping similar errors
Error tracking tools usually group errors using a fingerprint based on:
- Exception type.
- File and line where it occurred.
- Stack trace.
This means:
- You see "1 issue" with "10,000 events" instead of 10,000 separate issues.
- When you fix the bug, the issue stops receiving new events.
If you build your own system, you can approximate grouping with hashes of messages and key stack frames.
Throttling noisy errors
Sometimes an error explodes, for example if a database is down. You might want to:
- Log the first few errors in full.
- After that, log only summaries, for example "Database down, suppressed 1,000 similar errors in last 60 seconds".
Example idea in code:
from collections import defaultdict
import time
error_counts = defaultdict(lambda: {"count": 0, "last_reset": time.time()})
SUPPRESS_AFTER = 10
RESET_WINDOW = 60
def log_error_key(key: str, message: str):
now = time.time()
state = error_counts[key]
if now - state["last_reset"] > RESET_WINDOW:
state["count"] = 0
state["last_reset"] = now
state["count"] += 1
if state["count"] <= SUPPRESS_AFTER:
logger.error(message)
elif state["count"] == SUPPRESS_AFTER + 1:
logger.error("Suppressed further '%s' errors", key)You usually do not implement this manually for all errors, but the idea is useful for understanding how tooling manages noisy logs.
Error Logging in Background Jobs
Not all errors happen in HTTP requests. Many backends use background workers for tasks like sending emails or processing payments.
Important points:
- Errors in background jobs often do not have an immediate user-facing response.
- You must log them, or the failure can go unnoticed.
- Attach job-specific context, for example job name, task ID, payload details.
Example: logging errors in a worker
Pseudo code for a worker that runs tasks:
def run_task(task_name: str, payload: dict, task_id: str):
try:
TASKS[task_name](payload)
except Exception:
logger.exception(
"Error in background task",
extra={
"task_name": task_name,
"task_id": task_id,
}
)
# You might also mark task as failed in a database or retry itIf the worker is separate from your API process, ensure its logs also go to the centralized logging system.
Connecting Error Logs to Monitoring and Alerts
Error logs are not only for debugging after the fact. They are also a signal that something is wrong right now.
Turning error logs into signals
You can create alerts when:
- The rate of
ERRORlogs increases sharply. - A particular error appears for the first time in production.
- A
CRITICALerror occurs.
Example alert rules:
| Condition | Example alert |
|---|---|
| > 100 ERROR logs in 5 minutes | "High error rate in orders-api in production" |
| New error group appears | "New unhandled exception in checkout endpoint" |
| Any CRITICAL log in production | "Service outage or misconfiguration detected" |
These alerts often integrate with chat tools or incident management systems.
Making Error Logs Developer Friendly
Finally, error logs are for humans. Good error logging makes your future investigation easier.
Good practices
- Use clear messages:
"Failed to send email"is better than"Error occurred". - Include identifiers: order IDs, user IDs, job IDs, but not secrets.
- Avoid logging the same stack trace repeatedly manually: rely on centralized tools to aggregate.
- Follow a consistent pattern: similar format and fields for HTTP, background jobs, and scheduled tasks.
Compare:
Bad:
ERROR:root:Error
Traceback (most recent call last):
...Better:
ERROR:orders.checkout:Failed to charge card
user_id=42 order_id=987 path=/orders/987/charge
Traceback (most recent call last):
...
PaymentGatewayError: Connection timeoutThis tells you immediately:
- Which subsystem failed.
- Which user and order are affected.
- Likely where to start debugging.
Checklist
Use this quick checklist for error logging in a backend:
- Unhandled exceptions are caught and logged with stack traces.
- Logs are structured and include useful context.
- Sensitive data is masked or removed.
- 4xx and 5xx errors are logged with appropriate levels.
- Background jobs and workers log their failures.
- Logs from all instances go to a central system.
- Errors are grouped and not overwhelming.
- Alerts are configured based on error logs.
If you satisfy these points, your backend is much easier to maintain, debug, and operate in production.
Views: 16
KAHIBARO