В большом проекте на пакет log лежат сотни вызовов: обёртки, утилиты, код третьих библиотек, который вы не контролируете. Разовый рывок «переписать всё за выходные» проваливается по одному сценарию — в середине ветки обнаруживается зависимость, которая требует log.Logger, и ветка замирает на месяцы. Стандартная библиотека предлагает другой путь: оба пакета умеют работать друг через друга, поэтому миграция становится переездом по слоям, где на каждом шаге система собирается, запускается и читаема. Мостов три: SetDefault перенаправляет строки пакета log в ваш обработчик, NewLogLogger выдаёт настоящую log.Logger для чужих API, а аккуратная замена вызовов в собственном коде движется слоями от транспорта к утилитам. В этом уроке — все три моста, таблица соответствия вызовов, ловушки Fatalf и calldepth и чек-лист, по которому миграцию можно признать завершённой.
Переход на slog: мосты между пакетами log и slog#
Первый мост уже знаком по шестому уроку: slog.SetDefault перенаправляет логгер пакета log в ваш обработчик, и все log.Print, log.Printf и log.Println после этого попадают в общий поток на уровне Info, текстом в поле msg. Это мост нулевого дня: один вызов в main — и разнородные строки сервиса уже собираются в одном JSON, ещё до первой правки чужого кода. Второй мост работает в обратную сторону: slog.NewLogLogger(handler, level) возвращает настоящий log.Logger, который пишет записи через заданный обработчик на заданном уровне. Он нужен там, где API требует именно log.Logger: поле ErrorLog у http.Server, конструкторы шаблонов, внешние библиотеки с параметром старого типа. Вы передаёте им мост, и их строки становятся структурными записями уровня Error — без правки самой библиотеки. Третий мост — собственный код: вызовы log в вашем слое заменяются на slog слой за слоем, и это уже не мост, а переезд, у которого ниже будет свой план. Разбор перехода на slog стоит начать с первых двух: они дают результат в первый же день.
ErrorLog у http.Server через NewLogLogger
GOpackage main
import (
"log/slog"
"net/http"
"os"
)
func main() {
h := slog.NewJSONHandler(os.Stdout, nil)
slog.SetDefault(slog.New(h))
srv := &http.Server{
Addr: ":8080",
ErrorLog: slog.NewLogLogger(h, slog.LevelError),
}
slog.Info("сервер стартует", "addr", srv.Addr)
log.Fatal(srv.ListenAndServe())Ошибки HTTP-сервера — осыпавшиеся соединения, проблемы с TLS — пойдут через тот же JSON-обработчик на уровне ERROR, а не текстом в stderr. Строки вашего кода после SetDefault тоже в общем потоке; log.Fatal в конце стоит здесь намеренно — о нём отдельный разговор ниже.
Таблица соответствия вызовов#
Замена вызовов механична, если под рукой есть таблица соответствия. Прямые переезды очевидны: log.Print и log.Println превращаются в slog.Info с одним сообщением, log.Printf — в slog.Info с fmt.Sprint-склейкой или, лучше, с настоящими атрибутами: раз уж переписывается вызов, время разложить fmt.Sprintf("подключение к %s", host) на два поля. Сложнее с парой Fatal: log.Fatalf печатает запись и завершает процесс кодом один, а slog.Error только печатает — выход нужно дописать руками. Обёртки log.New со своим writer переезжают на slog.New с обработчиком, а для чужого API, требующего log.Logger, остаётся NewLogLogger. Отдельно про место вызова: если в проекте есть хелпер с log.Output(2, msg) ради правильного имени файла в выводе, при переезде на slog вычислять глубину не нужно — место вызова достаёт Handler через AddSource. Таблица ниже закрывает большинство кодовых баз; остальное решается по месту с той же логикой.
Вызовы пакета log и их преемники#
| Было на log | Стало на slog | Замечание |
|---|---|---|
| log.Println("старт") | slog.Info("старт") | прямой переезд без изменений смысла |
| log.Printf("подключение к %s", host) | slog.Info("подключение", "host", host) | строку формата раскладывают на атрибуты |
| log.Fatalf("конфиг: %v", err) | slog.Error("конфиг", "err", err); os.Exit(1) | выход дописывают руками: Error не завершает процесс |
| log.New(f, "", 0) | slog.New(slog.NewJSONHandler(f, nil)) | свой writer становится обработчиком |
| log.Logger в чужом API | slog.NewLogLogger(h, slog.LevelError) | мост для http.Server.ErrorLog и подобных |
Замена Fatalf на две строки
GO// было
log.Fatalf("не удалось открыть конфиг: %v", err)
// стало
slog.Error("не удалось открыть конфиг", "err", err)
os.Exit(1)Главная ловушка миграции: slog.Error только пишет запись, завершения процесса у него нет. Забытый os.Exit оставляет сервис жить с пустым конфигом — кейс ниже ровно про это. Заодно атрибут err стал структурным полем, а не частью строки.
Кейс: при миграции обёртки заменили log.Fatalf на slog.Error механической правкой, а os.Exit дописали не везде. Воркер очереди с битым URL поднялся, записал ошибку конфигурации уровня ERROR — и продолжил работу с пустыми настройками: воркеры без подписок, ретраи без лимитов. На мониторинге сервис был зелёным, строка с ошибкой тонула в потоке. Найдено было по простой примете: процесс жив, а большинство метрик нулевые. Лечение: в каждом месте, где стоял Fatal, после slog.Error выходит os.Exit(1); тест на старт с битым конфигом проверяет код возврата — это перекликается с правилами уровней из третьего урока.
План migrate log slog: переписывание по слоям#
Собственный код переезжает не по файлам, а по слоям, и порядок здесь такой же, как у зависимостей. Сначала транспорт: HTTP-обработчики и middleware — они чаще всего пишут, и именно здесь логгер на запрос из восьмого урока даёт мгновенный прирост. Затем слой логики: бизнес-операции с настоящими атрибутами вместо Printf-склеек. Утилиты и обёртки переезжают последними, потому что на них держится меньше всего контекста. После каждого слоя проект собирается, запускается и логируется целиком: старые строки через мост, новые через обработчик, общий формат один. Готовность проверяется поиском: в собственном коде не остаётся вызовов log, кроме мостов для чужих API, а grep по log.Print по пакетам проекта возвращает пустоту. Про инверсию — когда свою библиотеку требуется отдать наружу с API на log.Logger — снова выручает NewLogLogger: внутри библиотека живёт на slog, наружу отдаёт мост. Такой план не требует заморозки релизов: слои мигрируют между обычными задачами, а продуктовый поток не замечает переезда.
Проверки после миграции#
Финал миграции — не последний правленный файл, а список проверок. Первая: формат — все строки сервиса в одном выводе, и текстовые строки с датой в стиле пакета log больше не появляются ни в stdout, ни в stderr; их ловит простой скрипт по суффиксу формата. Вторая: уровни — записи, переехавшие как Info, размечены заново там, где на самом деле были ошибки; мост пишёт всё на Info, и часть Error-событий легаси-кода первое время живёт на уровне Info — это приемлемо на переходном этапе, но размечается в бэклоге. Третья: Fatal-пути — каждое место с выходом из процесса проверяется битым конфигом и недоступной зависимостью. Четвёртая: тесты из двадцатого урока — захват вывода в память и утверждения по полям работают и для мостов, потому что мост пишет через тот же Handler. Пятая: дашборды — фильтры по полям не опустели после смены путей, как это случалось с группами в седьмом уроке. Этот же чек-лист пригодится позже, когда сервис переедет на прод-настройки из восемнадцатого урока.
Отдельного разговора заслуживают обёртки-фасады, которые встречаются в больших проектах: файл logger.go с функциями LogInfo, LogError и глобальной переменной внутри. При миграции у фасада два пути. Первый — оставить фасад и поменять его внутренности на slog: вызовы по коду не трогаются, но и польза от структурных полей приходит лишь там, где фасад принимал атрибуты; как правило, фасад на log принимает строку, и это тупик переезда. Второй — расформировать фасад и звать slog в точках записи: правок больше, зато атрибуты появляются сразу. Практика показывает: фасад, написанный ради fmt-подобного API, живёт недолго — его раскрывают, как только дело доходит до полей; фасад ради централизованной настройки заменяется конструированием логгера в main и передачей потомков. В обоих случаях помогает правило из восьмого урока: логгер — значение, которое передают, а не функция, которую ищут.
В песочнице нет ни log/slog, ни пакета log — там работают fmt, strings, strconv, math, sort, errors и time. Механику честно разминают на fmt и time: соберите функцию-обёртку, печатающую строку с префиксом времени через time.Now().Format — это и есть модель моста вокруг чужого writer. Полные мосты NewLogLogger и SetDefault запускаются локально через go run; интерактивы ниже проверят назначение NewLogLogger, замену Fatalf и порядок миграции.
Чек-лист миграции#
- Day one: SetDefault в начале main — легаси-строки уже в общем формате.
- Мосты NewLogLogger для API, требующих log.Logger: http.Server.ErrorLog и подобные.
- Собственный код переезжает слоями: транспорт, логика, утилиты — с работающей сборкой после каждого.
- Каждый Fatal превращается в slog.Error плюс os.Exit(1): выход дописывают руками.
- Printf-строки раскладывают на атрибуты, а не переносят склейкой в msg.
- Финал: grep по log.Print в своём коде пуст, чек-лист проверок пройден.
Связи с другими уроками: шестнадцатый урок про дефолтный логгер разбирает первый мост — SetDefault — со всеми побочными эффектами, второй урок показывает исходный формат, из которого всё переезжает. Формат вывода настраивает пятый урок про TextHandler и JSONHandler, тесты мостов пишет двадцатый урок, а прод-окружение для переезжающего сервиса готовит курс по Viper. Карта курса — на странице курса.
Проверка: NewLogLogger
Зачем нужен slog.NewLogLogger(h, slog.LevelError), если весь код сервиса уже на slog?
Проверка: замена Fatalf
log.Fatalf("конфиг: %v", err) переписали на slog.Error("конфиг", "err", err) и забыли дописать выход. Что случится при битом конфиге?
Что окажется в выводе?
Прочитайте код и выберите, что он выведет:
h := slog.NewJSONHandler(os.Stdout, nil)
slog.SetDefault(slog.New(h))
legacy := slog.NewLogLogger(h, slog.LevelError)
legacy.Print("соединение оборвано")
log.Println("легаси-строка")Соберите мосты
Допишите: мост для http.Server, вывод через старый пакет и назначение дефолтного логгера.
Мосты в одной программе
Расставьте строки по порядку: обработчик, логгер, назначение дефолтным, мост для легаси, печать через мост, структурная запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: миграция с log
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Три моста миграции: SetDefault для строк пакета log, NewLogLogger для API на log.Logger, замена вызовов слоями.
- NewLogLogger(handler, level) отдаёт чужому коду настоящий log.Logger, пишущий структурными записями.
- log.Fatalf превращается в slog.Error плюс os.Exit(1): без выхода сервис продолжит работу с пустым конфигом.
- Printf-строки раскладывают на атрибуты: склейка через fmt.Sprintf в msg — полшага, а не переезд.
- Хелперам с log.Output глубина вызова больше не нужна: место вызова достаёт Handler через AddSource.
- Слои мигрируют от транспорта к утилитам, после каждого проект собирается и логируется целиком.
Домашнее задание
Возьмите небольшой проект на пакете log или скопируйте пример из уроков локально: поставьте SetDefault в начало main, переведите транспортный слой на slog и проверьте, что старые и новые строки пишутся одним форматом. Затем найдите все Fatalf, замените их парой Error плюс os.Exit(1) и устроите тест: битый конфиг должен завершать процесс с кодом 1.
Личные заметкиПусто
Вопросы и ответы по уроку
С чего начать миграцию: с чужих библиотек или со своего кода?
Со своего, а чужие подключаются мостами: SetDefault забирает строки библиотек на пакете log уже в первый день, а для API с параметром log.Logger ставится NewLogLogger. Переписывать чужой код не получится, и не нужно: мост закрывает вопрос вывода, а вы занимаетесь своими слоями.
Что делать со строками, которые после моста пишутся на Info, а по смыслу — ошибки?
На переходном этапе это приемлемо: мост не знает уровня, и всё идёт как Info. Разметку выносят в бэклог и по ходу миграции слоёв переводят значимые строки на slog.Error с настоящими атрибутами; полностью убрать мосты можно, когда свой код и ключевые зависимости переехали.
Стоит ли мигрировать на slog, если в проекте уже zap?
Сравнение логгеров — отдельный разговор, но мосты и там и там похожи: для zap есть zapstd.LogLogger и обёртки, для slog — NewLogLogger. Миграция имеет смысл, когда хочется уйти от зависимости к стандартной библиотеке; если zap уже приносит пользу, переезд не срочный.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Ошибки в логах: err как атрибут, wrap и детали
- Свой Handler: интерфейс, Record и WithAttrs
- Конкурентность: один логгер на сервис
- Производительность: Enabled и горячий путь
- LogValuer: отложенные и «опасные» значения
- With: предзаданные поля и логгер на запрос
- Группы: WithGroup и вложенные поля
- Дефолтный логгер: SetDefault и старый пакет log
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY