| name | logging-observability |
| description | Load when adding logging to application code, setting up structured logging, configuring Sentry error tracking, designing log levels, or reviewing code for observability gaps. Also load when debugging production issues where log output is part of the investigation. Keeps logs useful, consistent, and safe (no sensitive data).
|
Logging and Observability
The Goal: Logs as a Debugging Tool, Not a Paper Trail
Logs are useful when you can answer "what was the system doing right before this
failed?" from them. Logs are useless when they're either empty (nothing logged)
or noisy (everything logged). The right level is: log events and state that would
help a future you diagnose a production problem at 2am without a debugger.
Never log PII, passwords, tokens, or full request bodies. A useful log event
is: "what happened, to what resource, with what outcome, in how long."
Structured Logging Setup
Use structured (JSON) logging in production so logs are queryable in whatever
log aggregation tool you use (Datadog, Cloudwatch, Loki, etc.). In development,
use a human-readable format.
import logging
import sys
from app.core.config import settings
def configure_logging() -> None:
"""Call once at application startup, before any loggers are used."""
if settings.ENVIRONMENT == "production":
try:
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
)
except ImportError:
logging.basicConfig(
level=settings.LOG_LEVEL,
format='{"time":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","message":"%(message)s"}',
stream=sys.stdout,
)
else:
logging.basicConfig(
level=settings.LOG_LEVEL,
format="%(asctime)s [%(levelname)8s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout,
)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
Log Levels: Use Them Correctly
logger.debug("Product lookup", extra={"product_id": str(product_id), "found": result is not None})
logger.info("Order created", extra={"order_id": str(order.id), "user_id": str(user_id), "total": str(order.total)})
logger.warning("Snowflake query slow", extra={"query": "daily_events", "duration_ms": duration_ms, "threshold_ms": 5000})
logger.error("Failed to send welcome email", exc_info=True, extra={"user_id": str(user_id)})
logger.critical("Database connection pool exhausted", extra={"pool_size": pool_size})
What to Log: A Practical Pattern
The most useful logs follow the "request lifecycle" pattern: log when an important
operation starts (DEBUG), log when it completes with outcome (INFO or WARNING), log
when it fails (ERROR).
import logging
import time
from uuid import UUID
from app.schemas.order import OrderCreate
logger = logging.getLogger(__name__)
class OrderService:
async def create(self, user_id: UUID, payload: OrderCreate) -> Order:
"""Log the outcome — not every step, but enough to reconstruct what happened."""
start = time.perf_counter()
try:
order = await self._do_create(user_id, payload)
duration_ms = int((time.perf_counter() - start) * 1000)
logger.info(
"order.created",
extra={
"order_id": str(order.id),
"user_id": str(user_id),
"item_count": len(payload.items),
"total": str(order.total),
"duration_ms": duration_ms,
}
)
return order
except InsufficientStockError as e:
logger.warning(
"order.create.insufficient_stock",
extra={"user_id": str(user_id), "product": e.product_name}
)
raise
except Exception:
duration_ms = int((time.perf_counter() - start) * 1000)
logger.error(
"order.create.failed",
exc_info=True,
extra={"user_id": str(user_id), "duration_ms": duration_ms}
)
raise
What NOT to Log
logger.info(f"User login: {username} / {password}")
logger.info(f"Token: {access_token}")
logger.info(f"Request body: {request.body()}")
logger.info(f"User SSN: {user.ssn}")
logger.info(f"Card: {payment.card_number}")
logger.info(f"User: {user}")
logger.info("user.login", extra={"user_id": str(user.id), "ip": request.client.host})
Request ID Tracing
Every request should carry a unique ID through all log messages so you can
correlate all logs from a single request in production.
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import structlog
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
with structlog.contextvars.bound_contextvars(
request_id=request_id,
path=request.url.path,
method=request.method,
):
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
Sentry: Error Tracking in Production
Sentry captures exceptions automatically and gives you stack traces, breadcrumbs,
and user context without additional logging code. Set it up once and it works.
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.core.config import settings
def configure_sentry() -> None:
if not settings.SENTRY_DSN:
return
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
environment=settings.ENVIRONMENT,
release=settings.VERSION,
integrations=[
FastApiIntegration(transaction_style="endpoint"),
SqlalchemyIntegration(),
],
traces_sample_rate=0.1,
profiles_sample_rate=0.1,
send_default_pii=False,
before_send=_before_send,
)
def _before_send(event, hint):
"""Scrub sensitive fields before sending to Sentry."""
if "request" in event:
headers = event["request"].get("headers", {})
if "Authorization" in headers:
headers["Authorization"] = "[Filtered]"
return event
async def get_current_user(...) -> User:
user = await ...
sentry_sdk.set_user({"id": str(user.id), "email": user.email})
return user
Metrics to Log for Later Analysis
Beyond error tracking, log these quantitative events so you can spot trends:
if duration_ms > 500:
logger.warning("slow_query", extra={"query": query_name, "duration_ms": duration_ms})
logger.info("snowflake.query", extra={"query": "user_events", "rows": row_count, "duration_ms": duration_ms})
logger.info("job.completed", extra={"job": "process_report", "report_id": report_id, "duration_ms": duration_ms})
logger.error("job.failed", exc_info=True, extra={"job": "process_report", "report_id": report_id, "attempt": attempt})
logger.info("business.order_placed", extra={"order_id": str(order.id), "total": str(order.total), "item_count": count})