# Трейсинг AI-агента в Langfuse: tools, retry и ошибки

> Проверенный Python-паттерн для трейсов AI-агента: parentage для tools, отдельные retry, child errors, маскирование данных и изоляция exporter.
> Author: Roman Belov · Published: 2026-09-26 · Source: https://futurecraft.pro/ru/blog/langfuse-agent-tool-tracing/

Шесть тестов проходили, workflow возвращал правильный ответ, но в Langfuse появлялся
только root span. Ни retrieval, ни generations, ни tool calls до сервера не доходили.

Проблема оказалась в одном имени поля. Fake-клиент и adapter ожидали
`observation_id`, а установленный Langfuse Python SDK `4.14.5` отдавал ID span через
`id`. Тестовый двойник подтверждал нашу же ошибочную гипотезу. Только запуск против
реального self-hosted сервера показал, что parentage сломан.

После исправления рабочая схема выглядит так:

> Создайте один root observation для задачи пользователя. Retrieval, model steps и
> каждый tool attempt пишите отдельными children. Ошибку оставляйте на конкретном tool,
> итог задачи — на root. Чувствительные данные удаляйте до отправки telemetry.

Здесь не будет повторной установки Langfuse. Для неё есть
[отдельный пошаговый туториал](/ru/blog/langfuse-step-by-step/). Метрики, alerts и
операционная модель разобраны в
[гайде по LLM observability в production](/ru/blog/llm-observability-langfuse/).

## Какой workflow мы проверили

Fixture имитирует ответ службы поддержки на вопрос о возврате. Все данные синтетические,
model responses записаны заранее, tools детерминированы. Так повторный запуск проверяет
структуру trace, а не случайное поведение провайдера.

```text
answer-support-question
├── retrieve-account-policy
├── draft-plan
├── tool:get-subscription
├── tool:get-refund-window
└── compose-answer
```

Окружение зафиксировано 26 августа 2026 года:

| Компонент | Версия |
| --- | --- |
| Langfuse Python SDK | `4.14.5` |
| Self-hosted Langfuse | `3.205.0` |
| Python | `3.13.9` |
| Fixture release | `6401879` |

На удалённом сервере прошли три сценария:

1. happy path — оба tools отвечают;
2. первый вызов subscription tool завершается по timeout, второй успешен;
3. refund tool возвращает terminal error, после чего ответ уходит на human review.

В отдельном процессе exporter был направлен на недоступный `127.0.0.1:9`. Приложение
всё равно вернуло тот же fixture result. Это не обещание, что telemetry никогда не
потеряется. Результат уже: в проверенном workflow сбой exporter не изменил поведение
приложения.

![Санитизированная схема Langfuse trace с двумя отдельными retry attempts](/artifacts/langfuse-agent-tool-tracing/sanitized-trace-shape-2026-08-26.svg)

## Root — это задача пользователя, а не model call

Удобная граница trace — действие, результат которого видит пользователь: ответить на
тикет, составить маршрут, проверить pull request. Если сделать корнем только один
model call, retrieval и tools окажутся рядом, но не внутри той же причинной цепочки.

Согласно актуальному
[описанию Langfuse SDK](https://langfuse.com/docs/observability/sdk/overview), trace
состоит из observations с общим trace ID, а сами observations отображаются на
OpenTelemetry spans. В одном call stack вложенность может передаваться автоматически.
В актуальном
[Python instrumentation guide](https://langfuse.com/docs/observability/sdk/instrumentation)
отдельно описан `trace_context` с `trace_id` и `parent_span_id`.

Мы выбрали explicit parentage. Такой adapter можно использовать и там, где события
приходят через queue или от другого service. Для child нужны реальные `trace_id` и
`id` родительского SDK wrapper:

```python
parent = self.observations[event.parent_id]
trace_context = {
    "trace_id": parent.trace_id,
    "parent_span_id": parent.id,
}

observation = self.client.start_observation(
    name=event.name,
    as_type=event.kind,
    trace_context=trace_context,
    metadata=safe_metadata(event),
    version=self.fixture_version,
    model="recorded-fixture-v1" if event.kind == "generation" else None,
)
```

В первой версии стоял `parent.observation_id`. Телеметрические исключения были
изолированы, поэтому основной workflow не падал. Побочный эффект неприятный: зеленый
ответ приложения маскировал неполный trace.

После замены на `parent.id` сервер получил root и всех children. Практический вывод:
если adapter сам управляет parentage, fake test недостаточно. Нужен хотя бы один
integration run с той версией SDK, которая пойдёт в production.

## Каждый retry — отдельный tool observation

Не обновляйте первую попытку результатом второй. Это две сетевые операции, даже если у
них одинаковое имя tool.

В timeout-сценарии сервер сохранил такую структуру:

```text
answer-support-question                 DEFAULT
├── tool:get-subscription  attempt=1    ERROR   TimeoutError
├── tool:get-subscription  attempt=2    DEFAULT
├── tool:get-refund-window attempt=1    DEFAULT
└── остальные успешные children         DEFAULT
```

У двух subscription calls общий parent, но разные observation IDs. Первая попытка
заканчивается до начала второй. Поэтому по trace видно и причину задержки, и успешное
восстановление.

Если оставить только финальный `attempt=2`, timeout исчезнет из истории. Если повторно
использовать observation с уровнем `ERROR`, успешная задача будет выглядеть сломанной.
Отдельные attempts не заставляют выбирать между этими фактами.

Минимальная логика выглядит так:

```python
for attempt in (1, 2):
    observation_id = f"subscription-{attempt}"
    # Application wrapper, not a Langfuse SDK method.
    trace.start_tool(
        observation_id=observation_id,
        name="tool:get-subscription",
        parent_id=root_id,
        attempt=attempt,
    )
    try:
        subscription = tools.get_subscription(account_id)
    except TimeoutError as error:
        trace.end_tool(
            observation_id=observation_id,
            level="ERROR",
            error_type=type(error).__name__,
        )
        if attempt == 2:
            raise
        continue

    trace.end_tool(observation_id=observation_id, level="DEFAULT")
    break
```

В полном fixture нет специального `start_tool`: там используется общий `TraceSink`.
Пример выше показывает decision point, а downloadable code — проверенную реализацию.

## Tool error и провал всей задачи — не одно и то же

Tool может упасть, а agent — повторить вызов или вернуть безопасный неполный ответ.
Поэтому level конкретной операции и outcome всей задачи нужно хранить отдельно.

В terminal-сценарии `tool:get-refund-window` получил `ERROR`. Успешный retrieval,
subscription lookup и обе generations остались `DEFAULT`. Root завершился как
`WARNING`: приложение сформировало ответ, но попросило человека подтвердить право на
возврат.

```text
tool:get-refund-window → ERROR: TerminalToolError
answer-support-question → WARNING: требуется human review
успешные siblings → DEFAULT
```

Это наша telemetry policy, а не обязательная семантика Langfuse. До настройки alerts
зафиксируйте свою таблицу:

| Observation | Level | Что означает в fixture |
| --- | --- | --- |
| Неудачный tool attempt | `ERROR` | Эта операция не завершилась |
| Восстановленный root | `DEFAULT` | Пользователь получил ожидаемый результат |
| Degraded root | `WARNING` | Есть безопасный, но неполный результат |
| Успешный sibling | `DEFAULT` | Его собственная операция завершилась |

Иначе количество child errors легко принять за количество проваленных пользовательских
задач. В общей
[архитектуре production LLM stack](/ru/blog/production-llm-stack/) нужны обе метрики:
operation failures и task outcomes.

## Маскирование должно происходить до export

Во входе fixture есть синтетические email и account ID. Они заменяются в момент
создания trace attributes — до передачи события Langfuse adapter. Поля observation
`input` и `output` в этом эксперименте вообще не отправляются.

```python
EMAIL_PATTERN = re.compile(
    r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}",
    re.I,
)
ACCOUNT_PATTERN = re.compile(r"\bacct_[A-Z0-9]+\b", re.I)


def redact_text(value: str) -> str:
    value = EMAIL_PATTERN.sub("[EMAIL_REDACTED]", value)
    return ACCOUNT_PATTERN.sub("[ACCOUNT_REDACTED]", value)
```

Эти regex знают только два формата из fixture. Для production нужен data inventory и
набор тестов под реальные identifiers продукта. В официальной
[документации по masking](https://langfuse.com/docs/observability/features/masking)
Langfuse рекомендует менять данные до выхода из приложения и описывает hooks для
inputs, outputs, metadata и OpenTelemetry attributes.

У публичного evidence есть ещё одна граница. В полном authenticated API response мы
нашли Langfuse public key внутри metadata SDK scope. Это не secret key, но в переносимом
примере он не нужен. Sanitizer теперь собирает новый объект по allowlist и отклоняет
файл, если в нём встречается любой из ключей.

Получается три независимых контроля:

- source redaction очищает значения до отправки;
- allowlist не копирует посторонние server metadata;
- финальный negative scan проверяет именно публикуемый файл.

## Exporter не должен управлять бизнес-результатом

Workflow принимает интерфейс `TraceSink`. Обёртка `SafeTraceSink` ловит ошибки delegate
и считает пропущенные events, не вмешиваясь в ответ приложения:

```python
@dataclass
class SafeTraceSink:
    delegate: TraceSink
    dropped_events: int = 0

    def record(self, event: TraceEvent) -> None:
        try:
            self.delegate.record(event)
        except Exception:
            self.dropped_events += 1
```

Langfuse пишет, что SDK errors перехватываются и логируются, а export выполняется
асинхронно. В
[Python instrumentation guide](https://langfuse.com/docs/observability/sdk/instrumentation)
для короткоживущих processes также указан явный `flush()`. Наш application guard не
заменяет поведение SDK — он отделяет контракт workflow от конкретного exporter.

Компромисс честный: приложение продолжит работать, но trace может оказаться неполным.
Dropped telemetry нужно считать отдельным operational signal. Ломать продукт ради
идеально заполненного dashboard — плохой обмен.

## Как повторить проверку

В [публичный artifact](/ru/artifacts/langfuse-agent-tool-tracing/) входят workflow,
Langfuse adapter, tests на стандартной библиотеке, integration runner, санитизированный
JSON и схема trace. Код опубликован под MIT, evidence report и diagram — под CC BY 4.0.

Сначала запустите contract suite без внешних dependencies. Команда выполняется из
директории со скачанными файлами:

```bash
python3 -m unittest discover -s . -p 'test_*.py' -v
```

Для integration run передайте отдельные project credentials через environment и
запустите зафиксированную версию SDK в isolated environment:

```bash
uv run --isolated --with langfuse==4.14.5 python \
  run_integration.py \
  --output /tmp/futurecraft-langfuse-evidence.json
```

Не печатайте credentials и не сохраняйте полный authenticated response. Runner
минимизирует данные в памяти, проверяет parentage и levels, ищет synthetic identifiers
и keys, затем записывает только безопасную структуру.

## Границы результата

- Recorded model и tools не показывают поведение live providers.
- Millisecond timestamps одного локального запуска — не benchmark latency.
- Одна пара SDK/server versions не гарантирует совместимость следующих releases.
- Два regex из fixture не являются универсальным PII detector.
- Полный trace не доказывает правильность ответа модели.

Проверено другое: explicit parentage собрало одну причинную цепочку; timeout и retry
остались отдельными observations; terminal child error не испортил статусы успешных
шагов; identifiers были удалены до export; недоступный exporter не изменил fixture
result.

Такой trace уже можно использовать в разборе инцидента. Он показывает не только
финальный ответ, но и путь, по которому agent к нему пришёл.
