English · Español
03 — Tracing y logging estructurado¶
🇪🇸 Métricas resumen, trazas explican. Una métrica te dice "el p95 subió"; una traza te dice "esta petición concreta esperó 4 segundos en la cola del batcher porque el slot del KV cache estaba lleno". Los logs estructurados son el pegamento —
trace_idune todo.
Las métricas agregan; no apuntan con precisión. Cuando la latencia p95 sube, las métricas te dicen que subió; no te dicen por qué esta petición concreta fue lenta. Ese es el trabajo del tracing.
Una traza (trace) es un árbol de spans causalmente enlazados, donde cada span representa una unidad de trabajo con un tiempo de inicio, una duración y atributos clave-valor. El árbol de la traza muestra cómo una petición lógica fluyó por las etapas del servicio.
Anatomía de un span¶
Un span es una 4-tupla:
{
"trace_id": "<128-bit hex>", # mismo en todos los spans de la traza
"span_id": "<64-bit hex>", # único para este span
"parent_span_id": "<64-bit hex>", # el span que causó este
"name": "decode_step",
"start_unix_nano": 1716_xxx_xxx_xxx,
"duration_nano": 234_000_000,
"attributes": {"llm.model": "minigpt-v1", "llm.tokens.in": 128, "llm.tokens.out": 7},
"events": [{...}], # eventos discretos dentro del span
"status": {"code": "OK"},
}
OpenTelemetry (OTel) define este esquema; el SDK de Python proporciona tracer.start_as_current_span(...) para emitir uno.
El árbol de traza para una petición LLM¶
Una jerarquía de spans razonable para una petición de la Fase 33:
http.handler (raíz)
├── auth (10 ms)
├── tokenize.input (3 ms; attrs: tokens.in=128)
├── rag.retrieve (45 ms; attrs: docs.retrieved=3)
│ ├── embed.query (15 ms)
│ ├── vector.search (8 ms; attrs: top_k=10)
│ └── rerank (20 ms)
├── llm.prefill (180 ms; attrs: tokens.in=128, cache_misses=2)
├── llm.decode (2400 ms)
│ ├── decode.step (10 ms; un span por paso O muestreado)
│ ├── decode.step (10 ms)
│ └── ...
└── format.output (5 ms)
Este es aproximadamente el árbol de spans que construye el tracing.py de la Fase 34.
Advertencia práctica: los spans por paso explotan. Una generación de 240 tokens = 240 spans decode.step = abotargamiento. Dos opciones:
- Muestrear. Emitir spans
decode.steppara el 1% de los decodes. El span padre agregadollm.decodeaún captura la duración total. - Agregar. Emitir un único span
llm.decodeconattributes={"steps": 240, "mean_step_ms": 10.0, "max_step_ms": 18.2}.
Usamos (2) por defecto; (1) es la extensión opcional del laboratorio.
Propagación de contexto¶
La parte difícil de OTel en Python es asegurarse de que el trace_id y el span_id padre se propagan correctamente a través de:
- fronteras async (
await), - creación de tareas (
asyncio.create_task), - fronteras de hilos (
run_in_executor), - invocaciones de subprocesos (no en la Fase 34 — eso es Fase 31 en adelante),
- llamadas HTTP (la instrumentación HTTP de OTel se encarga de esto para clientes/servidores).
El start_as_current_span por defecto usa los contextvars de Python, que se propagan a través de await correctamente pero no a través de asyncio.create_task a menos que lo envuelvas. El servidor de la Fase 33 usa asyncio; el laboratorio fuerza a Borja a pensar en un caso explícito (el EventSourceResponse streaming para SSE) donde la propagación de contexto se rompe si no se corrige.
Diagnóstico: spans huérfanos (spans cuyo parent_span_id no está puesto, pero cuyo nombre implica que deberían ser hijos) son el síntoma. Tempo/Jaeger los renderiza como raíces flotantes; deberían ser cero en un setup correcto.
Cardinalidad y muestreo para trazas¶
A diferencia de las métricas, las trazas sí pueden permitirse etiquetas por petición — el propio trace_id es la etiqueta por petición, y el backend de almacenamiento (Tempo) las indexa eficientemente. Pero aún así no quieres volumen de trazas infinito.
Estrategia de muestreo: para el volumen del currículo (tests de carga de la Fase 34 ≤100 RPS durante 5 minutos = ~30k trazas), guarda el 100%. En producción, el muestreo "head-based" (decidir en el span raíz si conservar) es el por defecto; "tail-based" (decidir después de ver la traza entera) es la variante astuta pero cara.
Para referencia adelantada a la Fase 37: el muestreo tail-based te deja conservar el 100% de las trazas fallidas o anómalas mientras tiras la mayoría de las exitosas. Así es como preservas las trazas de ataques de prompt injection para análisis forense sin reventar el almacén de trazas.
Logging estructurado¶
Los logs no son strings; son eventos con payloads clave-valor, serializados como JSON, una línea por evento.
structlog es la librería. El cableado:
import structlog, logging
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context, # custom: extrae trace_id/span_id de OTel
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
],
)
log = structlog.get_logger()
log.info("request.received", endpoint="/v1/completions", model="minigpt-v1")
# → {"timestamp": "2026-05-22T...", "level": "info", "trace_id": "...", "span_id": "...", "event": "request.received", "endpoint": "/v1/completions", "model": "minigpt-v1"}
Cada línea de log lleva trace_id y span_id. Esa es la magia que te deja ir de un dashboard de Grafana (un pico de métrica a las 14:32) a la traza afectada (filtra Tempo por service.name="miniserve" en el rango temporal) a las líneas de log subyacentes para esa traza.
Qué loguear, qué no loguear¶
Loguea:
- Cada petición recibida (event=
request.received, con method, endpoint, model, recuento de tokens del prompt). - Cada error (event=
request.failed, con clase de error, etapa, recuento de tokens del prompt). - Cambios de estado significativos (event=
batcher.slot.evicted, event=kv_cache.full).
NO loguees:
- El cuerpo del prompt (PII; sensible; grande). Va en el atributo de span de traza bajo un feature flag — nunca en la métrica, nunca en el log.
- El cuerpo del completion. Igual.
- Eventos por token. Demasiado ruidosos.
- Pings de health-check. Filtra en la capa del access-log.
El laboratorio hace que Borja escriba un paso log_redactor que limpia cualquier cosa que machee prompt, completion, messages, text de los payloads de log. Default-deny.
La tríada: métricas + trazas + logs unidos¶
Las tres señales están unidas por trace_id (y solo por trace_id). Una vez que lo tienes:
- Haz clic en un pico de métrica en Grafana → consulta Tempo para trazas en esa ventana temporal → escoge una traza → ve el árbol de spans → haz clic en un span → consulta Loki/logs estructurados para líneas de log con ese
trace_id.
La cadena es una única herramienta (Grafana) con tres fuentes de datos. El infra/compose/observability.yml de la Fase 34 levanta las tres localmente.
Langfuse: cuándo y por qué¶
Langfuse es una UI de tracing específica para LLM que se puede auto-alojar. Se solapa con Tempo + Loki pero añade:
- Renderizado nativo de prompts y completions lado a lado.
- Metadatos a nivel de token como concepto de primera clase.
- Scoring de eval integrado (LLM-as-judge) anclado a trazas.
Para la Fase 34, Langfuse es opcional. La pila OTel + Tempo + Loki es suficiente para el DoD. Langfuse es la herramienta adecuada si la Fase 37 (prompt injection) quiere usar el almacén de trazas como fixture de test de regresión — y el laboratorio de la Fase 37 sí planea usarlo así. Punto de decisión: añadir Langfuse al fichero compose en la Fase 34 o en la Fase 37. Por defecto: posponerlo a la Fase 37.
Un ejemplo trabajado: tracing un bug real¶
Supón que los usuarios se quejan de que una de cada cincuenta completions está vacía (cero tokens). Investigación solo con métricas:
rate(http_requests_total{status="200"}[5m])confirma que la mayoría son 200.histogram_quantile(0.5, generation_length_tokens_bucket)es normal.- Pero
histogram_quantile(0.01, generation_length_tokens_bucket)muestra un pico en 0 tokens. Así que el bug es real, y está en la cola izquierda.
Sin trazas, no tienes forma de encontrar una petición mala representativa. Con trazas:
- En Tempo, busca spans donde
llm.tokens.out = 0. Sacas 30 trazas. - Escoge una. Ves: el span
llm.decodeterminó tras 5 ms — demasiado rápido con diferencia. - Mira los atributos del span:
stop_reason="immediate_eos". - Filtra logs por el
trace_idde esa traza. Ves: eventokv_cache.full evictionen medio del prefill, que corrompió el estado de la cache para ese slot del batch. - Causa raíz encontrada en 5 minutos. Sin tracing, esto es una investigación de varios días.
Este tipo de investigación es la razón entera por la que existe el tracing. Asegúrate de que Borja experimenta al menos una de ellas en el laboratorio.
Recapitulación en un párrafo¶
El tracing construye un árbol de spans causal por petición vía OpenTelemetry; el logging estructurado emite eventos JSON indexados por trace_id/span_id; los dos se unen vía ese id, de modo que pico de métrica → trazas relevantes → líneas de log relevantes es un único flujo de trabajo. La propagación de contexto en Python requiere cuidado a través de asyncio.create_task y de fronteras de hilo; los spans huérfanos son el diagnóstico. No loguees prompts ni completions (PII + abotargamiento); guarda esos en atributos de span de traza bajo un feature flag. Langfuse es opcional — pospónlo a la Fase 37 a menos que la UI específica de LLM se necesite antes.
Hecha la teoría. Pasa a lab/00-prom-grafana-up.md.