Files
fit-boilerplate-wox/docs/observability-pattern.md
Felipe Arentsen b089c2ef18
Some checks failed
CI — Lint + Evals / lint (push) Has been cancelled
CI — Lint + Evals / smoke (push) Has been cancelled
feat: v1 — boilerplate WxO + web
Boilerplate completo para construir soluciones agénticas sobre IBM
watsonx Orchestrate (ADK 2.x) con una capa web encima.

Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye:

- 11 docs (best practices, architecture patterns, ADK cheatsheet,
  observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX,
  known-issues con 16 errores reales y fix, eval strategy, INDEX)
- 13 templates WxO (orchestrator/specialist/single-meta agents,
  3 connections, KB + runbook, observable_tool decorator, coercion
  helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook
  validator HMAC, MCP connection)
- 6 scripts (deploy idempotente con fallback ADK, undeploy, reset,
  check-adk-version, new-specialist scaffold, eval-agents runner)
- 4 eval pieces (linter de best practices, runner, smoke-test, direct
  backend probe) + scenario templates
- 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author,
  wxo-tool-author, runbook-author, mock-builder, backend-tool-builder,
  eval-author, web-layer-builder
- Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para
  copiar a ~/.claude/skills/
- Web layer default FastAPI+HTMX con vista timeline observable + endpoint
  receptor de trazas
- Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik,
  network split internal:true)
- CI Gitea workflow con lint + smoke

Best practices enforced por evals/lint_wxo_yaml.py:
- A1: máx 10 tools por agente
- A3: orchestrator sin tools de remediación
- A6: agente sin propósito (react sin tools ni KB)
- T1: @observable_tool obligatorio, no @tool directo
- T3: _compat shim inline en tools.py
- D1: docker-compose sin wget --spider
- I-005: OpenAPI con description per-operation
- I-009: sin labels Traefik manuales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:59:44 +00:00

5.7 KiB

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:

{
  "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:

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:

# 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:

# 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:

@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).