Модно не значит правильно — про pgx, метрики и OpenTelemetry

от автора

Всё началось с одного вопроса от старшего коллеги — что pgx предоставляет для сбора метрик. Казалось простым: открыл документацию, увидел QueryTracer, решил что вот оно — замена декоратору. Проверил руками и сразу споткнулся: транзакция дала три значения вместо одного. Начал копать глубже — и нашлось три разных инструмента, QueryTracer, метрики и OpenTelemetry, которые легко перепутать если не разбираться. Эта статья о разнице между драйверами, обёртками и инструментами замера производительности — и о том когда каждый из них нужен.

От одного вопроса к часам изучения вопроса

Мы пишем сервис на Go, PostgreSQL, sqlx. Observability реализовано просто — декоратор над репозиторием, Prometheus, time.Now(). Задачи делать трассировку не было — проект это MVP телеграмм бота и слабо нагруженного API, CRUDL операции не смогут нагрузить Postgres. И когда задали вопрос о pgx — стало интересно разобраться как устроена observability на уровне драйвера. Не для текущего проекта, а чтобы понимать инструменты для следующих.

Три инструмента: метрики, QueryTracer, OTel

Метрики нужны для общей статистики. Видеть как приложение работает — необходимость, а не прихоть. Будь то рост ошибок после деплоя или p99 на одном из эндпоинтов вырос с 50ms до 800ms — нужно заметить быстро. Но у метрик нет контекста — видим что p99 вырос, а почему непонятно. В этом и есть их смысл — дешёвый анализ. Одно число, один график, один алерт. Метрика говорит что и когда.

QueryTracer — интерфейс pgx через который можно перехватывать запросы на уровне драйвера. Можно замерять длительность, логировать SQL, создавать spans. Но важно понимать: при транзакции он даёт три отдельных замера — BEGIN, сам запрос, COMMIT. Это не одна метрика на метод репозитория, а три отдельных события. Что для метрик лишнее, то для трейсинга — необходимость. Поэтому QueryTracer это инструмент трейсинга, а не метрик.

OpenTelemetry — здесь ищем источник проблем. Span создаётся в хендлере и передаётся через контекст вглубь — через сервис, репозиторий, до самого запроса в БД. Каждый слой добавляет свой дочерний span. В итоге в Jaeger видите полное дерево: какой конкретно запрос тормозил и в каком контексте.
Трейсинг работает постоянно — это не инструмент который включают когда что-то сломалось. Но не каждый запрос трейсится: настраивается sampling, например 10% запросов. Если запрос попал в выборку — записывается весь трейс целиком, все дочерние spans. Это баланс между полнотой данных и стоимостью хранения.
OTel отвечает на вопрос где именно и почему.

Наш декоратор

В нашем проекте используется обычный декоратор с дженериком. Главной причиной появления декоратора были простые метрики и централизованная обработка ошибок — чтобы не повторять один и тот же код в каждом методе репозитория и следовать DRY.

func WithDBMetricsValue[T any](operation string, fn func() (T, error)) (T, error) {  start := time.Now()  result, err := fn()  seconds := time.Since(start).Seconds()  metrics.ObserveDatabaseQueryDuration(operation, seconds)  if err != nil {    metrics.IncDatabaseErrors(operation)    return result, CheckDBError(operation, err)  }  return result, err}

При подготовке статьи нашел ошибку. Длительность конвертировали в минуты, что неверно для таких метрик. Наглядная польза от написания статей.

// былоminutes := time.Since(start).Minutes()// сталоseconds := time.Since(start).Seconds()

Для простого случая — один запрос на метод — декоратор точен:

func (r *BookingRepo) GetAvailableSlots(ctx context.Context, serviceID int, date time.Time) ([]time.Time, error) {    const operation = "get_available_slots"    var slots []time.Time    return WithDBMetricsValue(operation, func() ([]time.Time, error) {        err := r.db.SelectContext(ctx, &slots, getAvailableSlotsQuery, serviceID, date)        if err != nil {            return nil, err        }        return slots, nil    })}

Декоратор меряет ровно время запроса. Метрика точная. Когда внутри метода транзакция и бизнес логика — картина меняется:

func (r *BookingRepo) CreateBooking(ctx context.Context, b *models.Booking) (int64, error) {    const operation = "create_booking"    return WithDBMetricsValue(operation, func() (int64, error) {        if err := r.validateBooking(b); err != nil { // ← входит в метрику            return 0, err        }        tx, err := r.db.BeginTxx(ctx, nil) // ← и это        if err != nil {            return 0, err        }        defer func() { _ = tx.Rollback() }()        var id int64        err = tx.QueryRowContext(ctx, createBookingAtomicQuery,...).Scan(&id) // ← и это        if err != nil {            if errors.Is(err, sql.ErrNoRows) {                return 0, models.ErrSlotOccupied            }            return 0, err        }        if err = tx.Commit(); err != nil { // ← и это            return 0, err        }        return id, nil    })}

Метрика говорит create_booking = 45ms. Но внутри — валидация, открытие транзакции, SQL запрос, коммит. Сколько из них реальный запрос к БД — декоратор не знает.

Декоратор видит:      create_booking = 45msQueryTracer видел бы: BEGIN  = 0.1ms                      INSERT = 40ms                      COMMIT = 2ms

QueryTracer

QueryTracer — это интерфейс из pgx. Ответственность за реализацию методов на разработчике — вы сами решаете что отслеживать, что логировать, какие метрики собирать. Работает через type assertion: pgx в рантайме проверяет какие интерфейсы реализует ваша структура. Забыли реализовать BatchTracer — ошибки не будет, но Batch молча в слепой зоне.

Для каждого типа операции pgx — свой интерфейс с методами Start и End. Batch добавляет третий метод TraceBatchQuery — потому что Batch это контейнер с несколькими запросами внутри. Это пример интерфейса из официального репозитория pgx.

// BatchTracer traces SendBatch.type BatchTracer interface {// TraceBatchStart is called at the beginning of SendBatch calls. The returned context is used for the// rest of the call and will be passed to TraceBatchQuery and TraceBatchEnd.TraceBatchStart(ctx context.Context, conn *Conn, data TraceBatchStartData) context.ContextTraceBatchQuery(ctx context.Context, conn *Conn, data TraceBatchQueryData)TraceBatchEnd(ctx context.Context, conn *Conn, data TraceBatchEndData)}

Пример реализации объявления кастомных методов:

type MyTracer struct{}// QueryTracerfunc (t *MyTracer) TraceQueryStart(...) context.Context {}func (t *MyTracer) TraceQueryEnd(...) {}// BatchTracer — та же структураfunc (t *MyTracer) TraceBatchStart(...) context.Context {}func (t *MyTracer) TraceBatchQuery(...) {}func (t *MyTracer) TraceBatchEnd(...) {}// Подключениеconfig.ConnConfig.Tracer = &MyTracer{}

Важно понимать: QueryTracer сам по себе не является трейсингом. Это точка перехвата — хук. Что вы в него вложите — метрику, span или лог — ваш выбор. Трейсинг появляется только когда внутри этих методов создаёте OTel spans и правильно передаёте ctx через все слои.

OTel требует настройки экспортера — без него spans создаются но никуда не отправляются. В проде это Jaeger или Tempo. В разработке можно использовать stdout или написать свой — как в примере кода к статье.

Выбираем стек

Выбор инструмента для трейсинга зависит от двух вещей — стека и того что хотите видеть. Если остаётесь на sqlx и не используете Batch — otelsql закроет потребности без смены стека. Если переходите на pgx и хотите полную картину — otelpgx подключается так же просто. MyTracer имеет смысл только когда нужна кастомная логика внутри трейсера — специфичные метрики или особое логирование.

sqlx + otelsql

pgx + otelpgx

pgx + MyTracer

Подключение

одна строка

одна строка

пишете сами

Spans автоматически

✓ если реализовали

Верхние слои

вручную

вручную

вручную

Query

Batch

CopyFrom

Кастомная логика

Смена стека

не нужна

sqlx → pgx

sqlx → pgx

Во всех трёх случаях верхние слои — handler, service, repo — ваша ответственность. Библиотека закрывает только SQL уровень. Контекст нужно передавать через все слои — без этого spans будут корневыми и потеряют связь с запросом пользователя.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1024854/