Привет! Я Никита, Staff-инженер в крупном финтехе. В этой статье я хочу поделиться нашим опытом построения системы observability. Мы прошли путь от простых логов до сквозной трассировки, и я покажу, как это работает на фронтенде.
TL;DR: В статье разбираем опыт внедрения OpenTelemetry в крупном финтех-проекте.
Проблема: Логи без контекста не позволяют быстро найти причину 500-й ошибки в распределенной системе.
Решение: Сквозная трассировка (Distributed Tracing) от фронтенда до бэкенда.
Что внутри: Реализация CompositeLogger на TypeScript, патчинг fetch для сохранения контекста и примеры того, как превратить технические трейсы в карту бизнес-процесса. А именно - frontend реализация и практические детали интеграции.
Что мы хотели
У нас очень много процессов, через которые проходят пользователи. Логирования недостаточно для того, чтобы получать детальную информацию об ошибке, потому что лог не содержит контекст. Кроме того, часто логи сложно объединить и понять последовательность событий. А когда мы имеем дело с огромной распределенной системой, где есть несколько бекендов, разбор инцидентов превращается в ад.
OpenTelemetry хорошо подходит для решения нашей проблемы, потому что это стандарт и SDK, который позволяет добавить контекст и структурировать логирование. У нас уже была система логирования и мониторинга на этот момент, но она была достаточно примитивной и не давала функционала, о котором я написал выше.
Нам была нужна сквозная трассировка бизнес-процессов. Рассмотрим пример процесса на этой схеме:
Веб-модуль содержит форму для перевода денег. Форма может быть запущена несколько раз за время жизни веб-модуля. На форме есть шаги, и внутри этих шагов выполняются различные запросы к бекенду.

Мы хотим, чтобы весь процесс перевода денег, а по-хорошему - вся сессия внутри веб-модуля money-transfer-ui, логировалась. Тогда можно посмотреть логи, отфильтровать их,
и понять что происходило внутри.
Такие инструменты очень полезны при разборе инцидентов. В нашем случае они в десятки раз снизили время поиска ошибки в разрезе конкретного пользовательского пути конкретного пользователя.
Обязательно обфусцируйте чувствительные данные.
Никогда не логируйте их ни на фронтенде, ни на бекенде.
Что такое OpenTelemetry
OpenTelemetry - это, в первую очередь, протокол, который регламентирует формат сообщений. Кроме того, это реализация API, SDK и collector сервера, который уже может пересылать логи в конкретные системы типа jaeger, prometeus, kibana, grafana etc.
Детально с терминами OpenTelemetry можно ознакомиться в официальной документации тут . В этой статье я дам только общие понятия, необходимые для того, чтобы продолжить.
Основные понятия в нашем контексте это trace, span, event, attribute.
Trace. Корневая структура, внутри которой лежат дочерние элементы - span-ы. По своей сути trace это просто traceId, нужный для того чтобы объединять спаны в группы.
Span. Временной промежуток, у него есть время начала и время окончания, span может быть вложен в другой span, что и позволяет нам иметь детализацию и вложенность при дальнейшем сборке данных из процессов.
Event. Событие, точка во времени. Есть время события, а так же атрибуты события и ссылка на span в рамках которого произошло событие.
Attribute. Произвольная пара key-value. Сюда можно писать ваши данные, по которым можно будет фильтровать и производить поиск.
Архитектурное решение и типы
Архитектура системы
Сначала давайте разберемся с тем, как работает типичный сетап с opentelemetry.
Фронтенд SPA взаимодействует с продуктовыми бэкенд‑сервисами через HTTP API.
Платформенный бэкенд недоступен для фронтенда и работает только как продюсер/консьюмер сообщений в Kafka и для обмена данными с другими бэкенд‑сервисами (эти другие бекенд сервисы у нас называются продуктовыми бекендами, у вас это может быть BFF).
Для observability фронтенд отправляет трейсы через Audit Microservice, который проксирует их в OpenTelemetry Collector. Audit Microservice у нас используется для логирования, а теперь и трейсинга, чтобы не светить URL коллектора наружу.
Все бэкенд‑сервисы также отдают трейсы, метрики и логи в Collector.
Collector агрегирует данные и передаёт их в Jaeger (трейсы), Prometheus (метрики) и Kibana (логи), а Grafana используется для визуализации метрик и трейсов.

Такая схема позволяет централизованно собирать и анализировать observability-данные без прямого доступа фронтенда к платформенному слою. И, что немаловажно, внедрять такое решение по мере необходимости по всем микросервисам и фронтендам системы.
Архитектура frontend решения
Эту секцию можно пропустить если вас не интересует конкретика реализации решения на фронтенде.
Начнём с объявления абстракций. Главная абстракция в нашем решении - это интерфейс Logger. Данный интерфейс позволит создавать различные реализации логгера, в том числе наш OpenTelemetry logger.
/** * Интерфейс для логгера, который поддерживает логирование ошибок и сообщений, * а также может быть инициализирован с дополнительными опциями. */ export interface Logger { /** * Имя логгера */ readonly name: string; /** * Инициализация логгера с переданными опциями. * Может быть использовано для настройки внешнего сервиса логирования. * * @param options - Необязательные параметры инициализации. Тип зависит от реализации. */ init?( options?: CompositeInitOptions | SentryInitOptions | AuditInitOptions ): void; /** * Логирует ошибку с необязательным контекстом. * * @param error - Ошибка, которую необходимо залогировать. * @param context - Дополнительная информация о контексте, в котором произошла ошибка. */ logError(error: Error, context?: ErrorContextType | ErrorContextType[]): void; /** * Логирует произвольное сообщение с необязательным контекстом. * * @param message - Сообщение для логирования. * @param context - Дополнительная контекстная информация. */ logMessage?(message: string, context?: Record<string, unknown>): void; }
CompositeLogger реализует паттерн Компоновщик.
Он представляет собой обёртку для всех логгеров и умеет пробрасывать события во все логгеры, которые он оборачивает.
В нашем случае у нас есть ещё два логгера, кроме OpenTelemetry, и этот набор может как расширяться, так и сужаться.
Мы выбрали реализовать именно через CompositeLogger потому что такая реализация имеет ряд преимуществ. А именно:
он позволяет скрыть реализацию от разработчиков, которые используют наше решение,
даёт гибкость и возможность расширять набор систем логирования,
не требует участия продуктовых команд при добавлении новой системы.
Если появляется новая система логирования - мы просто пишем ещё одну реализацию интерфейса Logger и добавляем её в CompositeLogger.
Можно не писать такой компоновщик вообще, но тогда придётся ходить по командам разработки и добавлять вызовы вручную. У нас слишком большой проект, поэтому мы решили добавить слой абстракции. Зато добавление новой системы логирования теперь происходит без участия продуктовых команд - им достаточно обновить npm-пакет.
Ниже приведён листинг CompositeLogger.
Полная реализация включает управление шагами, ошибками и sampling - здесь оставим только самые важные части.
export class CompositeLogger implements Logger { public readonly name = "composite"; constructor(private readonly loggers: Logger[]) {} init(options?: CompositeInitOptions): void { this.loggers.forEach((logger) => { logger.init?.(options?.[logger.name as keyof CompositeInitOptions]); }); } logError(error: Error, context?: ErrorContextType[]): void { this.loggers.forEach((logger) => { logger.logError(error, context); }); } logMessage(message: string, context?: Record<string, unknown>): void { this.loggers.forEach((logger) => { logger.logMessage?.(message, context); }); } startBusinessProcess(name: string, attributes?: Attributes): void { this.loggers.forEach((logger) => { if (logger.name === "otlp") { (logger as OtlpLogger).startBusinessProcess(name, attributes); } }); } addEvent(name: string, attributes?: Attributes): void { this.loggers.forEach((logger) => { if (logger.name === "otlp") { (logger as OtlpLogger).addEvent(name, attributes); } }); } }
Код ниже предоставляет контекст, провайдер и хук для удобного использования логгера по всему приложению. Он не относится напрямую к нашей теме, скорее демонстрирует, как при помощи базовых инструментов react можно предоставить всему приложению доступ к системе логгирования.
import React, { createContext, useContext } from "react"; import { CompositeLogger } from "./composite-logger"; /** * Контекст для предоставления реализации логгера по всему дереву компонентов React. */ const LoggerContext = createContext<CompositeLogger | null>(null); /** * Провайдер для внедрения логгера в дерево React-компонентов через контекст. * * @param props.logger - Реализация интерфейса `Logger`, предоставляемая потомкам. * @param props.children - Дочерние компоненты, которым будет доступен логгер через `useLogger`. */ export const LoggerProvider: React.FC<{ logger: CompositeLogger; children: React.ReactNode; }> = ({ logger, children }) => ( <LoggerContext.Provider value={logger}>{children}</LoggerContext.Provider> ); /** * Хук для получения логгера из контекста. * Бросает ошибку, если `LoggerProvider` не оборачивает вызывающий компонент. * * @throws {Error} Если `LoggerContext` не содержит логгер. * @returns {Logger} Экземпляр логгера из контекста. */ export const useLogger = (): CompositeLogger => { const logger = useContext(LoggerContext); if (!logger) { throw new Error("Logger not found in context"); } return logger; };
Класс логгера OpenTelemetry
Для OpenTelemetry логгера написана конкретная реализация интерфейса Logger.
Этот класс инициализирует SDK OpenTelemetry, патчит fetch (для наших целей это было обязательно), позволяет создавать бизнес-процессы и шаги, записывает события и ошибки. Словом - даёт возможность наполнять контекст полезной информацией.
Почему нам вообще понадобился патч fetch? Нам важно, чтобы запросы на бекенд выполнялись в рамках текущего активного спана. По умолчанию же на каждый HTTP-вызов Open Telemetry SDK создает отдельный trace.
В браузере сложно сохранять иерархию спанов, особенно при асинхронных вызовах. Для решения этой проблемы мы используем StackContextManager. Он хорошо работает на client side и довольно компактный с точки зрения влияния на размер сборки. Также StackContextManager позволяет приклеивать дочерние спаны (например, fetch) к текущему бизнес-процессу без передачи контекста в каждый метод руками.
export class OtlpLogger implements Logger { public readonly name = "otlp"; private tracer?: Tracer; private provider?: WebTracerProvider; private activeSpan?: Span; async init(options?: OtlpInitOptions): Promise<void> { const provider = new WebTracerProvider({ sampler: new TraceIdRatioBasedSampler(options?.sampleRatio ?? 1), }); provider.addSpanProcessor( new BatchSpanProcessor( new OTLPTraceExporter({ url: options?.otlpUrl }) ) ); provider.register({ contextManager: new StackContextManager(), propagator: new B3Propagator(), }); this.tracer = trace.getTracer(options?.serviceName || "frontend"); this.provider = provider; this.patchFetch(); } startBusinessProcess(name: string, attributes?: Attributes) { if (!this.tracer) return; this.activeSpan = this.tracer.startSpan(name, { attributes }); } addEvent(name: string, attributes?: Attributes) { this.activeSpan?.addEvent(name, attributes); } logError(error: Error) { this.activeSpan?.recordException(error); } private patchFetch() { const originalFetch = window.fetch; window.fetch = async (...args) => { return context.with(context.active(), () => originalFetch(...args) ); }; } }
Самое главное здесь - это патчинг fetch. По умолчанию каждый HTTP-запрос создаёт новый trace. Нам же важно, чтобы запрос был частью текущего бизнес-процесса, для этого мы оборачиваем fetch в текущий контекст через context.with.
Пример использования в приложении
В нашем случае важно было разметить процесс оформления банковского перевода внутри приложения. Как можно увидеть из кода, модуль money-transfer-ui - это небольшое приложение со страницами /, /step-1, /step-2, /step-3. Каждая страница - шаг процесса перевода (* Интересный факт: по юридическим причинам вы не можете сделать все в один шаг, по крайней мере в России).
Наша задача понимать на каком шаге происходит ошибка. Для этого мы логируем шаги, в которых можно посмотреть процесс денежного перевода. В первую очередь нас интересуют сессии с ошибкой.
Здесь создаются шаги бизнес процесса, используя ранее разобранный функционал. При входе в процесс создается шаг бизнес процесса, а при переходе на следующий шаг завершается текущий и начинается следующий.
// Base dependencies import { useEffect } from "react"; import { Routes, Route, useLocation } from "react-router-dom"; // Import pages import { TransferPage } from "client/view/transefer-page"; import { FinalPage } from "client/view/final-page"; import { ContactPage } from "client/view/contact-page"; // Our logger package import { useLogger } from "@my-org/logger"; // Constants moved to separate file import { OTLP_BUISENESS_PROCESS_STEP_NAMES } from "client/system/otlp-logger-utils/constants"; export const AppRoutes = () => { const logger = useLogger(); const location = useLocation(); useEffect(() => { switch (location.pathname) { case "/": case "/step-1": logger.startBusinessProcessStep( OTLP_BUISENESS_PROCESS_STEP_NAMES.CHOOSING_A_PAYEE ); break; case "/step-2": logger.startBusinessProcessStep( OTLP_BUISENESS_PROCESS_STEP_NAMES.SETTING_UP_A_PAYMENT ); break; case "/step-3": logger.startBusinessProcessStep( OTLP_BUISENESS_PROCESS_STEP_NAMES.PAYMENT_COMPLETION ); break; default: break; } return () => { if (location.pathname === "/step-3") { logger.endBusinessProcess(); } logger.endBusinessProcessStep(); }; }, [location]); return ( <Routes> <Route path="/" element={<ContactPage />} /> <Route path="/step-1" element={<ContactPage />} /> <Route path="/step-2" element={<TransferPage />} /> <Route path="/step-3" element={<FinalPage />} /> </Routes> ); };
Примеры трейсов, записанных при помощи нашего SDK
Визуализирую искусственный трейс во избежание возможных проблем с NDA, это не должно помешать пониманию.
В результате, после разметки процесса можно получить полноценный таймлайн. На скриншоте видно, что в рамках Payment пользователь прошел шаги choosing a payee, setting up a recipient, setting up a payment, payment completed. Сразу видно, что на втором шаге в логику формы закралась ошибка. Если бекенд ответил ошибкой - пользователя нельзя пускать на следующие шаги, а здесь он был пропущен дальше.

Но прелесть OpenTelemetry в том, что далее мы можем посмотреть, что стало причиной ошибки:

Система сквозных идентификаторов (traceId) позволяет сопоставить ошибку на фронте и увидеть, что бек ответил с ошибкой по причине таймаута интеграции.
Этот пример простой, но бекенды могут пробрасывать сквозные идентификаторы (traceId и spanId) дальше и в интеграцию, и в kafka, и между собой. OpenTelemetry SDK предоставляет для этого инструментарий. В этом случае мы увидим еще большую детализацию в глубину.
Известные ограничения
Несмотря на то что OpenTelemetry - уже устоявшийся стандарт для работы с трейсами, все еще есть различные ограничения.
OpenTelemetry писался для бекенда, поэтому на стороне фронта его больно использовать, особенно за рамками стандартных задач, описанных в документации.
Сам SDK достаточно тяжелый. Если вам важен каждый байт, то вам придется разбираться с асинхронной загрузкой чанков телеметрии и проставить им приоритеты. Однако после всех улучшений станет значительно быстрее, чем из коробки.
Если хочется спаном считать сессию (как в нашем примере), то вам придется писать костыли, просто потому что невозможно отследить событие завершения сессии (в нашем случае - уничтожение web-view). Да, на десктопе вы можете подписаться на onbeforeunload и использовать sendBeacon, но все это не железные 100% рабочие методы.
Заключение
OpenTelemetry часто сравнивают с Sentry, но я не буду комментировать это сравнение тут. Дело в том что sentry в нашем проекте тоже используется, в основном для снятия технических фронтовых метрик (web-vitals, метрики кеширования, ошибки технического характера). OpenTelemetry же у нас используется для разметки бизнес процессов, а так же для того, чтобы получить срез процесса по всей инфраструктуре, которая в нем участвует. Если тема сравнения OpenTelemetry и Sentry будет актуальна, я напишу отдельный пост.
Нужно ли всем заносить OpenTelemetry? Однозначно нет. Если у вас простая система, пара десятков сервисов, вам скорее всего хватит обычных логов. Если же у вас зоопарк из сервисов, они появляются быстрее, чем вы можете это контролировать и вам нужно надежное решение для обвязки ключевых процессов мониторингом - имеет смысл посмотреть в сторону OpenTelemetry.
Как вы решаете проблему сквозной аналитики в больших проектах? Пишите комментарии, будет интересно узнать ваши сетапы.
