Коротка відповідь. Спостережуваність агента – це не логи «request completed», а трейс усього ланцюжка рішень: кожен виклик моделі й кожен tool call – окремий span зі входом, виходом, токенами, вартістю і версією промпта, зшиті одним correlation ID. Додай taxonomy збоїв (wrong tool, bad args, timeout, hallucinated state), бюджети на сесію і чіткі privacy-межі логування – і «агент знову дивний» перетворюється на конкретний span, де видно, що модель отримала і чому вирішила саме так. Нижче – vendor-neutral схема на TypeScript, яку можна прикрутити до будь-якого стека.

Чому звичайний APM тут сліпий

Класичний моніторинг бачить: POST /api/agent → 200, 14 секунд. Усе «добре». А всередині цих 14 секунд модель викликала пошуковий tool із порожнім запитом, отримала сміття, «виправилась», викликала не той tool, отримала помилку, переформулювала – і видала користувачу впевнену нісенітницю. Шість LLM-викликів, $8 токенів, нуль корисності – і жодного сліду в логах.

Проблема структурна: одиниця роботи агента – не HTTP-запит, а ланцюжок рішень. Спостережуваність має відповідати цій одиниці.

Анатомія трейсу агента

Поняттєвий апарат – стандартний (OpenTelemetry): trace – усе опрацювання одного звернення, span – окремий крок усередині. Для агента кроки такі:

trace: "допоможи оформити повернення замовлення #1234"
├── span: model_call #1        (вирішує: потрібен lookup)
├── span: tool_call orders.get (args: {id:"1234"} → 200, 1.2s)
├── span: model_call #2        (вирішує: створити повернення)
├── span: tool_call refunds.create (→ 422: вікно повернення минуло)
└── span: model_call #3        (формулює відповідь користувачу)

Кожен span відповідає на чотири питання: що отримав на вході, що віддав, скільки коштував (токени, мілісекунди, гроші) і під якими версіями працював (модель, промпт, схеми tool-ів). Correlation ID пронизує все – від першого повідомлення до останнього tool-а, через усі черги й сервіси.

Vendor-neutral схема на TypeScript

Мінімальна модель, якої вистачає на 90% питань «що сталося»:

type AgentSpan = {
  traceId: string;
  spanId: string;
  parentSpanId?: string;
  kind: 'model_call' | 'tool_call';
  name: string;                  // 'claude-...' або 'orders.get'
  startedAt: string;
  durationMs: number;

  // Версії – без них не поясниш «чому вчора працювало»
  versions: {
    model?: string;
    promptId?: string;           // ідентифікатор+версія промпта, НЕ сам текст
    toolSchemaHash?: string;
  };

  usage?: { inputTokens: number; outputTokens: number; costUsd?: number };

  // Вміст – свідомо обрізаний і керований політикою (див. privacy)
  inputDigest: string;           // хеш або санітизований прев'ю
  outputDigest: string;

  outcome: 'ok' | AgentFailure;
};

type AgentFailure = {
  category:
    | 'wrong_tool'          // викликав не той інструмент
    | 'bad_args'            // той, але з невалідними аргументами
    | 'tool_error'          // tool впав сам (5xx, timeout)
    | 'timeout'
    | 'budget_exceeded'     // вибив ліміт токенів/вартості
    | 'hallucinated_state'; // послався на дані, яких не отримував
  detail: string;
};

І middleware, який робить трейсинг неуникним – замість «не забудь залогувати»:

function traced<TArgs, TResult>(
  kind: AgentSpan['kind'],
  name: string,
  fn: (args: TArgs) => Promise<TResult>
) {
  return async (args: TArgs, ctx: TraceContext): Promise<TResult> => {
    const span = startSpan(ctx, { kind, name });
    try {
      const result = await fn(args);
      endSpan(span, { outcome: 'ok', output: result });
      return result;
    } catch (error) {
      endSpan(span, { outcome: classifyFailure(error), output: error });
      throw error;
    }
  };
}

// Кожен tool загортається один раз – і жоден виклик не пройде повз трейс
const getOrder = traced('tool_call', 'orders.get', ordersApi.get);

classifyFailure – це і є твоя taxonomy в коді: Zod-помилка валідації аргументів → bad_args, 5xx від tool-а → tool_error, перевищення лічильника → budget_exceeded. Категорія wrong_tool – єдина, яку не визначиш автоматично в момент виклику: вона ставиться пізніше – ручним розбором або LLM-judge/eval-ами, і для швидких типізованих вердиктів тут природно лягає модель на кшталт Jev.

Що НЕ можна логувати

Найкоротший розділ і найдорожчий у разі ігнорування. У вмісті розмов живуть email-и, токени, медичні подробиці – і повний текст діалогу в логах перетворює твою систему спостережуваності на найбільший витік компанії.

  • Логуй digest (хеш + санітизоване прев'ю з редагованими PII), а не сирий вміст.
  • Промпт – через promptId@version, текст живе у версіонованому сховищі з контрольованим доступом.
  • Для глибокого дебагу тримай окремий режим повного семплінгу: явно увімкнений, обмежений у часі, з аудитом доступу.
  • Аргументи tool-ів – найпідступніше місце: orders.get({email}) уже містить PII. Фільтруй по білому списку полів.

Бюджети, алерти, семплінг

Бюджет – це запобіжник, а не метрика пост-фактум: лічильник токенів/вартості всередині циклу агента, який обриває сесію з budget_exceeded замість рахунку-сюрпризу наприкінці місяця.

Алерти вішай на taxonomy, а не на «error rate»: сплеск bad_args після деплою = зламали схему tool-а; ріст wrong_tool після зміни промпта = регресія роутингу; hallucinated_state – завжди привід для ручного розбору. Семплінг: 100% для помилок і дорогих сесій, відсоток – для успішних, інакше сховище трейсів з'їсть більше, ніж самі моделі.

Типові помилки

  1. Логування «на совісті» кожного tool-а. Один пропущений виклик – і трейс бреше. Middleware робить трейсинг структурно неуникним.
  2. Повний текст розмов у логах. Див. privacy – це не «потім почистимо», це дизайн-рішення дня першого.
  3. Немає версій промптів. «Чому вчора працювало?» без promptId@version – археологія по git blame.
  4. Метрики без трейсів. Середня вартість сесії виросла на 40% – а чому? Без span-ів відповіді немає.
  5. Алерт «агент помилився». Без категорії це шум, який усі навчаться ігнорувати за тиждень.

Практичне завдання

Візьми один реальний (чи навчальний) агентний флоу і намалюй його очікуваний трейс на папері: усі model/tool span-и, їхні входи-виходи, де межі бюджету. Потім влаштуй йому «поганий день»: уяви по одному збою кожної категорії з taxonomy і перевір – чи дозволить твоя намальована схема за 2 хвилини відрізнити bad_args від tool_error і wrong_tool? Якщо ні – ти щойно знайшов, якого поля бракує в span-і. Це дешевше, ніж знаходити його о другій ночі в production.