Structured logging, OpenTelemetry distributed tracing, metrics (RED method), health checks, and error tracking with Sentry. Use for structured logs, correlation IDs, PII redaction, OpenTelemetry setup, distributed tracing, metrics counters/histograms/gauges, SLO-based alerting, liveness/readiness probes, or Sentry integration. Dual Python/TypeScript examples throughout.
Install
npx skillscat add saajunaid/caddis-plugin/observability Install via the SkillsCat registry.
Observability Skill
Everything needed to understand what your system is doing in production: structured logging, distributed tracing, metrics, and error tracking.
1. Structured Logging
Core Principles
- Emit JSON that machines can parse, not prose that humans must parse
- Every log line:
timestamp,level,message,correlation_id,service - Never log PII — redact before logging
- Use log levels semantically (DEBUG/INFO/WARNING/ERROR — no
print())
Python — structlog or loguru
# structlog (recommended for services)
import structlog
import uuid
logger = structlog.get_logger()
# Configure at app startup
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
# Bind context for a request/task
structlog.contextvars.bind_contextvars(
correlation_id=str(uuid.uuid4()),
user_id=user.id, # Safe — internal ID, not PII
service="user-service",
)
logger.info("user.login", method="oauth2", provider="google")
logger.warning("rate.limit.approaching", remaining=5, limit=100)
logger.error("db.query.failed", query="users.find", error=str(e))# loguru (recommended for simpler scripts/notebooks)
from loguru import logger
import sys
logger.remove() # Remove default stderr handler
logger.add(
sys.stderr,
format="{time:ISO8601} {level} {message}",
serialize=True, # JSON output
level="INFO",
)
logger.add("logs/app.log", rotation="100 MB", retention="7 days", serialize=True)TypeScript — pino
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: { service: 'web-api', env: process.env.NODE_ENV },
})
// Child logger with bound context
export function requestLogger(correlationId: string) {
return logger.child({ correlationId })
}PII Redaction
# Python — never log these fields
REDACT_KEYS = {'password', 'token', 'secret', 'credit_card', 'ssn', 'dob', 'email', 'phone'}
def safe_log(data: dict) -> dict:
return {k: '***' if k in REDACT_KEYS else v for k, v in data.items()}// pino built-in redaction
const logger = pino({ redact: ['password', 'authorization', 'cookie', '*.token', '*.secret'] })2. Correlation IDs
Every entry point generates a correlation_id and attaches it to all downstream calls.
# FastAPI middleware
import uuid
from fastapi import Request
import structlog
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response// Next.js middleware
import { NextResponse } from 'next/server'
import { randomUUID } from 'node:crypto'
export function middleware(request: Request) {
const correlationId = request.headers.get('x-correlation-id') ?? randomUUID()
const response = NextResponse.next()
response.headers.set('x-correlation-id', correlationId)
return response
}3. OpenTelemetry — Distributed Tracing
Python Setup
# pip install opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
# Configure tracer
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317")))
trace.set_tracer_provider(provider)
# Auto-instrument FastAPI
FastAPIInstrumentor.instrument_app(app)
# Manual span creation
tracer = trace.get_tracer(__name__)
async def process_order(order_id: str):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", order_id)
span.set_attribute("order.source", "api")
try:
result = await db.fetch_order(order_id)
span.set_attribute("order.status", result.status)
return result
except Exception as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
raiseTypeScript Setup
// npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http
// instrumentation.ts (Next.js) or tracing.ts (Node)
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
const sdk = new NodeSDK({
serviceName: process.env.OTEL_SERVICE_NAME ?? 'my-service',
traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()Span Naming Convention
- Format:
{noun}.{verb}— e.g.,order.process,user.authenticate,db.query - Include resource IDs as span attributes, not in the span name
- Group spans by service domain:
payment.*,inventory.*
4. Metrics — RED Method
Rate, Error rate, Duration — the minimal viable signal for every service endpoint.
# Python — prometheus_client (or OpenTelemetry Metrics)
from prometheus_client import Counter, Histogram, Gauge, start_http_server
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status_code'],
)
REQUEST_DURATION = Histogram(
'http_request_duration_seconds',
'HTTP request duration',
['method', 'endpoint'],
buckets=[0.005, 0.01, 0.05, 0.1, 0.5, 1, 2.5, 5],
)
ACTIVE_CONNECTIONS = Gauge('active_connections', 'Active connections')
# FastAPI middleware
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
import time
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
REQUEST_COUNT.labels(
method=request.method,
endpoint=request.url.path,
status_code=response.status_code,
).inc()
REQUEST_DURATION.labels(
method=request.method,
endpoint=request.url.path,
).observe(duration)
return responseMetric Types
| Type | Use For | Examples |
|---|---|---|
| Counter | Things that only go up | Requests served, errors, events processed |
| Histogram | Distribution of values | Request duration, payload size, DB query time |
| Gauge | Values that go up and down | Active connections, queue depth, memory usage |
5. Health Checks — Liveness vs Readiness
# FastAPI health endpoints
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
@app.get("/health/live")
async def liveness():
"""I am alive and not in a broken state. Restart me if this fails."""
return {"status": "ok"}
@app.get("/health/ready")
async def readiness():
"""I am ready to serve traffic. Remove from load balancer if this fails."""
checks = {}
# Check DB connection
try:
await db.execute("SELECT 1")
checks["database"] = "ok"
except Exception as e:
checks["database"] = f"error: {e}"
# Check cache
try:
await redis.ping()
checks["cache"] = "ok"
except Exception as e:
checks["cache"] = f"error: {e}"
all_ok = all(v == "ok" for v in checks.values())
return JSONResponse(
status_code=status.HTTP_200_OK if all_ok else status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "ready" if all_ok else "degraded", "checks": checks},
)| Probe | When Fails | Action |
|---|---|---|
| Liveness | App is deadlocked or crashed | Kubernetes/platform restarts the pod |
| Readiness | App can't handle traffic (DB down, warming up) | Remove from load balancer pool, don't restart |
6. Error Tracking — Sentry
# Python FastAPI
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
environment=settings.APP_ENV,
traces_sample_rate=0.1, # 10% of transactions traced
profiles_sample_rate=0.1,
integrations=[FastApiIntegration(), SqlalchemyIntegration()],
before_send=scrub_sensitive_data, # Always scrub PII
)
def scrub_sensitive_data(event, hint):
"""Remove PII from Sentry events before sending."""
if 'request' in event:
event['request'].pop('cookies', None)
if 'headers' in event['request']:
event['request']['headers'].pop('Authorization', None)
return event// TypeScript Next.js — sentry.server.config.ts / sentry.client.config.ts
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
beforeSend(event) {
// Strip auth tokens from breadcrumbs
event.breadcrumbs?.values?.forEach(b => {
if (b.data?.['Authorization']) delete b.data['Authorization']
})
return event
},
})Sentry Grouping — Good Fingerprinting
with sentry_sdk.push_scope() as scope:
scope.set_tag("order_id", order_id)
scope.set_context("order", {"status": order.status, "items": len(order.items)})
scope.fingerprint = ["order-processing-failure", order.error_type]
sentry_sdk.capture_exception(e)7. SLO-Based Alerting
# Example Prometheus alerting rule
groups:
- name: slo
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m])) > 0.01
for: 5m
labels: { severity: warning }
annotations:
summary: "Error rate above 1% SLO for 5 minutes"
- alert: SlowResponseTime
expr: |
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1.0
for: 10m
labels: { severity: warning }
annotations:
summary: "P95 response time above 1s SLO"Burn Rate: Alert when error budget burns faster than expected, not just when a threshold is crossed momentarily. for: 5m prevents alert storms.
8. Checklist
- Structured JSON logs (not
print()) - Correlation IDs propagated as request header + bound to logger context
- PII fields redacted before logging (
password,token, email, phone) - OpenTelemetry SDK initialized before app starts
- Custom spans wrap business operations (not just framework auto-instrumentation)
- Span naming uses
{noun}.{verb}convention - RED metrics (rate, error rate, duration) instrumented for all HTTP endpoints
-
/health/liveand/health/readyendpoints implemented with different semantics - Sentry DSN loaded from environment, not hardcoded
- Sentry
before_sendscrubs PII before sending - Alerts fire on SLO burn rate, not just raw thresholds