Содержание урока · 12 разделов
- Gorm logger zap: контракт logger.Interface
- Trace: три исхода одного вызова
- SlowThreshold 200 мс: порог медленных
- Настройки моста и их эффект
- IgnoreRecordNotFoundError: NotFound — не авария
- Контекст: SQL-записи с request_id
- Source location: откуда вызван SQL
- Почему SQL-параметры в логах — плохая идея
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
ORM в проекте — и у него свой голос. Дефолтный логгер GORM пишет текстом через стандартный log: цветные строки в stdout, мимо JSON-конвейера, без request_id и без полей. Молчит он по умолчанию почти всегда — уровень Warn: обычные запросы не показывает, пишет только ошибки и медленные запросы дольше двухсот миллисекунд. В итоге логирование gorm-слоя живёт отдельной жизнью: приложение пишет структурно, база — текстом, и связать «этот медленный SELECT был в этом запросе» можно только по времени вручную. Лекарство — мост: интерфейс logger.Interface у GORM маленький, четыре метода, и реализовать его на *zap.Logger — полчаса работы, которые соединяют оба потока в один. Здесь соберём мост целиком и разберём решения, которые в нём спрятаны.
Gorm logger zap: контракт logger.Interface#
Пакет gorm.io/gorm/logger описывает контракт: LogMode(level) Interface переключает минимальный уровень и возвращает логгера, Info, Warn и Error получают строку формата с аргументами для редких событий GORM — миграций и внутренних предупреждений, а Trace — сердце моста. Его вызывают после каждого SQL-вызова с тремя аргументами: контекст запроса, время начала и функция fc(), которая возвращает готовую SQL-строку и число затронутых строк, плюс err. Константы уровня свои: Silent=1, Error=2, Warn=3, Info=4 — выше, тем больше SQL попадает в лог. Уровни не совпадают с zap, и мост честно переводит: Error GORM — Error zap, Warn — Warn, трейс — Info или Debug по вашему выбору. Логирование gorm-слоя начинается с решения, куда направить обычные запросы: у нас они пойдут в Debug — иначе под Info утонет весь трафик приложения.
Каркас моста: структура и простые методы
GOimport (
gormlogger "gorm.io/gorm/logger"
)
type ZapGormLogger struct {
ZapLogger *zap.Logger
LogLevel gormlogger.LogLevel
SlowThreshold time.Duration
IgnoreRecordNotFoundError bool
}
func (l ZapGormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
l.LogLevel = level
return l
}
func (l ZapGormLogger) Info(ctx context.Context, format string, args ...any) {
l.ZapLogger.Sugar().Infof(format, args...)
}
func (l ZapGormLogger) Warn(ctx context.Context, format string, args ...any) {
l.ZapLogger.Sugar().Warnf(format, args...)
}
func (l ZapGormLogger) Error(ctx context.Context, format string, args ...any) {
l.ZapLogger.Sugar().Errorf(format, args...)
}Мост — обычная структура, реализующая интерфейс. LogMode возвращает копию с новым уровнем: так сделан и логгер GORM, значение-приёмник здесь безопасен. Info, Warn и Error у GORM приходят редкими пачками текста — их честнее всего отправлять через SugaredLogger, не выдумывая поля ради строк без структуры.
Trace: три исхода одного вызова#
Вся логика моста живёт в Trace, и исходов три. Первый — err не nil: запрос упал, уходим в Error с полем error, длительностью, числом строк и SQL — но с одной оговоркой, которой посвящён отдельный раздел ниже. Второй — ошибок нет, но время превысило SlowThreshold: медленный запрос уходит в Warn с полем elapsed рядом с порогом, и именно эти записи кормят дашборд производительности. Третий — обычный запрос: Info или Debug с длительностью и числом строк. Важная мелочь: fc() стоит времени — он собирает финальную строку SQL с подставленными значениями, поэтому его вызывают только когда уровень позволяет писать, а не безусловно. Экономия мелкая на запрос, но запросов тысячи, и в мостах, где fc() зовётся всегда, она видна в бенчмарках.
Реализация Trace
GOfunc (l ZapGormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && l.LogLevel >= gormlogger.Error &&
(!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
sql, rows := fc()
l.ZapLogger.Error("sql failed",
zap.Error(err), zap.Duration("elapsed", elapsed),
zap.Int64("rows", rows), zap.String("sql", sql),
)
case l.SlowThreshold != 0 && elapsed > l.SlowThreshold && l.LogLevel >= gormlogger.Warn:
sql, rows := fc()
l.ZapLogger.Warn("slow sql",
zap.Duration("elapsed", elapsed), zap.Duration("threshold", l.SlowThreshold),
zap.Int64("rows", rows), zap.String("sql", sql),
)
case l.LogLevel >= gormlogger.Info:
sql, rows := fc()
l.ZapLogger.Debug("sql",
zap.Duration("elapsed", elapsed),
zap.Int64("rows", rows), zap.String("sql", sql),
)
}
}Порядок веток — приоритет записи: ошибка важнее медлительности, медлительность важнее трейса. fc() вызывается внутри каждой ветки — строка SQL собирается только когда запись состоится. gorm.ErrRecordNotFound проверяется через errors.Is: обёрнутые ошибки всё равно распознаются.
SlowThreshold 200 мс: порог медленных#
SlowThreshold — главный рабочий параметр моста, и дефолт у GORM осознанный: двести миллисекунд. Это примерно граница, за которой интерактивный ответ уже чувствуется пользователем: быстрее — норма, дольше — повод посмотреть план запроса и индексы. Каждое превышение становится Warn-записью с elapsed и SQL — прямым материалом для алерта «медленный SQL растёт». Менять порог стоит не глобально, а по месту: отчётные запросы по своей природе медленные, и рубить их порогом в двести миллисекунд — заспамить Warn самим собой. У GORM для этого db.Session(&gorm.Session{Logger: мостСПорогом}) — отдельная сессия со своим мостом и, например, секундным порогом. Такую настройку честнее делать не ради одной функции, а через репозиторий отчётов: у медленного кода свой порог, у интерактивного — свой.
SlowThreshold — time.Duration, и структурный литерал без этого поля оставляет ноль. Ветка медленных выключается целиком условием l.SlowThreshold != 0: сервис молчит о запросах по секунде, Error при этом не пишется — ошибки формально нет. Порог — обязательный параметр моста наравне с IgnoreRecordNotFoundError: собирается ли мост руками, из конфига или из окружения, значение должно приходить явно.
Настройки моста и их эффект#
| Поле | Рабочее значение | Эффект |
|---|---|---|
| LogLevel | gormlogger.Warn | тихий режим: ошибки и медленные; Info — всё SQL |
| SlowThreshold | 200 * time.Millisecond | порог Warn для медленных запросов |
| IgnoreRecordNotFoundError | true | NotFound не превращается в Error-алерт |
| ZapLogger | logger.Named("gorm") | все записи с полем logger: gorm |
| Session{Logger: ...} | свой мост на сессию | отчётные запросы живут с секундным порогом |
Проверка: уровень медленного запроса
LogLevel — Warn, SlowThreshold — 200 мс, ошибок нет. Запрос выполнялся 350 мс. Что запишет мост?
IgnoreRecordNotFoundError: NotFound — не авария#
db.First(&u, id) на несуществующем ключе возвращает gorm.ErrRecordNotFound — и это нормальный поток приложения: проверка наличия записи, а не сбой. Дефолтный конфиг моста поднимает флаг IgnoreRecordNotFoundError, и NotFound не превращается в Error-запись: без него каждый 404-подобный сценарий заводил бы алерт, и через неделю алерты отключили бы целиком. Запись при этом не исчезает совсем — на уровне Info она остаётся как обычный трейс. Игнорировать NotFound стоит не всегда: если для вашей логики отсутствие записи — исключение, которое ломает инварианты, а не ожидаемая ветка, оставьте его ошибкой и поднимите флаг только в интерактивных сервисах. Как всегда с уровнями: правило задаёт бизнес-смысл, а не привычка.
Проверка: NotFound
IgnoreRecordNotFoundError = true. Что запишет мост для First() по несуществующему ID при LogLevel = Info?
Контекст: SQL-записи с request_id#
Trace принимает контекст, и мост может достать из него request_id посредника — но только если контекст вообще доехал до GORM. Вызов db.WithContext(r.Context()) прокидывает контекст запроса в слой базы, и в Trace он приходит тем же самым: мост вытаскивает идентификатор и добавляет полем к каждой SQL-записи. Без WithContext контекст пустой — background, и связка рвётся на первом же шаге: access-лог говорит, что запрос был, а SQL-записей с этим request_id нет. Практика, которая работает: репозитории принимают ctx первым аргументом и не зовут GORM без WithContext — правило закрепляется код-ревью, а не призывами. Проверка на два grep: один запрос, поиск request_id в access-записи и в SQL-записи — оба должны найтись.
Source location: откуда вызван SQL#
У моста GORM есть опция SourceLocation: с ней в запись добавляется файл и строка вызова — не моста, а места, где код позвал .Find или .Save. Разница с zap.AddCaller принципиальная: caller zap показал бы внутренности GORM, где лог реально писался, и был бы бесполезен. GORM решает это через utils.FileWithLineNum — обход стека до первого кадра вне orm. Опция отвечает на вопрос, который без неё разбирается глазами: один и тот же SELECT выполняется из пяти мест, и какое из них медленное? Цена — обход стека на каждый SQL-вызов, на фоне самого запроса она теряется, но включайте осознанно: в горячих циклах с тысячами мелких запросов разница видна. Рабочая схема: SourceLocation включена всегда, а выключают её по замеру, а не по вере.
Подключение моста к gorm.Open
GOdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: ZapGormLogger{
ZapLogger: logger.Named("gorm"),
LogLevel: gormlogger.Warn,
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
},
})
if err != nil {
panic(err)
}Мост подаётся как обычное поле конфига GORM. Named("gorm") даёт записям поле logger: gorm — их видно в системе сбора отдельным потоком. Для разового включения подробностей по цепочке есть db.Debug(): он поднимает уровень до Info только у своего вызова, не трогая глобальные настройки.
Почему SQL-параметры в логах — плохая идея#
Вот место, где мост становится оружием. fc() возвращает SQL уже с интерполированными значениями — так устроен дефолтный вывод GORM: вместо плейсхолдеров $1 в записи стоит готовый запрос. Значит, в лог с каждым трейсом уходят значения аргументов: почта пользователя, номер карты, токен из WHERE, поле с персональными данными из INSERT. Логи читают шире, чем базу: дежурные, системы сбора, бэкапы логов — и регуляторные требования к ним жёстче. Мой рабочий компромисс, за который меня ругали: в интерактивных сервисах SQL пишем на Debug и маскируем значения, в системных интеграциях — пишем как есть, но канал с ограниченным доступом и коротким сроком хранения. Универсального ответа нет, но осознанное решение должно быть: «просто логировать SQL» — это решение писать персональные данные всем, у кого есть доступ к логам.
Худший реальный случай моста: запрос INSERT или UPDATE, где значение поля — пароль, токен или сессионная cookie. Такие записи попадают в Error-вывод при сбое запроса, то есть в момент, когда дампят больше всего. Маскируйте известные поля на уровне моста или логируйте вместо SQL его подпись: имя таблицы и число строк.
Что запишет молчащий мост?
Прочитайте код и выберите, что он выведет:
bridge := ZapGormLogger{
ZapLogger: logger.Named("gorm"),
LogLevel: gormlogger.Silent,
SlowThreshold: 200 * time.Millisecond,
}
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: bridge})
db.Raw("SELECT pg_sleep(1)").Scan(&result)Соберите Trace
Восстановите каркас Trace: длительность, SQL, запись ошибки.
Соберите ветвление Trace
Расставьте строки Trace по порядку: от длительности до веток записи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: GORM и zap
Что дальше по курсу#
Мост встраивается в конвейер из уроков про HTTP-middleware и request id: SQL-записи с request_id соединяются с access-логом в одну хронологию. Цену логирования каждого запроса в цифрах считают в материале про производительность zap, а полю error из ветки ошибки место в уроке про zap.Error и стек-трейсы. Подключение самой GORM и DSN разбирались в уроке про подключение к базе курса GORM; карта остальных тем — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Мост — реализация logger.Interface: LogMode, Info, Warn, Error и сердцевина Trace.
- Trace разбирает три исхода: ошибка — Error, превышение порога — Warn, обычный запрос — Info или Debug.
- SlowThreshold 200 мс — дефолт GORM; для отчётных запросов порог поднимают отдельной сессией.
- IgnoreRecordNotFoundError оставляет NotFound обычным трейсом: это поток приложения, а не авария.
- fc() возвращает SQL с подставленными значениями: персональные данные уходят в лог вместе с запросом.
- SourceLocation показывает место вызова GORM: caller zap без неё бесполезен.
- db.Debug() — разовая лупа: Info на одну цепочку вызовов.
Домашнее задание
Реализуйте ZapGormLogger целиком и подключите к локальной базе: Named("gorm"), LogLevel Warn, порог 200 мс, IgnoreRecordNotFoundError true. Проверьте три сценария по JSON-выводу: упавший запрос на несуществующей таблице — Error, запрос с pg_sleep(0.3) — Warn с elapsed, First() по отсутствующему ID — тишина. Затем поднимите LogLevel до Info и найдите в записях поле logger со значением gorm. После этого вызовите тот же репозиторий без WithContext и найдите отсутствие request_id в SQL-записях — связка рвётся ровно на этом шаге.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы не использовать logger.Default с перенаправлением вывода?
logger.Default пишет через стандартный log в заданный io.Writer — текстом, без полей. Его можно направить в файл или буфер, но в JSON-конвейер он поля не принесёт. Мост нужен именно ради структуры: elapsed, rows, sql как отдельные значения.
Куда направлять обычные SQL-запросы — в Info или Debug?
В Debug: полный трейс каждого запроса полезен при разборе и вреден в постоянном потоке. Info оставьте для окружений, где SQL-лог ведут намеренно — локальная разработка, воспроизведение бага под флагом.
Как связать SQL-записи с конкретным HTTP-запросом?
Через контекст: если Trace принимает ctx с request_id от middleware, мост может достать идентификатор и добавить полем. GORM передаёт контекст с WithContext в вызовах — прокинуть его в мост можно вытащив логгер из request context.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси