Atlas

Observability: logging, metrics, tracing

Cómo entender el estado interno de un sistema en producción desde sus outputs externos —logs, métricas y traces— sin tener que shipear código nuevo para responder una pregunta. Enfoque senior: qué monitorear para garantizar cada atributo de calidad, cómo trazar un request entre servicios y cómo configurar rotación/alerting sin ahogarse en ruido.

Teoría

Monitoring vs observability: por qué la distinción importa

Monitoring responde preguntas que definiste de antemano: dashboards, umbrales, alertas sobre métricas conocidas. Te dice “algo se cayó”. Observability es una propiedad del sistema: la capacidad de inferir su estado interno desde sus salidas externas, incluso para preguntas que no anticipaste, sin desplegar código nuevo.

El test honesto de observabilidad: cuando ocurre un incidente que nunca viste antes, ¿podés responder “¿por qué pasó?” solo consultando lo que el sistema ya emite? Si tenés que agregar un print, redeployar y esperar a que se repita, tu sistema no es observable.

El anti-patrón clásico: dashboards en verde (CPU OK, memoria OK, error rate OK) mientras el 12% de las transacciones se pierden silenciosamente. La causa es siempre la misma: se midió infraestructura (cpu_usage) en lugar de negocio (payment_success_rate). CPU es un síntoma; la métrica de negocio te dice si el síntoma importa.

Los 3 pilares y qué pregunta responde cada uno

PilarQué capturaPreguntaCardinalidadCoste
LogsEventos discretos con contexto completo¿Qué pasó exactamente en este request?Alta (cada evento)Alto storage
MetricsValores numéricos agregados en el tiempo¿Cuánto / cuán seguido / qué tan rápido?Baja (series por labels)Bajo, barato
TracesCamino de un request entre servicios¿Dónde se fue el tiempo?Media (sampled)Medio

No son intercambiables, son complementarios y se usan en ese orden durante un incidente:

1. Metric alerta:  P99 latency > 3s en los últimos 5 min
2. Trace localiza: payment-service.call_stripe() span = 5002ms  ← el 92% del tiempo
3. Log explica:    filtrar por trace_id → "Stripe rate limit 429, retry backoff 4800ms"

Root cause: un load test sin gate disparó el rate limit de Stripe.
Time to root cause: < 6 min.

Sin traces, el paso 2 es arqueología manual cruzando timestamps entre 12 log streams. Sin logs estructurados, el trace te lleva al servicio lento pero no al detalle (“¿por qué ese span tardó?”). Sin métricas, te enterás por un usuario y no por un dashboard. La pieza que los une es el trace_id / correlation_id: cada log debe llevarlo, cada métrica debe estar tagueada por servicio+endpoint+status, cada span forma parte de él. OpenTelemetry es el estándar que emite los tres correlacionados desde un solo SDK.

Structured logging: JSON, no texto libre

Un log de texto libre no es queryable sin regex frágiles. Un log estructurado es un objeto JSON con campos consistentes que el aggregator (ELK, Loki, CloudWatch, Datadog) indexa y filtra.

# Malo — texto libre: el collector no puede filtrar por order_id sin regex
logger.info(f"Order {order_id} created by user {user_id} in {elapsed}ms")

# Bueno — structured JSON
import structlog

log = structlog.get_logger()
log.info(
    "order.created",           # el "event" es una key estable, no una frase
    order_id=str(order_id),
    user_id=str(user_id),
    elapsed_ms=elapsed,
    items_count=len(order.items),
)
# Output: {"event": "order.created", "order_id": "abc", "user_id": "42",
#          "elapsed_ms": 23, "timestamp": "...", "level": "info"}

Setup de structlog que produce JSON en producción y salida legible con colores en dev (el mismo pipeline, distinto renderer final):

import logging
import sys
import structlog

def configure_logging(json_logs: bool, level: str = "INFO") -> None:
    shared_processors = [
        structlog.contextvars.merge_contextvars,       # inyecta correlation_id, etc.
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,           # exc_info -> stack trace en un campo
    ]
    renderer = (
        structlog.processors.JSONRenderer() if json_logs
        else structlog.dev.ConsoleRenderer()
    )
    structlog.configure(
        processors=[*shared_processors, renderer],
        wrapper_class=structlog.make_filtering_bound_logger(
            logging.getLevelName(level)
        ),
        logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
        cache_logger_on_first_use=True,
    )

Regla dura: nunca loguear datos sensibles (passwords, tokens, PANs de tarjeta, PII). Se loguean IDs, no valores. En sistemas regulados, un processor de redacción en el pipeline de structlog es defensa en profundidad (filtra por key: password, authorization, card_number).

Correlation / request IDs: el hilo que une todo

Un correlation ID (o request ID) es un identificador —típicamente un UUID— generado en el borde del sistema (por el cliente o por el primer servicio que recibe el request) y propagado en headers a cada servicio downstream, e incluido en cada log de ese request. Sin él, debuggear una falla multi-servicio significa correlacionar timestamps a mano entre streams distintos, quizás 10.000 líneas en una ventana de 2 segundos. Con él, hacés una query: correlation_id = abc123 y ves la cadena completa.

El mecanismo correcto en Python async es contextvars (no thread-locals: async multiplexa muchos requests en un thread; una variable global se pisaría entre corrutinas). structlog.contextvars se apoya justamente en contextvars.ContextVar, que es seguro por tarea:

from uuid import uuid4
import structlog
from starlette.types import ASGIApp, Receive, Scope, Send

class CorrelationIdMiddleware:
    """ASGI puro: no rompe streaming ni background tasks como BaseHTTPMiddleware."""
    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            return await self.app(scope, receive, send)

        headers = dict(scope["headers"])
        incoming = headers.get(b"x-correlation-id")
        correlation_id = incoming.decode() if incoming else str(uuid4())

        structlog.contextvars.clear_contextvars()
        structlog.contextvars.bind_contextvars(correlation_id=correlation_id)

        async def send_with_header(message):
            if message["type"] == "http.response.start":
                message["headers"].append(
                    (b"x-correlation-id", correlation_id.encode())
                )
            await send(message)

        await self.app(scope, receive, send_with_header)

A partir de este bind, todos los logs emitidos durante el request llevan correlation_id sin pasarlo explícitamente. Cuando además usás OpenTelemetry, se suele inyectar el trace_id/span_id del span activo en cada log (processor de OTel para structlog), de modo que desde un log podés saltar al trace y viceversa.

Log levels: cuándo usar cada uno

NivelCuándoAcción esperada
DEBUGDetalle de desarrollo (queries SQL, payloads). Off en prod.Ninguna
INFOEventos de negocio normales (order.created, payment.processed)Ninguna, es el “diario”
WARNINGInesperado pero el sistema sigue (retry, cache miss, degraded mode)Revisar si el patrón crece
ERRORFalló algo que afectó a un usuario (excepción no manejada, pago rechazado)Investigar
CRITICALEl sistema no puede operar (DB caída, disco lleno)Página inmediata

El nivel es un filtro configurable por entorno, no una decisión de código: dev en DEBUG, prod en INFO. Esto es exactamente una de las diferencias entre local y prod deployment (junto con secrets manager, debug mode, environment). Anti-patrón: INFO verboso que loguea cada paso sin valor de ciclo de vida — infla costos de ingestión y esconde las líneas que importan.

Log rotation y configuraciones avanzadas

Los logs a archivo crecen sin límite y llenan el disco (alerta clásica: disk > 85%). La rotación cierra el archivo actual, lo archiva (a veces comprimido) y abre uno nuevo, reteniendo solo N archivos.

En Python hay dos handlers nativos:

import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

# Por tamaño: rota al llegar a 50 MB, conserva 5 backups (app.log.1 ... app.log.5)
size_handler = RotatingFileHandler(
    "app.log", maxBytes=50 * 1024 * 1024, backupCount=5, encoding="utf-8",
)

# Por tiempo: rota a medianoche, conserva 14 días
time_handler = TimedRotatingFileHandler(
    "app.log", when="midnight", interval=1, backupCount=14, utc=True,
)

Trampa senior con múltiples procesos: RotatingFileHandler NO es seguro entre procesos. Con gunicorn corriendo varios workers, cada uno tiene su file descriptor; cuando uno rota (rename + reopen), los demás siguen escribiendo al inode viejo ya renombrado → logs perdidos o corruptos. Opciones correctas:

  1. No rotar en la app. En contenedores, logueá a stdout/stderr y dejá que el runtime rote: Docker (json-file con max-size/max-file, o driver local), Kubernetes (rotación del kubelet), o un sidecar. Es la práctica cloud-native (Twelve-Factor: los logs son un stream de eventos, no responsabilidad de la app).
  2. logrotate externo (VM/bare metal) con la directiva copytruncate o enviando SIGHUP/reopen a la app; combinado con WatchedFileHandler (reabre el archivo si el inode cambió).
  3. concurrent-log-handler (ConcurrentRotatingFileHandler), que usa file locks y es multiproceso-safe si de verdad necesitás rotar en la app.

Regla mental: en prod distribuido, centralizás (los tres pilares van a un backend externo) y la rotación local pasa a ser solo un buffer de emergencia.

Métricas: RED, USE y las Four Golden Signals

Tres frameworks complementarios, cada uno para un tipo de objeto:

La instrumentación con prometheus_client. Prometheus scrapea (modelo pull) un endpoint /metrics; la app registra Counters, Gauges e Histogramas:

import time
from prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST
from starlette.requests import Request
from starlette.responses import Response

# RED a nivel HTTP. Cuidado con la cardinalidad de labels: usa la RUTA (/orders/{id})
# no la URL con el id concreto, o explota el número de series de tiempo.
REQUESTS = Counter(
    "http_requests_total", "Total HTTP requests",
    ["method", "route", "status"],
)
LATENCY = Histogram(
    "http_request_duration_seconds", "Request latency",
    ["method", "route"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)
IN_FLIGHT = Gauge("http_requests_in_flight", "Requests being served right now")

# Business metrics: valen más que las de infra para las decisiones de negocio.
ORDERS = Counter("orders_total", "Orders processed", ["status"])  # success|duplicate|failed
DB_POOL = Gauge("db_pool_connections", "DB pool connections", ["state"])  # active|idle

class MetricsMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        request = Request(scope)
        route = request.scope.get("route")
        # path template, no el path con ids -> baja cardinalidad
        route_label = getattr(route, "path", request.url.path)
        method = request.method
        status_holder = {"code": 500}

        async def send_wrapper(message):
            if message["type"] == "http.response.start":
                status_holder["code"] = message["status"]
            await send(message)

        IN_FLIGHT.inc()
        start = time.perf_counter()
        try:
            await self.app(scope, receive, send_wrapper)
        finally:
            LATENCY.labels(method, route_label).observe(time.perf_counter() - start)
            REQUESTS.labels(method, route_label, status_holder["code"]).inc()
            IN_FLIGHT.dec()

async def metrics_endpoint(request: Request) -> Response:
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
TipoSemánticaEjemplo
CounterMonótono, solo sube (o se resetea a 0 en restart)requests totales, errores totales
GaugeSube y bajaconexiones activas, workers, cola pendiente
HistogramBuckets de conteo → percentiles server-side vía histogram_quantile()latencia, tamaño de payload
SummaryPercentiles calculados en el cliente (no agregables entre instancias)evitá si tenés varias réplicas

Percentiles, no promedios. El promedio de latencia esconde la cola: p50=50ms con p99=4s significa que 1 de cada 100 usuarios sufre 4 segundos. Los SLO se definen sobre percentiles (p99 < 500ms), y para eso el histograma necesita buckets alineados con tu SLO (poné un bucket exactamente en tu umbral). Para agregar percentiles entre réplicas necesitás Histogram (server-side quantiles), no Summary.

Qué monitorear por atributo de calidad

Este es el núcleo del nivel senior: no “agregar Grafana”, sino mapear cada quality attribute (requisito no funcional) a señales concretas y a un nivel de monitoreo.

Atributo de calidadQué monitorearMétrica / señalFramework
Performance / latencyTiempo de respuesta por endpoint y operaciónhttp_request_duration_seconds p95/p99; span duration por servicioRED · traces
Reliability / correctnessFallos que ve el usuarioerror rate (5xx / total), orders_total{status="failed"}RED · Golden
Scalability / saturationRecursos cerca del límite → dolor inminentepool util (db_pool{state="active"} / max), queue depth, CPU run-queue, event-loop lagUSE
Availability¿Está arriba y sirviendo?probes de health, uptime, success ratio del SLIGolden
Capacity / throughputCarga actual vs planificadarequests/s, mensajes/s en la colaRED (Rate)
SecurityAccesos y anomalíasauth failures/s, rate-limit hits por cliente, audit trail (p.ej. CloudTrail)logs · métricas

Niveles de monitoreo (hay que verbalizarlos):

La clave: CPU y RAM casi nunca son accionables por sí solos. orders_failed_total te dice que algo está mal; db_pool{state="active"} acercándose al máximo te dice que viene el agotamiento de conexiones; la saturación (queue depth, event-loop lag) es el mejor predictor temprano de degradación.

Distributed tracing cross-service

Un trace es el árbol de un request a través de uno o más servicios. Cada nodo es un span: una unidad de trabajo (un handler, una query, una llamada externa) con nombre, timestamps de inicio/fin, atributos y un parent. El trace muestra la cascada (waterfall):

Trace: POST /api/v1/checkout   (total 2.30s)   trace_id=4bf92f...
├─ span: order.create            (order-service)      50ms
├─ span: inventory.reserve       (inventory-service) 120ms
├─ span: payment.charge          (payment-service)  1800ms  ← 78% del tiempo
│   └─ span: http POST stripe    (external)         1790ms
└─ span: notify.email            (notification)      330ms

Sin traces sabés que el request tardó 2.3s, pero no si fue la DB, Stripe o la validación. Con traces, el cuello de botella salta a la vista.

Propagación de contexto es lo que hace que el trace cruce servicios. El trace context viaja en un header HTTP estándar W3C traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └── trace-id (32 hex) ──────────┘ └ span-id (16) ┘ └ flags
             version                                              sampled=01

Cuando el servicio A llama al B, inyecta traceparent; B lo extrae y crea sus spans como hijos del span de A → un solo trace continuo. Con OpenTelemetry esto es automático si el cliente HTTP está instrumentado.

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

def configure_tracing(app, service_name: str) -> None:
    provider = TracerProvider(
        resource=Resource.create({"service.name": service_name}),
        # Sampling: 1.0 en dev; en prod, tasa baja (p.ej. 0.05) para acotar coste,
        # respetando la decisión del padre para no cortar traces a la mitad.
        sampler=ParentBasedTraceIdRatio(0.05),
    )
    # BatchSpanProcessor: exporta en background por lotes, no bloquea el request path.
    provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
    )
    trace.set_tracer_provider(provider)

    # Auto-instrumentación: spans para HTTP entrante, cliente httpx (propaga traceparent)
    # y queries SQLAlchemy, sin código manual.
    FastAPIInstrumentor.instrument_app(app)
    HTTPXClientInstrumentor().instrument()
    SQLAlchemyInstrumentor().instrument()

tracer = trace.get_tracer(__name__)

async def check_idempotency(key: str) -> bool:
    # Span manual para lógica de negocio (lo que la auto-instrumentación no ve).
    with tracer.start_as_current_span("idempotency.check") as span:
        span.set_attribute("idempotency.key", key)
        hit = await redis.get(f"idem:{key}") is not None
        span.set_attribute("idempotency.hit", hit)
        return hit

Notas senior: head-based sampling (decidís al iniciar el trace) es lo default y barato pero puede descartar justo el trace del error; tail-based sampling (en el Collector, decide al final) permite quedarte con el 100% de los traces con error o alta latencia a costa de más infra. El OTel Collector como capa intermedia desacopla la app del backend (Jaeger, Tempo, X-Ray, vendor) y hace batching/filtrado/rerouting sin tocar el código.

Alerting: sintomático, no causal; y sin alert fatigue

Alertar por todo garantiza que nadie lea las alertas (alert fatigue). Principios:

# BUENAS alertas (accionables, sintomáticas)
- alert: HighErrorRate
  expr: sum(rate(http_requests_total{status=~"5.."}[5m]))
        / sum(rate(http_requests_total[5m])) > 0.05
  for: 5m
  labels: { severity: page }

- alert: LatencyP99SLOBreach
  expr: histogram_quantile(0.99,
          sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 0.5
  for: 10m
  labels: { severity: page }

- alert: DBPoolNearExhaustion       # saturación = predictor temprano
  expr: db_pool_connections{state="active"} / 20 > 0.9
  for: 5m
  labels: { severity: ticket }

# MALAS alertas (ruido)
#  - cada 404: normal, los clientes piden cosas que no existen
#  - spike de CPU de 30s: probablemente GC o un deploy
#  - un solo request lento: outlier, no patrón

Ejercicios

Ejercicio 1 — De print a structured logging con redacción

Tenés este handler. Convertilo a structlog estructurado, sin loguear el token, y con un campo elapsed_ms.

async def login(email: str, password: str, token: str):
    print(f"Login attempt for {email} with token {token}")
    user = await auth.verify(email, password)
    print(f"Login OK for {email}")
    return user
Solución
import time
import structlog

log = structlog.get_logger()

async def login(email: str, password: str, token: str):
    start = time.perf_counter()
    # No se loguea el token ni el password. Solo el identificador de negocio.
    log.info("auth.login.attempt", email=email)
    try:
        user = await auth.verify(email, password)
    except AuthError:
        log.warning(
            "auth.login.failed", email=email,
            elapsed_ms=round((time.perf_counter() - start) * 1000, 1),
        )
        raise
    log.info(
        "auth.login.ok", user_id=str(user.id), email=email,
        elapsed_ms=round((time.perf_counter() - start) * 1000, 1),
    )
    return user

Claves: event como key estable (auth.login.ok), nunca el token/password, un nivel distinto para el fallo (warning) que para el éxito (info), y elapsed_ms como número para poder hacer histogramas/filtros. En sistemas regulados agregarías un processor global de redacción por si algún campo sensible se cuela.

Ejercicio 2 — Elegir el tipo de métrica correcto

Para cada señal, decidí Counter / Gauge / Histogram y por qué: (a) número de webhooks de pago procesados; (b) conexiones activas en el pool de DB; (c) tamaño en bytes de los payloads recibidos; (d) tasa de aciertos de cache en los últimos 5 minutos.

Solución
  • (a) Counterpayment_webhooks_total{status=...}. Solo crece; la “tasa” se deriva con rate() en Prometheus. Nunca uses Gauge para algo que solo sube: perdés la capacidad de calcular rate y las bajas por restart te confunden.
  • (b) Gaugedb_pool_connections{state="active"}. Sube y baja; querés el valor instantáneo para compararlo con el máximo (saturación).
  • (c) Histogramrequest_payload_bytes_bucket. Querés la distribución (p50/p99), no solo el total ni el promedio; los buckets te dan percentiles agregables entre réplicas.
  • (d) Counter (dos)cache_hits_total y cache_lookups_total; el hit ratio es rate(hits[5m]) / rate(lookups[5m]). No lo modeles como Gauge de “ratio actual”: perdés poder de agregación y ventana. La regla: casi todo lo acumulable es Counter; los ratios y percentiles se calculan en la query, no se pre-agregan en la app.

Ejercicio 3 — Diseñar la alerta sobre el SLO, no sobre el síntoma

El equipo tiene una alerta “CPU > 80% por 2 min” que dispara 15 veces al día y ya nadie la mira. El SLO real del servicio es: p99 de latencia < 300ms y error rate < 1%, medido mensual. Rediseñá el alerting.

Solución
  1. Borrar la alerta de CPU como página. Alto CPU sin impacto en latencia/errores es eficiencia, no un incidente. A lo sumo queda como ticket si es sostenido (posible necesidad de escalar), no como page.
  2. Alertar sobre los SLI que definen el SLO:
    • histogram_quantile(0.99, ...) > 0.3 for: 10m → severidad page.
    • error rate > 0.01 for: 5mpage.
  3. Mejor aún: burn rate del error budget. Con SLO 99% y ventana mensual, el budget es 1%. Alerta multiventana: burn rate rápido (consumís el budget de 1h en minutos) → page; burn rate lento (proyectás violar el SLO a fin de mes) → ticket. Esto elimina el ruido de picos transitorios y solo despierta a alguien cuando de verdad vas camino a violar el compromiso.
  4. Saturación como early warning en severidad baja: db_pool active / max > 0.9 como ticket, porque predice agotamiento antes de que se traduzca en errores.

El cambio de mentalidad: se alerta sobre lo que el usuario sufre (síntomas / SLO), no sobre causas internas que pueden o no importar.

Ejercicio 4 — Trazar un request entre dos servicios

order-service llama a payment-service vía httpx. Los traces aparecen como dos traces separados en Jaeger en lugar de uno solo con spans anidados. ¿Qué falta y cómo se arregla?

Solución

Falta la propagación del contexto. Diagnóstico y fix:

  1. El cliente HTTP no está instrumentado, así que no inyecta el header traceparent. Solución: HTTPXClientInstrumentor().instrument() en order-service — a partir de ahí cada request httpx lleva traceparent con el trace_id/span_id del span activo.
  2. payment-service debe extraer ese contexto e iniciar sus spans como hijos. Con FastAPIInstrumentor.instrument_app(app) la extracción es automática (lee traceparent del request entrante).
  3. Verificar que ambos usan el mismo formato de propagación (W3C TraceContext es el default de OTel; si un servicio usa B3/Jaeger legacy y el otro W3C, no se entienden → configurar el mismo propagator).
  4. Cuidado con el sampling: si order-service no samplea el trace (decisión head-based) y usás ParentBased, el hijo respeta esa decisión y tampoco aparece — no es que se rompió la propagación, es que no se muestreó. Para no perder errores, tail-based sampling en el Collector.

Resultado: un único trace donde el span de payment.charge es hijo del handler de order-service, y la cascada muestra cuánto tiempo real se fue en la llamada externa.

Ejercicio 5 — Rotación de logs con gunicorn multi-worker

Tu app corre con gunicorn -w 4 y configuraste RotatingFileHandler(maxBytes=50MB, backupCount=5). En producción notás líneas de log perdidas y un app.log que a veces “reaparece” con contenido viejo. Explicá la causa y da dos soluciones production-grade.

Solución

Causa: RotatingFileHandler no es multiproceso-safe. Los 4 workers tienen file descriptors independientes al mismo archivo. Cuando el worker A alcanza 50MB, hace rename(app.log → app.log.1) y abre un app.log nuevo. Pero los workers B, C, D siguen escribiendo a su descriptor, que ahora apunta al inode renombrado (app.log.1) — o pisan el rename. Resultado: escrituras entrelazadas, líneas perdidas y archivos “resucitando”.

Soluciones:

  1. No rotar en la app (cloud-native / preferida). Loguear a stdout y delegar en el runtime: en Docker, driver json-file con max-size=50m, max-file=5 (o driver local); en Kubernetes, la rotación del kubelet + un collector (Fluent Bit/Vector) que centraliza en Loki/ELK. La app no gestiona archivos; los logs son un stream (Twelve-Factor). Esto además unifica los logs de todos los workers.
  2. Rotación externa con logrotate usando copytruncate (trunca el archivo en su lugar sin cambiar el inode, así los descriptors siguen válidos — con una pequeña ventana de carrera aceptable), o enviando SIGHUP a la app combinado con WatchedFileHandler que reabre el archivo si detecta cambio de inode.
  3. (Si de verdad hay que rotar en la app) concurrent-log-handler (ConcurrentRotatingFileHandler), que serializa con file locks entre procesos.

La lección senior: en producción distribuida no querés que cada proceso pelee por un archivo; centralizás los tres pilares en un backend y la rotación local es a lo sumo un buffer.

Preguntas tipo entrevista (EN)

Q1 — What’s the difference between monitoring and observability?

Q2 — A customer reports their order failed. How do you investigate in production?

Q3 — Which metrics would you expose for an orders API, and why not just CPU and memory?

Q4 — What is a correlation ID and why is a service without one hard to debug?

Q5 — How does a trace follow a request across service boundaries?

Q6 — How do you handle log rotation for a multi-worker Python web app?

Q7 — Your alert channel fires 200 times a day and the team ignores it. How do you fix alerting?

Referencias