Куда делись 4 секунды: разбираем автоинструментацию в APM. apm-мониторинг.. apm-мониторинг. opentelemetry.. apm-мониторинг. opentelemetry. tracing.
 4 секунды, это вы?

4 секунды, это вы?

Вместо введения.

Когда приложение состоит из одного процесса и нескольких десятков методов, найти причину медленной работы относительно просто: можно включить профилировщик, посмотреть логи и найти проблемный участок кода.

В распределённой системе всё меняется.

Один пользовательский запрос может пройти через API Gateway, несколько микросервисов, очередь сообщений, базу данных и внешний API. При этом каждый компонент может находиться на отдельном сервере или в отдельном контейнере.

Если такой запрос выполняется 4 секунды, возникает вполне практический вопрос: где именно были потрачены эти 4 секунды? На сети? На базе данных? На блокировке потока? На выполнении конкретного метода? На внешнем сервисе?

Именно для ответа на этот вопрос в APM используются трассировка (distributed tracing) и инструментация приложений.

Один из наиболее интересных подходов — автоматическая инструментация, при которой разработчику не требуется вручную добавлять tracing-код в каждый сервис.

Разберём, как это работает на уровне архитектуры, что происходит внутри приложения на разных языках и где у автоматизации проходят границы.

Что такое трассировка

Начнём с базовых понятий. В distributed tracing каждый пользовательский или системный запрос представляется как trace — распределённая трасса, то есть полный маршрут прохождения запроса через систему. Trace состоит из отдельных операций — span (каждый отдельный вызов: на фронте, на бэке, на шине).

упрощённо это можно представить так

упрощённо это можно представить так

Каждый span описывает отдельную операцию:

Span: trace_id, span_id, parent_span_id, start_time, duration, operation, service, status, attributes

Например:
trace_id: 4bf92f3577b34da6…
span_id: 00f067aa0ba902b7
parent_span_id: 7a085853722dc6…
operation: POST /api/orders
duration: 183 ms
status: OK

Связь между span формирует дерево выполнения. При этом trace не обязательно ограничивается одним процессом. Именно поэтому возникает самая интересная часть задачи: как система понимает, что запрос, который сейчас обрабатывается в Service B, является продолжением запроса, который несколько миллисекунд назад обрабатывался в Service A?

Trace context: как связать несколько сервисов

Представим цепочку: Client → Service A → Service B.

Service A создаёт trace и span: Trace ID = ABC123, Span ID = 111.

Когда Service A отправляет HTTP-запрос в Service B, tracing-инструментация добавляет в запрос специальный контекст. Современный стандартный вариант — W3C Trace Context: в запрос добавляется заголовок traceparent. Упрощённо: traceparent: 00-ABC123-111-01 (в реальности trace-id — это 32 hex-символа, а span-id — 16). Service B получает запрос и извлекает из него trace context. После этого он создаёт дочерний span: Trace ID = ABC123, Span ID = 222, Parent = 111.

Trace ABC123

└── Service A (span 111)

    └── Service B (span 222, parent 111)

Таким образом, отдельные процессы начинают выглядеть для APM как единая цепочка выполнения. Это фундаментальный механизм distributed tracing. Ключевой момент здесь заключается в том, что инструментация должна не только измерять время выполнения операций, но и корректно переносить контекст между компонентами системы.

Контекст за пределами HTTP

С HTTP всё относительно просто: заголовок traceparent едет вместе с запросом. Сложнее с асинхронными сценариями.

Очереди (Kafka, RabbitMQ). Контекст записывается в заголовки сообщения: продюсер добавляет traceparent, консьюмер извлекает его и создаёт span, связанный с исходной трассой. Даже если сообщение пролежало в очереди несколько минут, связь между отправкой и обработкой сохраняется.

Пулы потоков. Задача создаётся в одном потоке, а выполняется в другом. Инструментация должна «завернуть» задачу так, чтобы вместе с ней в поток пула переехал и текущий контекст, иначе трасса рвётся.

Именно такие места чаще всего ломаются при ручной инструментации, их автоинструментация и берёт на себя.

Но кто создаёт эти span? Есть два принципиально разных подхода.

Подход №1. Ручная инструментация

Разработчик сам добавляет в код создание span: где начинается операция, где заканчивается, какие атрибуты записать. Например, на Java с OpenTelemetry SDK это выглядит так:

Span span = tracer.spanBuilder(“processOrder”).startSpan();

try (Scope scope = span.makeCurrent()) {

    span.setAttribute(“order.id“, orderId);

    processOrder(orderId);

} finally {

    span.end();

}

Плюсы такого подхода:

• полный контроль;
• можно инструментировать собственную бизнес-логику;
• можно добавлять специфические атрибуты.

Но есть и проблема. В реальном enterprise-приложении таких точек могут быть тысячи. Кроме того, разработчику нужно самостоятельно учитывать:

• HTTP;
• JDBC;
• Kafka;
• Redis;
• gRPC;
• очереди;
• асинхронные операции;
• thread pools;
• внешние API.

В результате наблюдаемость становится частью application code. Чтобы выпустить приложение в прод с наблюдаемостью внутри, нужно закладывать много дополнительного времени. Что-то можно и забыть. А с каждым новым релизом придётся обновлять и разметку.

Отдельно стоит сказать об OpenTelemetry. Сегодня это фактический стандарт observability. То есть набор стандартных API, SDK и библиотек, который единообразно описывает, как собирать трассировки.

Можно размечать код вручную через SDK, как в примере выше, а можно подключить готовые агенты автоинструментации.

Плюсы очевидны: единый формат данных и независимость от вендора. Но есть и ограничения:

  • автоинструментация OpenTelemetry покрывает популярные библиотеки и фреймворки, а бизнес-логику всё равно приходится размечать вручную;

  • OpenTelemetry отвечает за сбор и передачу телеметрии, но не за её анализ. Корреляция с инфраструктурой, построение топологии, поиск первопричины — для этого нужна отдельная платформа.

Подход №2. Автоматическая инструментация

Рассмотрим, как это устроено технически и как это работает у нас в Ключ-АСТРОМ.

Идея достаточно проста: если система знает, какие библиотеки и фреймворки использует приложение, ей не обязательно ждать, пока разработчик вручную добавит instrumentation code. Можно автоматически перехватывать интересующие операции.

Например: Application → HTTP request → Controller → Business logic → JDBC → PostgreSQL.

На каждом этапе инструментация добавляет точки наблюдения, и приложение превращается в источник структурированных telemetry-данных.

Где именно происходит автоинструментация

Конечно, это зависит от языка программирования. Условно можно выделить несколько механизмов.

Java

Для Java одним из распространённых вариантов является bytecode instrumentation. Java-приложение работает поверх JVM, поэтому агент может модифицировать bytecode классов таким образом, чтобы добавить необходимую instrumentation-логику.

Разработчик при этом исходный код не меняет. Инструментация происходит на уровне выполнения приложения.

Технически в параметры запуска приложения добавляется jar-файл агента (-javaagent), который стартует вместе с приложением. Так, например, работает OpenTelemetry Java agent.

Способ давно известен и широко используется, но у него есть ограничения:

  • приложение всё-таки модифицируется: в классы вставляется байт-код, и ошибка в инструментации может повлиять на работу приложения;

  • инструментация добавляет overhead, поэтому многие решения по умолчанию сэмплируют данные и видят не каждый запрос;

  • набор данных ограничен тем, что можно получить через вставленный код: события GC, аллокации, блокировки агенту напрямую не видны;

  • в ряде реализаций новые настройки (новый entry point или извлечение нового атрибута) применяются только после рестарта приложения.

Есть и другой путь, который мы используем в Ключ-АСТРОМ, — нативный агент на базе JVMTI.

JVMTI (JVM Tool Interface) появился ещё в Java 5, одновременно с механизмом java-агентов, но работает на другом уровне. Агент подключается к JVM как нативная библиотека (-agentpath) и получает доступ к внутренним событиям виртуальной машины.

Процесс сбора информации тоже отличается. Java-агент видит только то, что сообщает вставленный им код. Нативный агент дополнительно получает callback-уведомления от самой JVM. Что это даёт:

  • доступ к событиям, которые обычному Java-агенту не видны без внедрения кода в каждый метод;

  • возможность выполнять memory profiling: аллокации, работа GC, состояние heap;

  • гибкое управление overhead: агент сам решает, где достаточно событий JVM, а где нужна вставка кода;

  • возможность «на лету» добавлять новые точки сбора информации, без рестарта приложения.

    JVMTI позволяет также сильно расширить набор собираемых событий, включая:

    • Вызовы методов (вход/выход). Это возможно без модификации байт-кода, но дорого, поэтому на практике используется точечно.

    • Создание и удаление объектов, перемещение объектов сборщиком мусора.

    • Начало/окончание сборки мусора (с деталями о поколениях).

    • Загрузка/выгрузка классов, создание потоков, блокировки, мониторы.

    • Обработка исключений, изменение состояния VM, завершение работы.

    Java Agent не умеет получать эти события напрямую — для их эмуляции пришлось бы внедрять инструментирующий код в каждый метод, что дорого и ненадёжно.

.NET

В .NET роль, аналогичную JVMTI, играет CLR Profiling API (ICorProfiler). Агент подключается к среде исполнения через переменные окружения при старте процесса, получает уведомления от CLR и может переписывать IL-код методов на этапе JIT-компиляции. На этом же механизме построена и автоинструментация OpenTelemetry для .NET.

В Ключ-АСТРОМ для .NET мы используем два дополняющих друг друга подхода.

Во-первых, агент мониторинга размещает трассировочные операторы в основных точках кода (методах, вызовах зависимостей), обеспечивая сквозную трассировку транзакций и отслеживание зависимостей без изменения исходного кода приложения.

Во-вторых, работает always-on CPU-профилирование, которое собирает данные о горячих методах (method hotspots) и узких местах в стеке вызовов. Также агент собирает метрики сборки мусора (по поколениям Gen 0/1/2), размеры managed-памяти и другие процессные показатели.

Node.js

В Node.js ситуация ещё интереснее из-за асинхронной модели выполнения. Инструментация должна учитывать не только вызовы функций, но и сохранение контекста между асинхронными операциями. Важно не потерять связь: HTTP request → await DB → await Payment API → HTTP response. Для этого используется механизм async_hooks / AsyncLocalStorage, который позволяет «протащить» контекст трассы через цепочку промисов и колбэков.

Ключ-АСТРОМ поддерживает автоматическую инструментацию Node.js, включая наблюдение за HTTP-вызовами, базами данных и другими операциями. При этом у Node.js есть объективные ограничения, связанные с асинхронной моделью и особенностями самого runtime, так что автоинструментация здесь — не просто «вставить несколько строк кода».

Node.js работает на движке V8, поэтому агент внедряется в процесс и использует возможности рантайма для сбора данных. Ключевые возможности профилирования включают CPU-сэмплирование, метрики event loop (задержки, загрузка), а также heap-метрики и heap dumps. Отслеживаются входящие и исходящие HTTP-вызовы, запросы к поддерживаемым базам данных с захватом текста запросов, а также метрики worker threads (continuous thread analysis). Поскольку Node.js часто используется как прокси-слой между сервисами, агент автоматически выстраивает связи между вызовами, позволяя видеть распределённые транзакции в контексте всей цепочки.

Go

Go компилируется напрямую в машинный код, у него нет виртуальной машины с готовым интерфейсом для перехвата. Поэтому автоинструментация в Go устроена иначе: агент перехватывает вызовы функций на уровне скомпилированного бинарника, без перекомпиляции приложения. Для сравнения: в OpenTelemetry для Go автоинструментация строится либо на eBPF-зондах (uprobes), либо на инструментации во время сборки. Это обеспечивает full code-level visibility: агент мониторинга различает код, выполняемый в контексте распределённой трассировки (например, обработчик входящего HTTP-запроса), и фоновые активности.

Каждый входящий запрос в net/http порождает новую горутину — она и связанные с ней горутины помечаются как service-related, тогда как остальные учитываются как background. В Ключ-АСТРОМ агент оптимизирован для работы с параллелизмом Go и избегает неявных точек синхронизации между горутинами при сборе данных. Также собираются специфичные для Go метрики: размеры heap (offheap, stack), количество объектов, вызовы GC, состояние worker-потоков и очередей горутин.

Автообнаружение процессов

Осталось разобраться, как агент вообще узнаёт, какой процесс и на каком языке ему инструментировать. В Ключ-АСТРОМ за это отвечает автоматическое обнаружение процессов с последующей активацией соответствующих code modules для поддерживаемых технологий. Один агент на хосте может собирать данные сразу для нескольких типов сущностей и приложений.

Это принципиально отличается от модели «установить отдельный агент + изменить код приложения + подключить SDK + настроить каждый сервис».

Вместо этого получается: установить агент → обнаружить процесс → определить технологию → активировать code module → инструментировать поддерживаемые операции → собрать телеметрию. Именно автоматическое прохождение этой цепочки делает подход интересным для больших инфраструктур.

При запуске процесса агент определяет, что именно было запущено, и, если процесс поддерживается и не исключён правилами, устанавливает связь с соответствующим модулем глубокого мониторинга. В результате инструментация становится частью работающего процесса. После внедрения агент получает возможность наблюдать за интересующими операциями непосредственно внутри процесса.

Комбинирование подходов

У автоинструментации есть естественные границы: самописные протоколы, нестандартные фреймворки, внутренняя бизнес-логика. Например, «рассчитать скоринг клиента» для агента — просто цепочка вызовов методов, без бизнес-смысла. В таких местах автоматическое покрытие дополняют ручной разметкой.

Поэтому Ключ-АСТРОМ не ограничивается собственной моделью инструментации. Единый агент может работать совместно с OpenTelemetry и объединять OpenTelemetry spans с данными, полученными собственной инструментацией. Для этого предусмотрены механизмы настройки span capturing, context propagation и entry points. Получается гибридная модель.

Заключение

Если коротко, автоинструментация превращает обычное приложение в источник подробной телеметрии, плюс никому не нужно вручную расставлять tracing-код по всему проекту. Под капотом всё выглядит примерно так:

Application → Process discovery → Technology detection → Code instrumentation → Operation interception → Span generation → Context propagation → Distributed trace → Service detection → Topology → Code-level visibility → Problem investigation

Но смысл не в том, чтобы просто «поставить агент», а в том, когда агент понимает, что происходит внутри приложения, связывает операции между сервисами и не теряет нить запроса: от клика пользователя до конкретного метода, SQL-запроса или внешнего API. Этот шаг от «сервер жив, CPU в норме» к «вот путь, по которому прошёл запрос» — главная идея современного APM.

В Ключ-АСТРОМ автоинструментация не живёт отдельной фичей, она встроена в общую картину. Агент сам находит процессы, подключает подходящие модули, собирает данные на уровне кода, связывает их через distributed tracing и строит по ним карту сервисов и зависимостей. А где автоматического покрытия недостаточно или оно невозможно, его дополняют OpenTelemetry и ручной разметкой.

Получается вполне практичный рецепт: сначала собрать автоматически максимум полезной информации → стандартизировать то, что должно быть стандартизировано → вручную дополнить только действительно уникальные участки приложения.

В больших распределённых системах команда переносит фокус с вопроса «Как нам вообще это инструментировать?» на более полезный: «Где именно потерялись эти 4 секунды?»

Автор: AmidN

Источник