Наблюдаемость AI-систем
Сквозной trace ID для AI-агента, MCP и внутренних API
Один агентный запрос может пройти через модель, несколько инструментов и бизнес-систему. Если каждый компонент пишет собственный несвязанный журнал, ответ на вопрос «почему изменился заказ?» превращается в ручное сопоставление времени, параметров и догадок.
Что именно нужно связать
Trace ID — идентификатор всей операции от входного запроса до последнего внутреннего вызова. Он должен сопровождать:
- получение задачи агентом;
- решение вызвать инструмент;
- MCP-запрос и выполнение обработчика;
- HTTP-вызов внутреннего API;
- изменение состояния бизнес-системы;
- итоговый ответ агента или ошибку.
Одного trace ID недостаточно для описания ветвления. Поэтому у каждого отдельного шага есть span ID, а у дочернего шага — parent span ID. Trace ID отвечает на вопрос «какая это операция?», span ID — «какой именно шаг?».
Целевая схема
Клиент
└─ Агент: trace=T, span=A
├─ Решение модели: trace=T, span=M, parent=A
└─ MCP-вызов: trace=T, span=P, parent=A
└─ Внутренний API: trace=T, span=I, parent=P
└─ Изменение заказа: trace=T, span=B, parent=I
Во всех журналах сохраняется один trace_id, но каждый компонент создаёт собственный span_id. Это позволяет найти операцию простым поиском и восстановить дерево исполнения по полям span_id и parent_span_id.
Шаг 1. Зафиксируйте формат контекста
Для HTTP удобно передавать стандартный заголовок traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Это пример, а не значение для копирования в продакшен. Поля разделены дефисами:
версия-trace_id-parent_span_id-флаги
Trace ID содержит 32 шестнадцатеричных символа, span ID — 16. Нулевые значения недопустимы. Флаг 01 в примере означает, что трасса выбрана для записи.
В MCP-контекст можно положить те же значения в метаданные запроса. Конкретный способ зависит от используемого SDK, поэтому полезно определить внутренний нейтральный объект:
{
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"trace_flags": "01"
}
Не добавляйте в контекст промпт, токен авторизации, персональные данные или аргументы инструмента. Контекст трассировки должен содержать технические идентификаторы, а не бизнес-содержимое.
Шаг 2. Создайте и проверьте идентификаторы на входе
Ниже — минимальный пример на Python без внешних зависимостей. Он генерирует криптографически случайные идентификаторы и проверяет входной traceparent.
import re
import secrets
from dataclasses import dataclass
TRACEPARENT_RE = re.compile(
r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$"
)
@dataclass(frozen=True)
class TraceContext:
trace_id: str
span_id: str
trace_flags: str = "01"
def new_trace_id() -> str:
value = secrets.token_hex(16)
return value if value != "0" * 32 else new_trace_id()
def new_span_id() -> str:
value = secrets.token_hex(8)
return value if value != "0" * 16 else new_span_id()
def parse_traceparent(value: str | None) -> TraceContext | None:
if not value or len(value) > 128:
return None
match = TRACEPARENT_RE.fullmatch(value.strip())
if not match:
return None
trace_id, parent_span_id, flags = match.groups()
if trace_id == "0" * 32 or parent_span_id == "0" * 16:
return None
return TraceContext(trace_id, parent_span_id, flags)
def start_request(incoming: str | None, trust_incoming: bool) -> TraceContext:
parent = parse_traceparent(incoming) if trust_incoming else None
return TraceContext(
trace_id=parent.trace_id if parent else new_trace_id(),
span_id=new_span_id(),
trace_flags=parent.trace_flags if parent else "01",
)
trust_incoming должен определяться конфигурацией шлюза, а не параметром, которым управляет клиент. Ограничение длины применяется до регулярного выражения, чтобы отсечь чрезмерный ввод.
Шаг 3. Передайте контекст в MCP
Перед вызовом инструмента агент создаёт дочерний span. Названия полей метаданных ниже — внутреннее соглашение примера; адаптируйте их к своему MCP SDK.
def child_context(parent: TraceContext) -> TraceContext:
return TraceContext(
trace_id=parent.trace_id,
span_id=new_span_id(),
trace_flags=parent.trace_flags,
)
tool_ctx = child_context(agent_ctx)
mcp_request = {
"name": "update_order",
"arguments": {
"order_id": order_id,
"status": target_status
},
"_meta": {
"trace_id": tool_ctx.trace_id,
"span_id": tool_ctx.span_id,
"parent_span_id": agent_ctx.span_id,
"trace_flags": tool_ctx.trace_flags
}
}
MCP-сервер проверяет метаданные так же строго, как HTTP-заголовок. Если контекст отсутствует или повреждён, сервер создаёт новую трассу и пишет событие trace_context_rejected. Молча принимать произвольные строки не следует.
Шаг 4. Продолжите трассу во внутреннем API
MCP-обработчик создаёт новый span для исходящего HTTP-запроса, сохраняя trace ID:
def format_traceparent(ctx: TraceContext) -> str:
return f"00-{ctx.trace_id}-{ctx.span_id}-{ctx.trace_flags}"
api_ctx = child_context(tool_ctx)
headers = {
"traceparent": format_traceparent(api_ctx),
"content-type": "application/json"
}
Здесь span ID в заголовке становится родительским для принимающего API. Само API создаёт новый локальный span, а полученное значение сохраняет как parent_span_id.
Trace ID не является механизмом авторизации или идемпотентности. Для повторяемого изменения бизнес-состояния передавайте отдельный ключ идемпотентности и проверяйте обычные права доступа.
Шаг 5. Пишите структурированные журналы
Каждая запись должна быть отдельным JSON-объектом. Минимальный набор полей:
{
"timestamp": "2026-01-15T10:24:31.482Z",
"level": "INFO",
"service": "order-mcp",
"event": "tool.completed",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "7a31c94d8e1a22f0",
"parent_span_id": "00f067aa0ba902b7",
"tool_name": "update_order",
"duration_ms": 84,
"outcome": "success"
}
Запись целиком является примером. Не копируйте дату и идентификаторы как постоянные значения.
Для решения модели регистрируйте не скрытые рассуждения, а наблюдаемый результат управления:
{
"service": "agent-runtime",
"event": "model.tool_selected",
"trace_id": "...",
"span_id": "...",
"parent_span_id": "...",
"tool_name": "update_order",
"model_request_id": "...",
"outcome": "success"
}
model_request_id добавляйте только если провайдер действительно возвращает его. Не генерируйте фиктивное значение под видом идентификатора провайдера.
На границе бизнес-системы полезно записать тип операции и идентификатор сущности, если политика журналирования это разрешает:
{
"service": "orders-api",
"event": "order.status_changed",
"trace_id": "...",
"span_id": "...",
"parent_span_id": "...",
"entity_type": "order",
"entity_id": "example-order-id",
"outcome": "success"
}
Не журналируйте полный промпт, тело ответа модели, токены, платёжные данные и другие чувствительные поля по умолчанию.
Проверка результата
Выполните один запрос в тестовой среде и сохраните выданный системой trace ID. Затем найдите все записи локального JSONL-журнала безопасной командой только для чтения:
TRACE_ID='4bf92f3577b34da6a3ce929d0e0e4736'
rg --fixed-strings -- "$TRACE_ID" ./logs/*.jsonl
Значение переменной здесь демонстрационное. Подставьте trace ID фактического тестового запроса. Не используйте пользовательский ввод как имя файла или фрагмент команды.
Успешная проверка должна показать:
- одинаковый
trace_idво всех задействованных сервисах; - уникальный ненулевой
span_idу каждого шага; - корректную цепочку
parent_span_id; - событие выбора инструмента до события начала MCP-вызова;
- завершение внутреннего API до успешного завершения инструмента;
- явный
outcomeи длительность для каждого завершённого шага.
Для автоматической проверки структуры можно сохранить найденные записи в trace.jsonl и выполнить:
python3 - < trace.jsonl <<'PY'
import json
import sys
rows = [json.loads(line) for line in sys.stdin if line.strip()]
assert rows, "трасса пуста"
trace_ids = {row.get("trace_id") for row in rows}
assert len(trace_ids) == 1, f"найдено trace_id: {trace_ids}"
span_ids = [row.get("span_id") for row in rows if row.get("span_id")]
assert len(span_ids) == len(set(span_ids)), "span_id повторяется"
known = set(span_ids)
missing = {
row["parent_span_id"]
for row in rows
if row.get("parent_span_id")
and row["parent_span_id"] not in known
}
print({
"records": len(rows),
"trace_id": next(iter(trace_ids)),
"external_or_missing_parents": sorted(missing),
})
PY
Проверка является примером для набора, где каждый span представлен одной записью. Если сервис пишет события начала и завершения с одним span ID, проверяйте уникальность списка span-описаний, а не всех строк. Родитель входного корневого span также может отсутствовать в локальной выборке.
Как локализовать сбой
| Последнее наблюдаемое событие | Вероятная зона поиска |
|---|---|
model.tool_selected, но нет tool.started |
Планировщик агента, очередь или MCP-клиент |
tool.started, но внутреннее API не видит trace ID |
Формирование или передача traceparent |
| API завершилось успешно, инструмент сообщил ошибку | Разбор ответа API или постобработка MCP |
| Инструмент успешен, но агент отвечает иначе | Передача результата инструмента модели или финальная генерация |
| Есть изменение сущности, но нет родительского span | Потеря контекста перед бизнес-операцией или фоновая задача |
Это диагностические ориентиры, а не доказательство причины. Сверяйте временные метки, исход операции, повторные попытки и журналы очередей.
Типовые ошибки
- Один span ID на весь запрос
- Так невозможно отделить задержку модели от MCP и внутреннего API. Trace ID общий, span ID создаётся заново для каждого шага.
- Новый trace ID внутри MCP
- Связь с агентом теряется. Новый trace ID допустим только при отсутствии, недоверии или повреждении входного контекста; отказ нужно журналировать.
- Trace ID используется как ключ идемпотентности
- Повторная попытка может оставаться в той же трассе, но быть отдельной попыткой. Для защиты бизнес-операции нужен самостоятельный ключ.
- Контекст хранится в глобальной переменной
- Параллельные запросы перемешиваются. Передавайте контекст явно либо используйте безопасный для асинхронного выполнения механизм локального контекста.
- В журнал попадают аргументы инструмента целиком
- Это создаёт риск утечки данных. Используйте разрешённый список полей, маскирование и ограничения размера.
- Успех записывается до фиксации изменения
- Событие бизнес-успеха следует писать после подтверждённой транзакции. Иначе журнал утверждает то, чего система могла не сохранить.
- Повторные попытки неразличимы
- Оставляйте общий trace ID, создавайте новый span для каждой попытки и добавляйте числовое поле
attempt.
Ограничения
- Trace ID показывает связь событий, но сам по себе не гарантирует полноту или порядок доставки журналов.
- Сэмплирование может удалить часть span-записей. Ошибочные и бизнес-критичные операции обычно требуют отдельной политики хранения.
- Асинхронная очередь разрывает обычный стек вызовов. Контекст нужно помещать в метаданные сообщения, проверять у потребителя и создавать дочерний span.
- Для долгоживущих процессов одна трасса может стать чрезмерно большой. В таком случае операции разделяют, а связь сохраняют отдельным correlation ID или ссылкой между трассами.
- Единый trace ID не заменяет метрики, аудит, журнал транзакций и контроль доступа.
- Часы сервисов могут расходиться. Дерево parent-child надёжнее простого упорядочивания по временным меткам.
Минимальный критерий готовности
Решение можно считать рабочим, если по trace ID инженер находит решение вызвать инструмент, выполнение MCP, обращение к API и подтверждённое изменение бизнес-сущности; видит родительские связи, длительность, повторные попытки и точку ошибки — без чтения промптов и поиска по приблизительному времени.
Дальнейшие материалы собраны в разделе практических руководств, а определения observability, span, correlation ID и других терминов — в глоссарии.