# Observability Pattern Cada tool wrapper en este template emite trazas estructuradas por defecto. La capa web las consume, las muestra en una vista timeline, y las evals las usan para verificar que el agente llamó las tools correctas en el orden correcto. ## El contrato Toda tool, sin importar el lenguaje, devuelve este envelope: ```json { "result": { /* el resultado real que el LLM consume */ }, "trace": { "trace_id": "ad-reset-7f3a", "tool": "reset_password", "domain": "ad", "started_at": "2026-05-16T14:23:01.123Z", "duration_ms": 187, "inputs": { "username": "juan.perez@cotemar.com" }, "side_effects": [ { "type": "http_call", "method": "POST", "url": "...", "status": 200, "duration_ms": 142 } ], "observed_state_before": { "can_reset": true, "user_active": true }, "observed_state_after": { "password_rotated": true, "expires_at": "..." }, "agent_caller": "ad_specialist_cotemar", "correlation_id": "case-abcd-1234" } } ``` El LLM ve solo `result`. La traza va al sink configurado y no contamina el razonamiento del agente. ## Decorator Python (`@observable_tool`) Reemplaza al `@tool` directo de la ADK: ```python from wxo.tools.python._observable_tool import observable_tool @observable_tool(name="reset_password", domain="ad") def reset_password(username: str) -> dict: # tu lógica normal return {"temp_password": "xxx", "expires_in": "24h"} ``` El decorator: 1. Genera un `trace_id` único 2. Captura `started_at` y mide `duration_ms` 3. Serializa `inputs` 4. Intercepta llamadas HTTP (si usa `requests` patched) y las anota en `side_effects` 5. Llama tu función 6. Captura `result` 7. Emite la traza al sink configurado vía env `TRACE_SINK` 8. Devuelve `{result, trace}` envuelto La función decorada sigue siendo un `@tool` válido para la ADK — el decorator delega. ## Sinks soportados Tres modos según el caso, controlados por env var `TRACE_SINK`: ### `TRACE_SINK=sqlite` (default dev) Trazas se escriben a `traces.db` SQLite en el container del web layer. Útil para dev y CI. ### `TRACE_SINK=http` POST a `$TRACE_SINK_URL` (default `http://web:8000/api/traces`). Pattern Cotemar: el web layer recibe trazas live y las muestra en una vista timeline filterable. ### `TRACE_SINK=otlp` OpenTelemetry OTLP gRPC a `$OTEL_EXPORTER_OTLP_ENDPOINT`. Producción. Compatible con Jaeger, Dynatrace, Grafana Tempo, etc. ## Backend integrado (estilo Dun) — `write_audit` Cuando la tool no es un wrapper Python sino un endpoint del backend (patrón Dun con OpenAPI import), el equivalente es `write_audit`: ```python # backend/app/audit.py def write_audit(case_id: str, event: str, payload: dict): db.execute( "INSERT INTO audit_logs (case_id, event, payload, ts) VALUES (?, ?, ?, ?)", (case_id, event, json.dumps(payload), datetime.utcnow()) ) # backend/app/api/v1/orchestrate_tools.py @router.post("/reset-password") def reset_password(input: ResetPasswordInput, case_id: str): write_audit(case_id, "tool.reset_password.started", {"input": input.dict()}) result = ad_client.reset(input.username) write_audit(case_id, "tool.reset_password.completed", {"result": result}) return result ``` El backend reconstruye el timeline desde `audit_logs`, no desde trazas volátiles de Orchestrate. ## Schema unificado para multi-lenguaje Si tu wrapper es Node/Java/Go, el decorator equivalente debe emitir el **mismo envelope JSON**. Los templates están en: ``` wxo/tools/_observability/ ├── python/observable_tool.py ├── node/observable-tool.ts (TODO en v1, escribilo si lo necesitás) ├── java/ObservableTool.java (TODO en v1) └── README.md ← el contrato schema ``` ## Vista timeline en el web layer El `web/_default_fastapi_htmx/` viene con: - `POST /api/traces` — endpoint que recibe trazas - Tabla `traces` en SQLite con índices en `(agent_caller, started_at)` y `(trace_id)` - `GET /traces` — vista HTMX con timeline - Componente que polea `GET /traces/recent?since=...` cada 2s Captura visual: ``` 14:23:01.123 ad_specialist reset_password(juan.perez@cotemar.com) ├─ HTTP POST ad/reset-password 142ms 200 └─ result: { temp_password: "xxx" } 187ms total 14:23:02.456 ad_specialist create_ticket(...) └─ HTTP POST unus/api/tickets 89ms 201 └─ result: { ticket_id: "TKT-A1B2" } 103ms total ``` ## Evals que consumen trazas Las evals de behavior verifican comportamiento intermedio, no solo el resultado final: ```yaml # evals/scenarios/scenario_reset_password.yaml input: "Necesito resetear la contraseña de juan.perez@cotemar.com" expect: agent_response_contains: ["TKT-"] tool_calls_in_order: - tool: lookup_user inputs.username: "juan.perez@cotemar.com" - tool: reset_password - tool: create_ticket inputs.status: "RESOLVED" no_tool_calls: - escalate_to_n2 ``` El runner lee las trazas emitidas durante el run y matchea contra `expect`. Si el agente llamó las tools en el orden equivocado, falla aunque el ticket final esté bien. ## Regla del linter `evals/lint_wxo_yaml.py` falla si encuentra un `@tool(...)` sin `@observable_tool(...)` en cualquier archivo bajo `wxo/tools/python/`. No hay excepción. Si necesitás bypass para debug, marcalo: ```python @observable_tool(name="x", domain="y", _bypass_tracing=True) # explícito def x(): ... ``` ## Costo El decorator agrega ~2-5ms de overhead por call. Para 99% de los casos es invisible. Si tenés un tool ultra-hot que llama 1000 veces por minuto, configurá `TRACE_SINK=sqlite` con buffer batch o desactiválo con `_bypass_tracing=True` (con audit a mano).