Всё началось с одного вопроса от старшего коллеги — что 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/