
Наша исходная задача звучала просто: проверить, как Proto Observability Platform принимает и показывает GenAI-трейсы. Строить для этого ещё одно демо LLM-приложение не хотелось, поэтому мы взяли OpenTelemetry Demo 3.0.
OpenTelemetry Demo представляет собой официальный микросервисный стенд, приближенный к реальному распределённому приложению. Мы и раньше использовали его для проверки ProtoOBP: сервисы магазина давали готовые трейсы и метрики без необходимости собирать собственное демо. В версии 3.0.0, вышедшей 24 июля 2026 года, к ним добавились сервисы chatbot, agent и mcp, а вместе с ними ИИ-спаны с атрибутами gen_ai.*. Поэтому обновлённый стенд стал естественной отправной точкой для нового теста.
Ожидали получить готовый эталонный поток телеметрии, настроить экспорт OTLP и перейти к проверке интерфейса.
Подключили и запустили, GenAI-спаны появились, но сквозного трейса не получилось.
Вместо ожидаемой цепочки:
chatbot > agent > mcp > frontend > сервисы магазина
спаны chatbot и agent оказались в одном трейсе, а сервис mcp начинал новый. Формально телеметрия была, но ответить на главный вопрос было невозможно: что происходило с одним пользовательским запросом от чата до вызванного инструмента?
В этой статье разберём, где именно терялся контекст, почему обычная автоматическая HTTP-инструментация не спасла этот сценарий и какой небольшой слой над MCP ClientSession собрал цепочку обратно.
Почему мы начали с OpenTelemetry Demo
Нам был нужен не синтетический спан с парой атрибутов gen_ai.*, а реалистичный агентный сценарий:
пользователь отправляет запрос;
агент вызывает модель;
модель решает использовать инструмент;
агент получает список MCP-инструментов и вызывает нужный;
MCP-сервис обращается к обычному сервису приложения;
модель формирует финальный ответ.
Такой сценарий одновременно проверяет семантику GenAI-спанов и обычную задачу распределённой трассировки: сохранится ли причинно-следственная связь при переходе между сервисами, протоколами и библиотеками.
OpenTelemetry Demo подходит для этого лучше самодельного стенда. Он публичен, воспроизводим и знаком инженерам, которые оценивают платформы наблюдаемости. Кроме того, это зрелый проект с большим числом компонентов и участников. Расхождение с ожиданиями оказалось особенно интересным - тот же класс проблем легко встретить и в прикладном агентном LLM-сервисе.
Область вывода ограничена конкретной связкой OpenTelemetry Demo 3.0.0: сервис agent на Python, langchain-mcp-adapters, MCP Streamable HTTP и используемая инструментация. Речь не идёт о дефектах OpenTelemetry или MCP в целом.
Первый результат: данные есть, истории нет
Для интеграции с ProtoOBP мы направили трейсы и метрики из OpenTelemetry Collector по OTLP/gRPC в агент ProtoOBP. Встроенный стек наблюдаемости демо нам для этой проверки не требовался: хранение, поиск и визуализацию выполнял ProtoOBP.
После первого запуска картина выглядела обманчиво благополучно:
chatbotсоздавал спан пользовательского запроса;agentпоказывал HTTP-запрос и выполнение процесса LangGraph;mcpтоже отправлял серверные спаны и спаны инструментов.
Но ID трейсов у цепочек не совпадали. Наличие всех спанов ещё не означает наличие распределённого трейса. Без единой цепочки вызовов невозможно увидеть работу MCP и его инструментов в связке с пользовательским запросом, посчитать критический путь запроса или связать ошибку инструмента с исходным запросом к модели.
Первый трейс начинался в chatbot, переходил в agent и заканчивался после выполнения инструмента внутри agent. На скриншоте в нём восемь спанов и только два сервиса: chatbot и agent. Ни mcp, ни вызванных им сервисов магазина в дереве нет.

В ту же секунду возникал второй трейс с другим ID, его корнем уже был tools/call /get_ads в сервисе mcp, а в цепочке вызовов находились frontend и ad. Система видела обе половины запроса, но как две независимые истории.

На стенде такую корреляцию мы провели глазами просто по совпадающему времени трейсов, но на боевой системе - это нереально, нужно искать причину и чинить - что-то сломалось в момент перехода от agent к mcp.
Как «склеивается» трейс
Принимающая сторона может продолжить исходный трейс, только если вызывающая сторона передаст ей контекст.
Для W3C Trace Context основным полем служит traceparent, необязательный tracestate переносит дополнительные данные системы трассировки. В HTTP оба значения передаются в одноимённых заголовках. Типичная HTTP-инструментация создаёт клиентский спан и внедряет текущий контекст в исходящий запрос. На принимающей стороне инструментация извлекает контекст и создаёт серверный спан с удалённым спаном в качестве родителя.
В нашей связке серверная часть opentelemetry-instrumentation-mcp==0.62.1 извлекала контекст трассировки из объекта meta MCP-запроса. Клиентская обертка добавляла туда traceparent, только если объект meta уже существовал. Между тем langchain-mcp-adapters==0.3.0 вызывал и listtools(), и call_tool() без _meta.
У инструментации был и второй механизм - обёртка транспорта Streamable HTTP могла сама создать meta и внедрить контекст перед отправкой. Но в OpenTelemetry Demo функция streamablehttp_client импортировалась до вызова Traceloop.init(). Инструментация позднее оборачивала функцию в модуле MCP, а уже импортированная локальная ссылка оставалась прежней. Поэтому транспортная обёртка не участвовала в этом вызове.
Дальше запрос переходил через внутренний поток в долгоживущую задачу post_writer, созданную при подключении MCP-клиента. Именно из неё запускалась отдельная задача с HTTP POST. Контекст текущего выполнения агента в сообщение не попал, поэтому HTTP-инструментация на нижнем уровне уже не могла связать запрос с активным спаном agent. MCP-сервер получал запрос без traceparent в _meta и начинал новый трейс.
Это и было корнем проблемы:
активный спан agent
│
├── MCP-запрос без _meta.traceparent
│
└── фоновая задача транспорта без нужного контекста
!
новый трейс в MCP
Исправление: внедрить контекст на уровне MCP-запроса
Мы добавили TraceContextClientSession, который наследует стандартный MCP ClientSession. Перед отправкой запроса он берёт активный контекст OpenTelemetry и записывает его в контейнер метаданных с помощью стандартного механизма распространения контекста. Ниже приведены сокращённые фрагменты реализации:
from opentelemetry import propagate class TraceContextClientSession(ClientSession): @staticmethod def _trace_metadata(metadata=None): carrier: dict[str, str] = {} propagate.inject(carrier) return {**(metadata or {}), **carrier}
При стандартной конфигурации в carrier появляется traceparent, если используется tracestate, механизм распространения добавляет и его. Мы не собираем заголовок вручную и не привязываемся к конкретному формату ID. Этим занимается OpenTelemetry.
Дальше нужно было обработать два MCP-пути. Для вызова инструмента библиотека предоставляет аргумент meta:
async def call_tool( self, name, arguments=None, read_timeout_seconds=None, progress_callback=None, *, meta=None, ): return await super().call_tool( name, arguments, read_timeout_seconds, progress_callback, meta=self._trace_metadata(meta), )
А для list_tools метаданные находятся внутри параметров запроса, поэтому их пришлось сохранить и дополнить отдельно:
async def list_tools(self, cursor=None, *, params=None): metadata = self._trace_metadata( params.meta.model_dump(exclude_none=True) if params and params.meta else None ) request_params = types.PaginatedRequestParams( cursor=params.cursor if params else cursor, _meta=types.RequestParams.Meta(**metadata), ) return await super().list_tools(params=request_params)
Наконец, в MCPClient стандартная сессия заменяется на новую:
# Было session_context = ClientSession(read, write) # Стало session_context = TraceContextClientSession(read, write)
На стороне MCP дополнительной логики передачи контекста не потребовалось: установленная инструментация уже умела извлекать контекст из _meta. Ей просто наконец передали то, что она ожидала.
Из этого следует более общий принцип. Инструментации только транспорта недостаточно, если библиотека создаёт собственную асинхронную границу или протокол определяет отдельное место для метаданных. Контекст лучше внедрять там, где ещё доступен активный контекст текущего выполнения и формируется запрос к следующему сервису или в очередь.
Как мы проверяли результат
Для проверки отправили в чат запрос, который гарантированно требует обращения к инструменту:
What current promotions are available on binoculars?
Чат-бот ответил, что на Roof Binoculars действует скидка 50%, и вернул ссылку на товар. С точки зрения пользователя это обычный короткий диалог. Дальше проверим, можно ли восстановить всю внутреннюю цепочку, которая сформировала этот ответ.

Мы нашли свежий трейс POST /prompt в ProtoOBP.
После исправления один пользовательский запрос собрался в цепочку из 27 спанов продолжительностью около 240 мс. В одном дереве оказались:
входной
POST /promptотchatbot;процесс
agentи LangGraph;вызовы модели
ChatLLM.chat;получение списка MCP-инструментов;
вызов выбранного инструмента;
запрос MCP к
frontendAPI;вызов нижележащего сервиса магазина
adдля ответа на вопрос.

Для проверки передачи контекста посмотрим соседние HTTP-спаны на границе сервисов. Клиентский спан создан в mcp: span.kind=client, запрос GET /api/data, peer.hostname=frontend, ответ HTTP 200.

Следующий спан создан на принимающей стороне в frontend: span.kind=server, тот же GET /api/data, HTTP 200. Он расположен непосредственно под клиентским спаном MCP в дереве вызовов, а ниже продолжается обработка маршрута и вызов AdService. Это ожидаемое поведение на границе распределённого трейса: клиентский спан одного сервиса связан с серверным спаном другого, а не начинает новую историю с новым ID трейса.

Собирая и анализируя трейсы, клиентские и серверные спаны и их атрибуты, ProtoOBP восстанавливает зависимости между сервисами. Для POST /prompt платформа построила схему транзакции с шестью участниками: chatbot, agent, внешней моделью, mcp, frontend и ad. Пять связей складываются в непрерывный маршрут от чат-бота до внутреннего рекламного сервиса. Внешнюю модель мы подключили уже после первоначального исправления передачи контекста.

В дереве хорошо видны структура и зависимости спанов. Для оценки последовательности вызовов, их длительности и перекрытий по времени полезен флейм-граф.
Для следующей проверки мы взяли другой запрос, о поддерживаемых валютах. Он прошёл через сервис currency и собрался в трейс из 26 спанов.

Сквозная трассировка была нужна нам прежде всего для проверки ИИ-спанов. В том же трейсе можно открыть атрибуты gen_ai.input.messages и gen_ai.output.messages: аргументы вызванного инструмента и полученный результат.

Для демо это наглядно. В промышленной среде такие атрибуты требуют отдельного обсуждения защиты данных. Запросы к модели, её ответы, аргументы инструментов и их схемы могут содержать персональные данные, коммерческую информацию или секреты. Полноту GenAI-телеметрии нужно сочетать с маскированием, выборочным сбором и политикой доступа к трейсам.
Почему обычного дерева недостаточно
Обычные представления показали важную часть этого эксперимента. Запрос о скидках прошёл по цепочке chatbot > agent > mcp > frontend > ad, а запрос о валютах дошёл до currency. В обоих случаях контекст нигде не оборвался. Для классической распределённой трассировки это уже большая часть задачи - можно искать ошибки, задержки и проблемную границу между сервисами.
Но у ИИ-трейса быстро появляются другие вопросы:
какая модель участвовала в запросе и сколько раз её вызывали;
сколько времени заняла модель, а сколько времени заняли инструменты;
какие инструменты были доступны агенту и какой из них он выбрал;
как выглядела последовательность
модель > инструмент > модель;какой запрос привёл к вызову инструмента и какой ответ получился в итоге.
Искать всё это по десяткам или сотням обычных спанов и длинным спискам атрибутов можно, но неудобно. Поэтому в ProtoOBP мы сделали отдельное представление ИИ-трейса. Один и тот же запрос в нём можно рассматривать через модель, доступные и вызванные инструменты, распределение времени, запросы и ответы. Отдельный граф показывает связи уже не между сервисами, а между операциями модели и инструментов.

Здесь видно, что агент использовал azure/gpt-5.5, выполнил девять GenAI-операций и вызвал один инструмент. Запросы и ответы воспроизводились из записи, поэтому для этого запуска API-ключ не требовался. Позже для экспериментов мы отдельно подключили DeepSeek.
Подход к проектированию этого представления станет темой следующей статьи. В ней разберём, на какие вопросы должна отвечать платформа наблюдаемости за ИИ-приложениями, как мы сопоставляем операции модели и инструментов и к каким представлениям трейсов пришли.
Что показал эксперимент и чего он не показывает
Эксперимент подтвердил, что ProtoOBP принимает и визуализирует смешанный трейс, где GenAI/MCP-операции связаны с обычными HTTP- и сервисными спанами. Мы можем пройти от пользовательского запроса к конкретному вызову нижележащего сервиса, увидеть длительности и изучить GenAI-атрибуты в том же контексте.
Небольшой финальный дисклеймер:
исправление проверено с
mcp==1.28.1,langchain-mcp-adapters==0.3.0иopentelemetry-instrumentation-mcp==0.62.1, при обновлении поведение_metaнужно перепроверить;мы не утверждаем, что во всех реализациях транспорта MCP контекст нужно передавать именно так.
Наше исправление демо приложения обеспечивает передачу контекста в выбранном клиенте. В долгосрочной перспективе такая логика должна жить как можно ближе к библиотеке или официальной инструментации. Иначе каждый пользователь будет заново находить одну и ту же асинхронную границу.
Выводы
Мы превратили набор корректно экспортируемых, но разрозненных спанов в единый агентный трейс. Само исправление оказалось небольшим. Основное время ушло на поиск уровня, на котором действительно теряется контекст, и подготовку воспроизводимого стенда.
Из этой истории мы вынесли пять практических правил:
Наличие всех спанов не равно сквозной трассировке. Всегда проверяйте ID трейса, родительские связи и полноту охвата сервисов в одной цепочке.
При разрыве трейса ищите семантическую границу компонента или протокола. Асинхронные очереди и обращения к базам данных тоже могут оказаться местом потери контекста.
Если транспорт отправляет запрос из фоновой задачи, проверьте, дошёл ли до неё активный контекст.
Наблюдаемость ИИ-приложений полезна только вместе с обычной распределённой трассировкой: вызов инструмента ценен в связи с запросом, решением агента и нижележащим сервисом.
Сквозной контекст необходим, но обычного дерева спанов недостаточно, чтобы быстро разбирать поведение модели и выбор инструментов.
Для нас OpenTelemetry Demo 3.0 оказалось хорошим стартом - в нём обнаружился реальный разрыв контекста, который было полезно исследовать. После небольшой доработки стенд стал гораздо полезнее для демонстрации и проверки платформ наблюдаемости.