# Cómo implementar observabilidad LLM con distributed tracing en agentes

Aprendé a implementar observabilidad de LLM con distributed tracing y OpenTelemetry para diagnosticar fallas y bajar costos en tus agentes.

*observabilidad · 2026-09-20 · [mauriciosneira.com](https://mauriciosneira.com/blog/distributed-tracing-agentes-opentelemetry)*

---

Tu agente llama a una API externa para procesar un reembolso y la respuesta tarda el triple o devuelve un formato inválido. Si monitoreás el sistema como un endpoint HTTP común, solo vas a ver un error genérico o un timeout. En este post te explico cómo armar una estrategia de observabilidad de LLM con distributed tracing para encontrar fallas, bajar latencias y auditar costos sin atarte a plataformas cerradas.

Un agente ejecuta un grafo de decisiones probabilísticas con bucles, llamadas a tools, consultas vectoriales y memoria dinámica. Si el flujo falla o alucina y vos solo guardás el input inicial y el output final, no podés aislar el problema. No sabés si falló la búsqueda semántica, si el modelo generó mal los argumentos o si la API externa devolvió un payload roto.

Para operar agentes necesitás distributed tracing granular de inicio a fin.

```mermaid
flowchart TD
    subgraph Trace ["Root Trace: Session Workflow"]
        direction TB
        S1["Span 1: Context Hydration (Postgres / VectorDB)"] --> S2["Span 2: LLM Reasoning (Plan & Tool Selection)"]
        S2 --> S3{"¿Requiere Tool?"}
        S3 -- "Sí" --> S4["Span 3: Tool Execution (Stripe / Internal API)"]
        S4 --> S5["Span 4: Ingest Tool Result into Context"]
        S5 --> S2
        S3 -- "No (Meta alcanzada)" --> S6["Span 5: Final Synthesis & Output Generation"]
    end
```

## Anatomía de un span y propagación de contexto

Para diagnosticar problemas en producción, cada ejecución genera un `trace_id` único que viaja por todo el ciclo de vida del agente. Cada operación emite un span con atributos estandarizados.

Pensá en el flujo de procesar un reembolso (`process_customer_refund`):

```
[Trace: process_customer_refund (14.2s)]
 ├── [Span: hydrate_user_context (120ms)]
 ├── [Span: llm_intent_routing (1.4s)] -> tool_calls: [query_stripe_charges]
 ├── [Span: execute_tool: query_stripe_charges (850ms)]
 ├── [Span: vector_search: refund_policy (340ms)]
 ├── [Span: llm_policy_evaluation (4.1s)] -> tool_calls: [execute_refund]
 ├── [Span: execute_tool: stripe_refund_api (910ms)]
 └── [Span: llm_final_response (6.4s)]
```

Estos son los metadatos necesarios según el tipo de operación:

- **Spans de inferencia (LLM):** `gen_ai.provider.name` (el atributo `gen_ai.system` quedó deprecado en semconv v1.37), `gen_ai.request.model`, temperatura, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read_input_tokens` (caching de contexto efímero/prompt caching para calcular costos) y `gen_ai.response.finish_reasons`. Si usás streaming, medís `time_to_first_token` (TTFT) con la diferencia entre el envío del request y el primer chunk. En llamadas bloqueantes sin streaming, el TTFT equivale a la latencia total del span.
- **Spans de tools:** `gen_ai.tool.name`, argumentos en JSON serializado en `gen_ai.tool.call.arguments`, código de respuesta HTTP/RPC y el payload devuelto al contexto en `gen_ai.tool.call.result`. Ojo: `arguments` y `result` son datos de contenido; muchas librerías los apagan por defecto por privacidad y tenés que activar la captura de forma explícita.
- **Spans de retrieval (RAG / memoria):** Query embebida, top-k, similarity scores, filtros aplicados e IDs de los chunks inyectados.

Si no podés reconstruir el prompt exacto que recibió el LLM en el paso 3 de una secuencia de 5 llamadas, con sus tokens y respuestas intermedias, estás a ciegas.

## Implementación de observabilidad de LLM con OpenTelemetry

Para no quedar atado a un proveedor específico, estructurá la instrumentación con la API estándar de OpenTelemetry (OTel) y las [convenciones semánticas oficiales de GenAI](https://github.com/open-telemetry/semantic-conventions-genai).

Este decorador extrae el contexto W3C entrante, mantiene la jerarquía de la traza y guarda los atributos estándar:

```python
import functools
import json
import time
from typing import Any, Callable, Dict
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator

tracer = trace.get_tracer("agent.engine", "1.0.0")

def instrument_tool_step(tool_name: str):
    def decorator(func: Callable):
        @functools.wraps(func)
        def wrapper(*args, carrier_context: Dict[str, str] = None, **kwargs) -> Any:
            # Propagación de contexto distribuido (W3C standard)
            parent_ctx = TraceContextTextMapPropagator().extract(carrier=carrier_context or {})
            
            with tracer.start_as_current_span(
                f"execute_tool:{tool_name}", 
                context=parent_ctx, 
                kind=trace.SpanKind.INTERNAL
            ) as span:
                # Convenciones semánticas oficiales de OpenTelemetry GenAI
                span.set_attribute("gen_ai.operation.name", "execute_tool")
                span.set_attribute("gen_ai.tool.name", tool_name)
                span.set_attribute("gen_ai.tool.call.arguments", json.dumps(kwargs))
                
                start_time = time.perf_counter()
                try:
                    result = func(*args, **kwargs)
                    duration = time.perf_counter() - start_time
                    
                    span.set_attribute("gen_ai.tool.call.result", json.dumps(result)[:4000]) # Límite de seguridad
                    span.set_attribute("gen_ai.execute_tool.duration", duration)
                    span.set_status(Status(StatusCode.OK))
                    return result
                except Exception as exc:
                    span.record_exception(exc)
                    span.set_status(Status(StatusCode.ERROR, str(exc)))
                    raise exc
        return wrapper
    return decorator
```

## Arquitectura de ingestión y sampling

Guardar cada interacción entera con prompts largos, embeddings y payloads satura el almacenamiento y expone datos personales (PII).

La solución es usar un OTel Collector. Recibe las trazas por OTLP/gRPC, limpia datos sensibles, aplica tail-sampling y manda los datos a los backends elegidos:

```yaml
# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  # Sampling adaptativo en cola
  tail_sampling:
    decision_wait: 10s
    num_traces: 10000
    expected_new_traces_per_sec: 2000
    policies:
      # 1. 100% de traces con errores
      - name: sample-errors
        type: status_code
        status_code: { status_codes: [ ERROR ] }
      # 2. 100% de traces lentas (degradación)
      - name: sample-latency
        type: latency
        latency: { threshold_ms: 10000 }
      # 3. 5% de traces exitosas para análisis de costos y drifts
      - name: sample-success-probabilistic
        type: probabilistic
        probabilistic: { sampling_percentage: 5.0 }

  redaction:
    # Sanitización de PII antes de exportar
    allow_all_keys: true # conservar los atributos; solo enmascarar los valores que matcheen
    blocked_values:
      - "(?i)[a-z0-9_.+-]+@[a-z0-9-]+\\.[a-z0-9-.]+" # Emails
      - "\\b(?:\\d[ -]*?){13,16}\\b" # Tarjetas de crédito

exporters:
  otlp/langfuse:
    endpoint: https://cloud.langfuse.com:443
  prometheus:
    endpoint: 0.0.0.0:8889
  clickhouse:
    endpoint: tcp://clickhouse:9000?database=otel

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling, redaction]
      exporters: [otlp/langfuse, clickhouse]
    metrics:
      receivers: [otlp]
      exporters: [prometheus]
```

## Comparativa de backends

| Backend | Ventajas Operativas | Limitaciones y Tradeoffs |
| :--- | :--- | :--- |
| **OpenTelemetry + ClickHouse** | Control total de costos, retención histórica masiva y soberanía absoluta de datos. | Requiere mantener pipelines propios y diseñar UIs/dashboards ad-hoc. |
| **Langfuse / Arize Phoenix** | Visualización especializada de árboles de ejecución, debugging paso a paso y testing de prompts. | Introduce una plataforma adicional en la infraestructura fuera del APM central. |
| **LangSmith** | Trazabilidad nativa si el orquestador usa LangGraph/LangChain. | Acoplamiento de ecosistema y costos de licencia por volumen de eventos. |
| **Datadog / New Relic APM** | Monitoreo unificado de infraestructura base (K8s, DB, queues) junto al agente. | Costos elevados por ingestión de payloads extensos y herramientas de CI eval poco flexibles. |

## De traces en producción a golden datasets

El mayor provecho del tracing aparece cuando alimentás tu suite de pruebas continuas. Es un circuito clave que detallo en [cómo armar CI/CD para agentes de IA](/blog/arquitectura-evals-ci-cd-agentes):

```mermaid
flowchart LR
    A["Falla en Producción (Span Error / LLM Judge Fail)"] --> B["Extracción de Trace + Sanitización PII"]
    B --> C["Mock de Tools con Payloads Históricos"]
    C --> D["Inyección en Golden Dataset"]
    D --> E["Regresión en CI: Evaluar nuevo prompt/modelo"]
```

Para convertir una traza productiva en un test determinista, seguí estos pasos:

1. **Detectar el incidente:** Filtrá trazas con errores en tools o puntajes bajos de evaluadores asíncronos (como un LLM-as-a-judge o validadores semánticos).
2. **Aislar y mockear:** No vuelvas a pegarle a la API real (como Stripe en nuestro ejemplo de reembolsos) para no duplicar operaciones. Tomá los argumentos del span y mockeá la respuesta usando el payload guardado en `gen_ai.tool.call.result`.
3. **Validar localmente:** Con las tools mockeadas, ejecutá el paso de inferencia para chequear si el nuevo prompt o modelo resuelve bien el ruteo.

## Diagnóstico de problemas paso a paso

Si la tasa de éxito de tu agente baja, revisá los spans en este orden:

1. **Recuperación semántica:** Mirá el span de retrieval. Si bajó el similarity score promedio o aumentó su dispersión, revisá el modelo de embeddings, el chunking o el índice vectorial.
2. **Esquemas de tool calling:** Revisá el span de inferencia previo a ejecutar la tool. Si el LLM devolvió un JSON que rompe el schema, forzá la salida con Structured Outputs nativos o sumá validaciones intermedias con reintentos tipados, como explico en la guía sobre [tool calling determinista en producción](/blog/tool-calling-produccion-arquitectura-seguridad).
3. **Saturación del contexto:** Si el fallo ocurre a partir del cuarto paso, revisá el tamaño de la ventana de contexto. Muchos payloads acumulados confunden al modelo. Cuidado: no borres mensajes al azar. Las APIs exigen que cada `tool_call` coincida con su `tool_message` mediante el `tool_call_id`. Para ahorrar tokens sin romper el historial, resumí solo el contenido de los resultados viejos manteniendo intactos los IDs.

## Métricas de servicio para monitorear

- **Latencia P50 y P95 por sesión:** Tiempo total de la traza, separando la inferencia de las llamadas a APIs externas.
- **Costo por resolución exitosa:** Total de tokens consumidos (input, output y cacheados) sobre la cantidad de corridas exitosas.
- **Tasa de fallas en tools:** Proporción de spans de tools con status `ERROR` sobre los finalizados con `OK`.
- **Profundidad de ejecución (pasos por sesión):** Promedio de pasos por corrida. Si sube de golpe, el agente suele estar atrapado en bucles por instrucciones ambiguas en el prompt.

## Qué hacer el lunes

1. **Instrumentá con OTel:** Configurá la propagación de contexto W3C y usá las convenciones de OpenTelemetry GenAI para tus modelos y tools.
2. **Configurá tail-sampling:** Levantá un OTel Collector para guardar el 100% de los errores y trazas lentas, y solo un 5% de los casos exitosos para controlar los costos de storage.
3. **Automatizá la creación de datasets:** Armá un pipeline que tome las trazas fallidas sanitizadas, mockee las tools y sume esos casos a tu suite de CI/CD antes de cambiar prompts o modelos.

Un agente sin tracing distribuido es una caja negra que gasta presupuesto sin darte visibilidad de lo que pasa adentro.
