javascript

Observability в финтехе: связываем клик пользователя с падением интеграции

  • среда, 1 апреля 2026 г. в 00:00:04
https://habr.com/ru/articles/1017650/

Привет! Я Никита, 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 используется для визуализации метрик и трейсов.

Схема системы, которая обеспечивает работу логирования и трейсинга OpenTelemetry.
Схема системы, которая обеспечивает работу логирования и трейсинга OpenTelemetry.

Такая схема позволяет централизованно собирать и анализировать 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.

Как вы решаете проблему сквозной аналитики в больших проектах? Пишите комментарии, будет интересно узнать ваши сетапы.