1. Зачем нужен slog
Когда вы только начинаете писать программы, кажется, что log.Println("сюда дошли") — это уже вершина инженерной мысли. И правда: пока проект маленький, всё работает. Но как только программа начинает делать больше одного действия, а ошибок становится больше одной, строки в логах превращаются в «детектив без улик».
Проблема не в том, что строки плохие. Проблема в том, что строки плохо ищутся и плохо фильтруются. Вы хотите быстро ответить на вопросы: «какая операция упала?», «сколько заняла по времени?», «по какому id?», «сколько было элементов?», «это ошибка или просто предупреждение?». Если лог — это одна строка текста, вам приходится надеяться, что формат этой строки всегда одинаковый (а он почти никогда не одинаковый).
log/slog решает это через две идеи. Первая — уровни логов (Debug/Info/Warn/Error), чтобы управлять «шумом». Вторая — структура: лог — это не только текст, но и набор полей (attributes), которые можно стабильно добавлять и потом анализировать.
В этой лекции мы сфокусируемся на фундаменте: что такое Logger и Handler, и как работают уровни. Про поля поговорим позже — иначе мозг начнёт делать panic: stack overflow прямо у вас в голове.
2. Модель slog: Logger и Handler
Когда люди впервые видят slog, они часто пытаются запомнить «волшебную строчку инициализации» и дальше копировать её из проекта в проект. Это работает… до первой попытки что-то поменять. Поэтому лучше понять модель.
В slog есть два ключевых объекта.
Handler — это «куда и как писать». Он отвечает за формат (например, текстовый или JSON) и за вывод (обычно os.Stderr), а также за фильтрацию по уровню.
Logger — это «что писать». Он создаёт записи (Info, Error, …) и передаёт их в Handler.
Можно представлять это так:
flowchart LR
A["Ваш код: logger.Info(...)"] --> B["slog.Logger"]
B --> C["slog.Handler<br/>(TextHandler/JSONHandler)"]
C --> D["io.Writer<br/>(обычно os.Stderr)"]
Заметьте, насколько это похоже на здравый смысл: вы в коде «просите залогировать», а где-то ниже решается, в каком формате и куда именно это попадёт.
3. Быстрый старт: Logger с TextHandler
Когда вы впервые подключаете slog, вам нужна минимальная «точка старта». В идеале она занимает несколько строк и не требует шаманства.
Вот самый простой вариант: текстовый handler, вывод в stderr, и логгер поверх него.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
logger.Info("app started")
}
Обратите внимание на две вещи. Во-первых, мы явно используем os.Stderr, чтобы не смешивать диагностику с результатом команды. Во-вторых, вторым параметром в NewTextHandler мы передали nil: это значит «опции по умолчанию».
Если запустить такую программу, вы увидите запись примерно в стиле time=... level=INFO msg="app started" (точный вид зависит от handler’а и версии).
4. Уровни логов и порог в HandlerOptions
С уровнями у новичков часто возникает путаница. Кажется, что это просто четыре варианта сказать «что-то произошло», но на практике уровни — это ваш главный инструмент против лог-хаоса.
Идея простая: у логов есть «важность». Когда всё работает, вам не хочется читать 10 тысяч строк отладки. Когда что-то сломалось, вам наоборот хочется больше деталей. Поэтому уровни должны позволять вам включать и выключать детали одним «краном».
В slog есть стандартные уровни: Debug, Info, Warn, Error. Обычно их читают так: Debug — подробности для разработчика, Info — нормальные события жизни приложения, Warn — что-то подозрительное, но не фатальное, Error — операция провалилась.
Самое важное: видимость логов зависит от порога уровня, который задаётся в HandlerOptions.
Посмотрим на пример, где Debug не выводится, потому что порог выставлен на INFO.
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("debug is hidden")
logger.Info("info is visible")
}
Это почти идеальный «антишум» по умолчанию: в обычном запуске пользователю и вам (как оператору/разработчику) достаточно Info и выше. А когда нужно расследование, вы поднимаете детализацию до Debug.
Теперь включим Debug:
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("now debug is visible")
}
Заметьте: мы не меняли код в местах логирования. Мы поменяли только настройки handler’а. Это и есть смысл уровней: управление детализацией должно быть централизованным.
5. Конфигурация handler’а: уровень и форматы
Почему уровень задаётся на Handler, а не на Logger
Вопрос, который звучит странно, пока вы не наступили на грабли: почему порог уровня задаётся опциями handler’а?
Потому что фильтрация — это часть «куда и как писать». Сегодня вы пишете в stderr текстом и хотите показывать только Info+. Завтра вы пишете JSON в файл и хотите сохранять всё, включая Debug, потому что это окружение отладки. Послезавтра вы пишете в два места разными handler’ами (это уже более продвинуто, но сама идея логична).
Если уровень живёт в handler’е, вы можете переиспользовать один и тот же Logger-вызов (например, logger.Debug("...")) и просто менять конфигурацию вывода, не переписывая приложение.
TextHandler и JSONHandler: один смысл, два формата
В slog есть два самых популярных «готовых» handler’а: TextHandler и JSONHandler. И тут новичкам важно не попасть в ловушку «JSON всегда лучше, потому что современно».
TextHandler хорош, когда вы читаете логи глазами: локальная разработка, учебные проекты, быстрый запуск в терминале. Он обычно даёт компактный и читаемый формат.
JSONHandler хорош, когда логи будет читать не человек, а система: сборщик логов, поиск, фильтрация, агрегация. Даже если вы пока не используете такие системы, полезно понимать, зачем этот формат существует.
Вот минимальный пример JSON-логов:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
logger.Info("app started")
}
Вывод будет в виде JSON-объекта на строку (формат «json lines»). Опять же: точная форма зависит от реализации, но идея всегда одна — машинам проще с этим жить.
6. Логгер по умолчанию: slog.SetDefault
Когда проект маленький, можно держать logger в переменной и прокидывать его руками. Но в реальности вы быстро увидите, что часть кода зовёт пакетные функции slog.Info("..."), а часть использует logger.Info("..."). Если эти два мира не согласованы, у вас получится смешение форматов и настроек.
В slog есть механизм «логгер по умолчанию»: вы создаёте логгер и назначаете его дефолтным. Тогда пакетные вызовы slog.Info используют вашу настройку.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
slog.SetDefault(logger)
slog.Info("started via default logger")
}
Важно относиться к этому как к глобальной настройке процесса: вы сделали это один раз при старте — и дальше весь код, который использует slog.*, пишет в одном стиле.
Паранойя-разработчика подсказывает: «глобальное — зло». Иногда да, но здесь это скорее централизованный конфиг. В следующих лекциях мы обсудим, где логировать и как не плодить хаос, а пока нам важно просто научиться уверенно собирать «рабочий» логгер.
7. Встраивание в CLI: stdout отдельно, логи отдельно
Сейчас сделаем маленький шаг в сторону нашего учебного CLI-приложения (условно назовём его todo). Задача: оставить результат команды в stdout, а диагностику — в stderr через slog.
Представим, что у нас есть run() — функция, которая делает полезную работу и возвращает error. Мы хотим залогировать старт/финиш команды, но не смешать это с выводом результата.
Минимальная «обвязка» в main может выглядеть так:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := run(logger); err != nil {
logger.Error("command failed")
os.Exit(1)
}
}
Здесь мы сделали две вещи. Мы создали логгер, а потом передали его в run(logger). Да, это чуть больше кода, чем «просто глобальный логгер», но зато вы уже сейчас чувствуете границу: main собирает зависимости (включая логгер), а логика выполнения живёт внутри run.
Теперь давайте сделаем run, которая печатает результат в stdout (через fmt.Println) и параллельно пишет диагностику в stderr (через logger.Info). Это отличный пример разделения потоков.
package main
import (
"fmt"
"log/slog"
)
func run(logger *slog.Logger) error {
logger.Info("listing tasks")
fmt.Println("0 tasks") // 0 tasks
return nil
}
Если вы запустите программу, вы увидите «0 tasks» в stdout (это контракт результата), а лог «listing tasks» — в stderr. В терминале они могут визуально смешиваться, но для скриптов, пайпов и тестов это два разных мира.
8. Dev и prod: включаем Debug без переписывания кода
Очень частый сценарий: вы хотите, чтобы в разработке было больше деталей, а в обычном запуске — меньше шума. Мы пока не вводим env-конфиг как полноценную тему (она у вас будет в отдельном дне), но чисто технически можно показать переключение уровней на константе.
package main
import (
"log/slog"
"os"
)
func main() {
level := slog.LevelInfo // поменяйте на slog.LevelDebug для разработки
opts := &slog.HandlerOptions{Level: level}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
logger.Debug("debug details") // в LevelInfo это не появится
logger.Info("start")
}
Ключевая мысль: вы не должны переписывать десятки мест в коде, чтобы «включить больше логов». Вы должны менять конфигурацию handler’а.
9. stdout и stderr: схема для CLI
Эта идея уже звучала раньше, но сейчас она особенно важна, потому что slog легко делает «правильный» вывод по умолчанию.
flowchart TB
A["CLI-команда"] --> B["Бизнес-логика"]
B --> C["stdout<br/>результат (таблица/JSON/сообщение)"]
B --> D["slog -> Handler -> stderr<br/>диагностика (уровни, контекст)"]
Когда вы начнёте писать тесты для CLI, вы внезапно оцените эту дисциплину: stdout можно сравнивать как контракт результата, а stderr можно либо игнорировать, либо анализировать отдельно. Если же всё смешать, тесты превращаются в борьбу с мусором.
10. Типичные ошибки при работе с log/slog
Ошибка №1: «Я залогировал Debug, но ничего не выводится — slog сломан».
Обычно тут не баг, а настройка: порог уровня в HandlerOptions выставлен выше, чем Debug. Это нормальное поведение: Debug часто специально скрыт по умолчанию, чтобы логи не превращались в роман на 700 страниц.
Ошибка №2: создают новый логгер в каждой функции, а потом удивляются разному формату и разным уровням.
Технически slog.New(...) — недорогая операция, но организационно это почти всегда ошибка. Логгер должен быть собран один раз (или хотя бы централизованно), иначе вы получаете зоопарк настроек: тут JSON, там Text, тут Debug, там Info.
Ошибка №3: пишут логи в stdout «потому что так проще посмотреть».
Это ломает идею «stdout как контракт результата». Пока вы в одиночку запускаете программу руками, кажется, что всё нормально. Но как только появятся пайпы, скрипты или автопроверка, эти «удобные» логи станут проблемой. Лучше приучиться: результат — stdout, логи — stderr.
Ошибка №4: путают роли Logger и Handler и пытаются «поставить уровень на логгер».
В slog фильтр уровня живёт в handler’е. Это не случайность, а дизайн: один и тот же логгер можно подключать к разным обработчикам (форматы/выводы/пороги). Если помнить эту модель, настройка становится логичной.
Ошибка №5: выбирают JSON «потому что модно», а потом читают его глазами и страдают.
JSON-логи прекрасны, когда их обрабатывает система. Когда вы читаете логи глазами в терминале, текстовый формат часто удобнее. Выбор формата — это не религия, а «кому читать: человеку или машине».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ