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

179 lines
5.7 KiB
Markdown

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