Без моніторингу (антипатерн) + Що логувати + Код

  • Побач пастку до того, як вона потрапить у прод.
  • Дізнайся, що ламається при впевненій помилці моделі.
  • Скопіюй безпечні defaults: permissions, budgets, idempotency.
  • Знай, коли агент взагалі не потрібен.
Сигнали виявлення
  • Tool calls на run зростають (або повторюються з args hash).
  • Витрати/токени ростуть без кращих результатів.
  • Retries стають постійними (429/5xx).
Якщо ти не можеш відповісти «що зробив агент?», ти не можеш запускати його в продакшені. Мінімум: трейси, stop reasons, витрати й логи tool calls.
На цій сторінці
  1. Проблема (з реального продакшену)
  2. Момент 03:00
  3. Чому це ламається в продакшені
  4. 1) Агент — це distributed system з додатковими кроками
  5. 2) “Success rate” ховає цікаві фейли
  6. 3) Ти не виправиш те, що не можеш replay
  7. 4) Моніторинг — частина governance
  8. Жорсткі інваріанти (не обговорюється)
  9. Приклад реалізації (реальний код)
  10. Приклад інциденту (конкретний)
  11. 🚨 Інцидент: “все повільно” (і ми не знали чому)
  12. Dashboards + alerts (приклади, які можна вкрасти)
  13. PromQL (Grafana)
  14. SQL (Postgres/BigQuery-style)
  15. Alert rules (plain English)
  16. Компроміси
  17. Коли НЕ варто
  18. Чекліст (можна копіювати)
  19. Безпечний дефолтний конфіг
  20. FAQ
  21. Пов’язані сторінки
  22. Production takeaway
  23. Що ламається без цього
  24. Що працює з цим
  25. Мінімум, щоб шипнути
Коротко

Коротко: Без observability кожен фейл агента перетворюється на “модель була дивна” — діагноз, який не тестується й не фікситься. Тобі потрібні: трейси tool calls, stop reasons, трекінг вартості й можливість replay. Це не опційна інфраструктура.

Ти дізнаєшся: Мінімальні вимоги до моніторингу • Один unified event schema • Таксономія stop reasons • Основи replay • Конкретний інцидент, який ти впізнаєш

Concrete metric

Без моніторингу: юзери репортять першими • дебаг “по вайбах” • без replay
З мінімальним моніторингом: раннє виявлення drift • дебаг через трейси + stop reasons • replay останніх runs
Ефект: швидша реакція на інциденти + менше повторних фейлів (бо можна фіксити root cause)


Проблема (з реального продакшену)

Один run агента йде не так.

Юзер пише: “він відправив не той email”.

Ти відкриваєш логи й у тебе є:

  • фінальний текст відповіді (можливо)
  • stack trace (можливо)
  • вайби (точно)

Якщо ти не можеш відповісти на ці 5 питань — систему неможливо нормально оперувати:

Incident questions
  1. Які tools були викликані (і в якому порядку)?
  2. З якими аргументами (або хоча б args hashes)?
  3. Що повернулося (або хоча б snapshot hashes)?
  4. Яка версія model/prompt/tools працювала?
  5. Чому run зупинився?
Truth

Це не “немає дашбордів”. Це “ця система не operable”.

Момент 03:00

Ось як відчувається “без моніторингу”:

TEXT
03:12 — Support: "Agent emailed the wrong customer. Please stop it."

Ти grep’аєш логи й знаходиш… нічого, що можна нормально join’нути.

TEXT
2026-02-07T03:11:58Z INFO sent email to customer@example.com
2026-02-07T03:11:59Z INFO sent email to customer@example.com
2026-02-07T03:12:01Z WARN http.get 429
2026-02-07T03:12:03Z INFO Agent completed task

Нема run_id. Нема step trace. Нема stop reason. Нема tool args hash. Нема версії model/tool.

І ти робиш найгірший дебаг: grep по email і молишся, що він унікальний.


Чому це ламається в продакшені

Failure analysis

1) Агент — це distributed system з додатковими кроками

Щойно агент викликає tools, ти побудував:

  • кілька залежностей (HTTP, DB, APIs)
  • кілька failure modes (timeouts, 502s, rate limits)
  • кілька retries (і retry storms)

Якщо ти не логуєш кожен крок — ти дебажиш сторітелінгом.

2) “Success rate” ховає цікаві фейли

Drift проявляється як:

  • більше tool calls/run (зациклення, а не явний фейл)
  • більше tokens/request (модель “пояснює” помилки)
  • довша latency (retries, повільні tools)
  • інші stop reasons (budgets, denials, timeouts)

3) Ти не виправиш те, що не можеш replay

Якщо ти не можеш replay (або хоча б реконструювати) run з логів — ти не можеш довіряти “фіксу”. Ти просто вгадуєш.

4) Моніторинг — частина governance

Budgets, allowlists і kill switches марні, якщо ти не бачиш, коли вони trigger’яться.


Жорсткі інваріанти (не обговорюється)

  • Кожен run має run_id.
  • Кожен step має step_id.
  • Кожен tool call логує: tool name, args hash, duration, status, error class.
  • Кожен run завершується stop event: stop_reason.
  • Якщо ти не можеш replay (хоч частково) — ти не можеш довіряти фіксу.

Приклад реалізації (реальний код)

Типовий фейл тут — два різні формати логів:

  • tool events структуровані
  • stop events “особливі”

Це вбиває joinability.

Цей приклад використовує один unified event schema для tool calls і stop events.

PYTHON
from __future__ import annotations

from dataclasses import dataclass, asdict
import hashlib
import json
import time
from typing import Any, Literal


EventKind = Literal["tool_result", "stop"]


def sha(obj: Any) -> str:
  raw = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
  return hashlib.sha256(raw).hexdigest()[:24]


@dataclass(frozen=True)
class Event:
  run_id: str
  kind: EventKind
  ts_ms: int

  # optional fields
  step_id: int | None = None
  tool: str | None = None
  args_sha: str | None = None
  duration_ms: int | None = None
  status: Literal["ok", "error"] | None = None
  error: str | None = None

  stop_reason: str | None = None
  usage: dict[str, Any] | None = None


def log_event(ev: Event) -> None:
  print(json.dumps(asdict(ev), ensure_ascii=False))


def call_tool(run_id: str, step_id: int, tool: str, args: dict[str, Any]) -> Any:
  started = time.time()
  try:
      out = tool_impl(tool, args=args)  # (pseudo)
      dur = int((time.time() - started) * 1000)
      log_event(
          Event(
              run_id=run_id,
              kind="tool_result",
              ts_ms=int(time.time() * 1000),
              step_id=step_id,
              tool=tool,
              args_sha=sha(args),
              duration_ms=dur,
              status="ok",
              error=None,
          )
      )
      return out
  except Exception as e:
      dur = int((time.time() - started) * 1000)
      log_event(
          Event(
              run_id=run_id,
              kind="tool_result",
              ts_ms=int(time.time() * 1000),
              step_id=step_id,
              tool=tool,
              args_sha=sha(args),
              duration_ms=dur,
              status="error",
              error=type(e).__name__,
          )
      )
      raise


def stop(run_id: str, *, reason: str, usage: dict[str, Any]) -> dict[str, Any]:
  log_event(
      Event(
          run_id=run_id,
          kind="stop",
          ts_ms=int(time.time() * 1000),
          stop_reason=reason,
          usage=usage,
      )
  )
  return {"status": "stopped", "stop_reason": reason, "usage": usage}
JAVASCRIPT
import crypto from "node:crypto";

export function sha(obj) {
const raw = JSON.stringify(obj, Object.keys(obj || {}).sort());
return crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 24);
}

export function logEvent(ev) {
console.log(JSON.stringify(ev));
}

export async function callTool(runId, stepId, tool, args) {
const started = Date.now();
try {
  const out = await toolImpl(tool, { args }); // (pseudo)
  logEvent({
    run_id: runId,
    kind: "tool_result",
    ts_ms: Date.now(),
    step_id: stepId,
    tool,
    args_sha: sha(args),
    duration_ms: Date.now() - started,
    status: "ok",
    error: null,
  });
  return out;
} catch (e) {
  logEvent({
    run_id: runId,
    kind: "tool_result",
    ts_ms: Date.now(),
    step_id: stepId,
    tool,
    args_sha: sha(args),
    duration_ms: Date.now() - started,
    status: "error",
    error: e?.name || "Error",
  });
  throw e;
}
}

export function stop(runId, { reason, usage }) {
logEvent({
  run_id: runId,
  kind: "stop",
  ts_ms: Date.now(),
  stop_reason: reason,
  usage,
});
return { status: "stopped", stop_reason: reason, usage };
}

Приклад інциденту (конкретний)

Incident

🚨 Інцидент: “все повільно” (і ми не знали чому)

Date: 2024-10-08
Duration: 3 дні непомічено, ~2 години дебагу після того, як додали visibility
System: customer support agent


Що реально сталося

Tool http.get почав повертати інтермітентні 429/503.

Наш tool layer ретраїв до 8× на call (раніше 2×) без jitter. Агент інтерпретував ці фейли як “спробуй інший запит” і почав робити більше tool calls на run.

За 3 дні (ілюстративні числа, але патерн типовий):

  • avg tool calls/run: 4.3 → 11.7
  • p95 latency: 2.1s → 8.4s
  • spend/run: ~2×

Нічого “не впало”. Success rate тримався ~91%, тож drift виглядав як “юзери нетерплячі”, доки support не ескалював.


Root cause (нудна версія)

  • retries + без jitter → thundering herd
  • немає stop reasons у логах → “success” маскував drift
  • немає tool-call trace → ми не могли довести, куди пішли час/витрати

Fix

  1. Structured event logs (run_id, step_id, tool, args hash, duration, status)
  2. Stop reasons показувати caller/UI
  3. Dashboards + alerts на drift сигнали (tool calls/run, latency P95, stop reasons)

Dashboards + alerts (приклади, які можна вкрасти)

Тобі не потрібна ідеальна observability. Тобі потрібна корисна.

PromQL (Grafana)

PROMQL
# Tool calls per run (p95)
histogram_quantile(0.95, sum(rate(agent_tool_calls_bucket[5m])) by (le))

# Stop reasons over time
sum(rate(agent_stop_total[10m])) by (stop_reason)

# Latency p95
histogram_quantile(0.95, sum(rate(agent_run_latency_ms_bucket[5m])) by (le))

SQL (Postgres/BigQuery-style)

SQL
-- Alert: tool_calls/run spike vs baseline
SELECT
  date_trunc('hour', created_at) AS hour,
  avg(tool_calls) AS avg_tool_calls
FROM agent_runs
WHERE created_at > now() - interval '7 days'
GROUP BY 1
HAVING avg(tool_calls) > 2 * (
  SELECT avg(tool_calls)
  FROM agent_runs
  WHERE created_at BETWEEN now() - interval '14 days' AND now() - interval '7 days'
);

Alert rules (plain English)

  • Якщо tool_calls_per_run_p95 = 2× baseline 10 хвилин → investigate (і подумай про kill writes).
  • Якщо stop_reason=loop_detected з’являється вище baseline → investigate (tool spam / bad prompt / outage).
  • Якщо stop_reason=tool_timeout спайкає → це upstream проблема, не “model weirdness”.

Компроміси

Trade-offs
  • Логінг коштує грошей (storage, indexing). Все одно дешевше за сліпі інциденти.
  • Не логуй raw PII/secrets. Hash args і aggressively redact.
  • Replay потребує retention policy + access controls.

Коли НЕ варто

Don’t
  • Не будуй важку tracing платформу до того, як у тебе є structured logs. Start small.
  • Не логуй raw tool args, якщо там PII/secrets. Ніколи.
  • Не шип агенти без stop reasons. Ти створюєш retry loops.

Чекліст (можна копіювати)

Production checklist
  • [ ] run_id / step_id для кожного run
  • [ ] Unified event schema (tool results + stop events)
  • [ ] Tool-call logs: tool, args_hash, duration, status, error class
  • [ ] Stop reason повертається юзеру + логується
  • [ ] Metrics по run: tokens/tool calls/spend
  • [ ] Dashboards: latency P95, tool_calls/run, stop_reason distribution
  • [ ] Replay data: snapshot hashes (retention + access control)

Безпечний дефолтний конфіг

YAML
logging:
  events:
    enabled: true
    schema: "unified"
    store_args: false
    store_args_hash: true
    include: ["run_id", "step_id", "tool", "duration_ms", "status", "error", "stop_reason"]
metrics:
  track: ["tokens_per_request", "tool_calls_per_run", "latency_p95", "spend_per_run", "stop_reason"]
retention:
  tool_snapshot_days: 14
  logs_days: 30

FAQ

FAQ
Який мінімум моніторингу потрібен?
Tool call logs + stop reasons + базові usage metrics. Якщо ти не можеш відповісти “що він зробив?”, ти не можеш це оперувати.
Чи можна логувати raw tool args?
Зазвичай ні. Hash args, aggressively redact і зберігай raw лише в дуже контрольованих системах, якщо треба.
Чи потрібен distributed tracing?
З часом так. Почни зі structured logs з run_id, step_id і durations — це дає більшість цінності.
Як моніторити drift?
Дивись на tokens, tool calls, latency і stop reasons. Вони рухаються до скарг на correctness.

Пов’язані сторінки

Related

Production takeaway

Production takeaway

Що ламається без цього

  • ❌ Ти не можеш пояснити інциденти
  • ❌ Drift виглядає як “model weirdness”
  • ❌ Перевитрати видно постфактум

Що працює з цим

  • ✅ Можна join’ити, replay’ити й дебажити runs
  • ✅ Drift стає графіком, а не суперечкою
  • ✅ Kill switches trigger’яться від реальних сигналів

Мінімум, щоб шипнути

  1. Unified structured logs
  2. Stop reasons
  3. Базові metrics + dashboards
  4. Alerts на drift

Не впевнені, що це ваш кейс?

Спроєктувати агента →
⏱️ 9 хв читанняОновлено Бер, 2026Складність: ★★★
Реалізувати в OnceOnly
Safe defaults for tool permissions + write gating.
Використати в OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Автор

Цю документацію курують і підтримують інженери, які запускають AI-агентів у продакшені.

Контент створено з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Патерни та рекомендації базуються на постмортемах, режимах відмов і операційних інцидентах у розгорнутих системах, зокрема під час розробки та експлуатації governance-інфраструктури для агентів у OnceOnly.