Трейсинг и здоровье
Один трейсер, подключённый один раз, и health-проверка, знающая про миграции.
Контракт#
Библиотека определяет интерфейс и два типа событий и не импортирует ни одной телеметрической библиотеки. ORM, который импортировал бы такую, заставил бы каждый использующий его проект зависеть от её версии, её транзитивного дерева и её мнений.
type Tracer interface {
Start(ctx context.Context, e observe.StartEvent) context.Context
End(ctx context.Context, e observe.EndEvent)
}Правило, которое стоит назвать первым#
Стартовое событие никогда не несёт значения параметров. Не по соглашению и не за опцией, которую можно выключить, — такого поля просто нет.
Трейсинг ORM видит каждый запрос программы. Трейсер, получающий значения, положил бы каждый пароль, токен и адрес, который обрабатывает программа, туда, куда он пишет. SQL при этом есть — с плейсхолдерами, потому что WHERE email = $1 полезен и ничего не говорит о том, чей это адрес.
Исключение — SQL, который написали вы: библиотека не может вычистить литерал из сырого запроса, не разобрав SQL, а написать разборщик SQL значило бы построить ровно то, ради отсутствия чего она существует. StartEvent.Raw показывает, какие запросы такие.
Подключение#
db := domain.New(orm.Traced(pool, tracer))Один вызов, на старте, на исполнителе. Ничто ниже — ни сервис, ни хранилище, ни сгенерированный код — не упоминает телеметрию, а транзакция от этого исполнителя её наследует.
Два назначения#
Исполнитель несёт один трейсер. Двойная обёртка даёт исполнитель, у которого трейсер внешний, а внутренний не вызывается никогда — молча, без ошибки:
ex := orm.Traced(orm.Traced(pool, logging), tracing) // НЕВЕРНОЧтобы попасть в два места, нужен один трейсер, раздающий в оба:
type Multi []observe.Tracer
func (m Multi) Start(ctx context.Context, e observe.StartEvent) context.Context {
for _, t := range m {
if t != nil {
ctx = t.Start(ctx, e) // контекст протягивается: второй видит то, что добавил первый
}
}
return ctx
}
func (m Multi) End(ctx context.Context, e observe.EndEvent) {
for i := len(m) - 1; i >= 0; i-- { // в обратном порядке: вложенность закрывается изнутри
if m[i] != nil {
m[i].End(ctx, e)
}
}
}slog#
import "github.com/AlexAli29/orm/ormslog"
tracer := ormslog.New(log,
ormslog.WithSQL(true),
ormslog.WithSlowThreshold(200*time.Millisecond),
ormslog.WithRawSQL(false), // тумблер, который пустил бы литералы в лог
)OpenTelemetry#
Отдельный модуль, поэтому проект, который им не пользуется, его не компилирует:
import "github.com/AlexAli29/orm/ormotel"
tracer := ormotel.New(otelTracer,
ormotel.WithSQL(true),
ormotel.WithRawSQL(false),
ormotel.WithErrorMessages(false),
)WithErrorMessages(false) — умолчание, которое стоит оставить: сообщение PostgreSQL может процитировать значение из строки, нарушившей ограничение, а спан уходит туда, где аудитория другая, чем у лога приложения.
Health#
import "github.com/AlexAli29/orm/ormhealth"
// Дешёвый ответ про живость: дотягивается ли пул до PostgreSQL.
report := ormhealth.Quick(ctx, pool)
// Ответ про готовность: он же спрашивает, та ли это схема, которую описывают
// декларации, и все ли миграции применены.
report := ormhealth.Deep(ctx, pool,
ormhealth.WithMigrationState(migrationsDir),
ormhealth.WithSchemaCheck("orm.yaml"),
)WithMigrationState ловит наполовину случившийся деплой: пул поднят, запросы работают, а схема на версию отстала. Liveness скажет «нормально»; эта проверка — нет.
Разобранные примеры#
Подключение один раз, на старте#
func main() {
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
tracer := ormslog.New(logger,
ormslog.WithSQL(true),
ormslog.WithSlowThreshold(200*time.Millisecond),
ormslog.WithRawSQL(false))
db := domain.New(orm.Traced(pool, tracer))
// ниже этой строки телеметрия не упоминается
}Эндпоинт готовности, знающий про миграции#
http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
report := ormhealth.Deep(r.Context(), pool,
ormhealth.WithMigrationState("migrations"),
ormhealth.WithSchemaCheck("orm.yaml"))
if report.Status != ormhealth.StatusUp {
w.WriteHeader(http.StatusServiceUnavailable)
}
writeJSON(w, report)
})
http.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
if ormhealth.Quick(r.Context(), pool).Status != ormhealth.StatusUp {
w.WriteHeader(http.StatusServiceUnavailable)
}
})Liveness спрашивает, надо ли перезапустить процесс. Readiness — надо ли слать ему
трафик; поду, у которого схема на версию отстала, слать не надо, и ради этого
случая существует WithMigrationState.
И лог, и спан#
type Multi []observe.Tracer
func (m Multi) Start(ctx context.Context, e observe.StartEvent) context.Context {
for _, t := range m {
ctx = t.Start(ctx, e)
}
return ctx
}
func (m Multi) End(ctx context.Context, e observe.EndEvent) {
for i := len(m) - 1; i >= 0; i-- {
m[i].End(ctx, e)
}
}
db := domain.New(orm.Traced(pool, Multi{slogTracer, otelTracer}))Двойная обёртка Traced этого не даёт: внутренний трейсер не вызывается никогда,
и об ошибке никто не сообщает.