Привет, Хабр! На связи команда Seller API, а именно её тимлид Саша Валов и старший разработчик Никита Денисенко. В этой статье мы разберём одну из проблем большого API и расскажем, как мы её решили.

Вступление

Seller API — это продукт, предоставляющий программный интерфейс для работы с маркетплейсом Ozon. Он позволяет системам продавца и Ozon обмениваться информацией.

Seller API насчитывает более 200 методов. Эти методы удовлетворяют множество бизнес-потребностей и предоставляют доступ к широкому спектру сервисов Ozon. В основном методы API проксируют запросы к этим сервисам.

Проблема

Нашей команде необходимо оперативно давать ответы на вопросы а-ля «В какие сервисы мы, разработчики команды Seller API, ходим под капотом этой API-ручки?» или, наоборот, «В контексте каких API-ручек мы ходим в конкретный сервис или даже метод сервиса?». Эти вопросы с завидной регулярностью поступают от ребят из технической поддержки, которым необходимо расследовать неполадку, и ребят из продукта, которые систематизируют требования к новым функциональностям или к рефакторингу старых.

Чтобы ответить на любой из этих вопросов, нам чаще всего приходится смотреть код. Дежурный разработчик сначала идёт в proto-файлы, описывающие контракт API (да, мы в Ozon описываем и внешние, и внутренние API с помощью proto-файлов). Погружаясь глубже вплоть до кода клиентов нижележащих сервисов, он находит нужные методы, в которые мы проксируем запросы. И, наоборот, из кода клиента необходимого сервиса, поднимаясь по слоям, разработчик понимает, где и как используется метод нижележащего сервиса.

Да, у нас есть трейсинг (мы используем Jaeger), который может помочь в поиске ответов, но этот процесс тоже превращается в небольшое исследование, поскольку каждый конкретный трейс не даёт общей картины, а связан с одним методом API или даже отдельным кейсом в рамках метода.

Документация у нас тоже есть, но её ведение и организация актуальной навигации по ней, учитывая объём часто меняющихся взаимодействий, — задача нетривиальная и трудоёмкая. Нам нужны автоматические механизмы.

Решить проблему можно сбором метрик. В Ozon огромное количество различных метрик и решений, связанных с observability. У нас есть метрики нашего сервиса и сервисов, в которые ходит наш API, — но нет метрик, связывающих их воедино. Мы не видим общей картины.

Решение

Мы поняли, что нам необходимо придумать своё решение. Что у нас для этого есть? Первое, что приходит в голову, — это Prometheus. Можно завести новую метрику, например типа counter (счетчик) с лейблами: api_method, target_service, target_service_method, которая будет хранить факт вызова нижележащего сервиса за последние N секунд. После экспорта данной метрики можно будет доставать данные с помощью PromQL и отрисовывать их в Grafana. Звучит неплохо!

Но получалось, что таким образом мы начали бы плодить новые метрики, которых и так много.
Ну что ж, мы пошли думать дальше. А что, если те же лейблы превратить в поля таблицы? У нашего сервиса есть своя база данных Postgres, где мы сами решаем, что и как хранить. Звучит приемлемо.

Приступим к решению. Для начала нам нужно создать таблицу:

create table endpoint_map(
  id                    bigint generated by default as identity primary key,
  api_method            text not null,
  target_service        text not null,
  target_service_method text not null,
  created_at            timestamp with time zone default CURRENT_TIMESTAMP not null
);

Прежде чем что-то писать в таблицу, необходимо определить, как мы будем понимать, какой метод API обращается к какому методу сервиса. Как это можно сделать?

В Ozon для внешних API, доступных по HTTP, мы используем gRPC-Gateway. Если кратко, это плагин для gRPC, позволяющий описывать gRPC и RESTful API в одном proto-файле. Документация: https://grpc-ecosystem.github.io/grpc-gateway/.

gRPC-Gateway
gRPC-Gateway

Из proto-файла с помощью плагина gRPC-Gateway генерируется код, в котором описываются HTTP-хендлеры. Из того же файла генерируется код gRPC-сервера. gRPC-Gateway выступает в роли reverse proxy (обратного прокси) и преобразует HTTP-трафик в gRPC-трафик. 

Какой путь проходит запрос к публичному API? 

Упрощённо его можно представить так:

  1. Запрос от API-клиента приходит в gRPC-Gateway.

  2. gRPC-Gateway преобразует HTTP-запрос в gRPC-запрос.

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

  4. Затем запрос отправляется в сервисный слой — ту логику, которая непосредственно его обрабатывает.

  5. После обработки запроса действия выполняются в обратном порядке.

На стороне сервисного слоя, где располагается логика обработки запроса, могут находиться gRPC-клиенты конечных сервисов, в которые Seller API проксирует запросы данных. В каждый такой клиент можно добавить middleware (в парадигме gRPC их называют interceptors), которое, зная об исходном контексте запроса, может достать имя нужного нам метода API и выполнить с ним необходимые действия.

Попробуем закодить вышеописанное.

HTTP middleware

func NewHTTPMiddleware(ctx context.Context) func(handler http.Handler) http.Handler {
	return func(handler http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
			// ...
			// положим в контекст имя метода API
			apiMethod = req.Method + " " + req.RequestURI
			req = req.WithContext(context_helper.HTTPEndpointToContext(req.Context(), apiMethod))
			// ...
		}
    }
}

gRPC client middleware

func NewEndpointMapUnaryInterceptor() grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		apiMethod := utcontext_helperils.HTTPEndpointFromContext(ctx)
		targetService := cc.Target()
		// insert(ctx, apiMethod, targetService, method)
		return invoker(ctx, method, req, reply, cc, opts...)
    }
}

Очевидно, что нам не нужно записывать в базу каждый запрос, прилетевший в API. В middleware gRPC-клиента есть кеш, в который мы кладём отметку о том, что связка apiMethod + targetService + targetMethod уже была добавлена в таблицу. Нам нужно знать о факте вызова запросов в рамках одного дня, а не их количества.

Дело за малым — отобразить всё это в Grafana. Для этого добавляем (если ещё не добавлен) новый источник данных в виде нашей БД. Разумеется, с правами только на чтение. Создаём новый график и, выбрав источник данных, пишем запрос:

select api_method "ручка Seller API",
       target_service "нижележащий сервис",
       target_method "ручка нижележащего сервиса"
from endpoint_map
where to_char(created_at, 'yyyy-MM-DD') = '$date'
group by api_method, target_service, target_method

Здесь $date — это переменная, которую можно указать в разделе Variables в настройках дашборда. Сделано это для удобства, чтобы можно было выбрать дату из выпадающего списка и увидеть все вызовы API в этот день.

Получилась такая красота:

(Часть данных — с фильтром по одному из сервисов)
(Часть данных — с фильтром по одному из сервисов)

Первые два столбца — активные. То есть, нажав на какое-то значение, мы попадём на страницу либо с сервисом, либо со Swagger-документацией.

Выводы

Таким недорогим и не требующим большого количества ресурсов способом мы значительно упростили жизнь как минимум трём командам: разработки, продукта и техподдержки.

Мы создали единую точку входа, где собраны ответы на вопросы, упомянутые в начале статьи. Теперь каждый сотрудник может самостоятельно зайти в Grafana и увидеть таблицу с нужными данными — без обращения к нам или к коду. Это решение уменьшило количество запросов от техподдержки и аналитиков к команде разработки и ускорило процесс поиска и устранения проблем для разработчиков.

Комментарии (8)


  1. olku
    25.12.2024 10:27

    Чем не подошло OpenTelemetry, где это делается автоматически от HTTP до запроса к базе?


    1. aleksandrvalov
      25.12.2024 10:27

      Трейсинг запросов у нас есть. В нашем кейсе мы агрегируем данные для наглядности


      1. olku
        25.12.2024 10:27

        OpenTelemetry это не только трассировка, но и агрегирование и визуализации графа связей. Автоинструментирование включается тремя строчками. Правильно ли я понял, что альтернативы собственному решению не были оценены?


        1. aleksandrvalov
          25.12.2024 10:27

          В этом смысле оценку проводили. В нашем случае, самой адекватной альтернативой «изобретению своего велосипеда» является использование платформенных решений Озона. И мы обязательно так и сделаем, когда оно появится. Конкретно OpenTelemetry внедрить только в наш сервис, поверх или рядом с платформенными решениями - это более трудоемкая задача, а где-то и избыточная, чем написать небольшую свою мидлварю для решения конкретной проблемы. 

          А так, то что OpenTelemetry крутой инструмент и лучше пользоваться им, чем что-то изобретать - с этим абсолютно согласен


        1. aleksandrvalov
          25.12.2024 10:27

          граф связей, кстати говоря, в нашем распоряжении тоже есть, но он тоже не в полной мере дает наглядность в разрезе API методов


          1. olku
            25.12.2024 10:27

            Спасибо за ответы. Граф статистический и в OpenTelemetry, нужно сбрасывать после релиза, неиспользуемые связи не видны.


  1. Yohohori-san
    25.12.2024 10:27

    что мешало добавить сквозной request-id в логи каждого сервиса и смотреть по логам цепочки вызовов?


    1. aleksandrvalov
      25.12.2024 10:27

      У нас есть трейсинг, цепочки вызовов мы можем смотреть. Однако это не позволяет легко ответить на вопрос, например,  «В контексте каких API-ручек мы ходим в конкретный сервис или даже метод сервиса?». Да, можно провести нехитрое исследование и все выяснить, трейсы есть, логи есть. Процесс «выяснения» мы и автоматизировали в этом кейсе.