Привет! Я Никита, 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.
Как вы решаете проблему сквозной аналитики в больших проектах? Пишите комментарии, будет интересно узнать ваши сетапы.
Автор: DorianMood
