---
title: "Логирование задач: zap и slog рядом"
description: "Логирование cron в go: события планировщика через WithLogger, адаптеры zap и slog, поля лога задач job и длительность, уровни и корреляция логов с метриками."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/17-logirovanie-zadach/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Логирование задач: zap и slog рядом

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/17-logirovanie-zadach/*

Логирование cron сводится к двум потокам: события самого планировщика — старт, стоп, добавление записи, тик — и записи, которые делают ваши задачи. У robfig/cron v3 есть для этого интерфейс cron.Logger и опция WithLogger, но по умолчанию планировщик пишет в io.Discard, а ни zap, ни slog его интерфейс из коробки не реализуют — нужен адаптер на десять строк. Разберём, какие события отдаёт библиотека, как подключить структурный логгер, какие поля делают разбор инцидента быстрым и где проходит граница между полезным логом и шумом.

Инцидент для старта: ночью пропали выгрузки, дежурный открыл логи и увидел строки вида func1 0x7f3a2c, повторяющиеся в случайные моменты. В них нет ни имени задачи, ни выражения, ни результата — только адрес анонимной функции из AddFunc. Понять, какая задача работала, оказалось возможно только по стеку внутри pprof-снимка, и это заняло дольше, чем сам сбой. Классическая ошибка — считать, что планировщик сам расскажет о себе: в robfig/cron v3 события логгера по умолчанию отбрасываются, а задачи пишут в общий лог без общих полей. Рабочая схема складывается из двух потоков: события библиотеки через cron.Logger и структурные записи самих задач с полем job — обе половины настраиваются один раз на старте и дальше работают на весь сервис. Этот урок закрывает первую половину, а про наблюдаемость в числах говорит следующий — про [метрики задач](/courses/cron/lessons/18-metriki-zadach/).

### Логирование cron: события планировщика и тишина по умолчанию

Библиотека объявляет маленький интерфейс: type Logger interface { Info(msg string, keysAndValues ...interface{}); Error(err error, msg string, keysAndValues ...interface{}) } и опцию cron.WithLogger(l). Логирование cron начинается с честного факта: если WithLogger не передать, логгером будет PrintfLogger поверх log.New(io.Discard, ...), то есть все события уходят в никуда — ни паники Recover, ни тики не увидят свет. Второй факт: PrintfLogger печатает только ошибки, а для Info-событий нужен VerbosePrintfLogger — иначе вы подключили логгер, который молчит на тиках. Какие события библиотека отдаёт: start и stop при запуске и остановке, added и removed при регистрации и снятии записи, wake при пробуждении цикла и run — строка на каждое срабатывание с номером записи и моментом следующего тика. Это удобный каркас диагностики: вопрос «а задача вообще запланирована» закрывается строками added и run. Сами события несут только идентификаторы и время — бизнес-детали добавляете вы.

### Планировщик с видимыми событиями

```go
func main() {
    c := cron.New(cron.WithChain(
        cron.Recover(cronLogger{log.New(os.Stdout, "", log.LstdFlags)}),
    ), cron.WithLogger(cronLogger{log.New(os.Stdout, "", log.LstdFlags)}))

    c.AddFunc("*/15 * * * *", pullOrders)
    c.Start()
    log.Println("планировщик запущен")
    select {}
}
```

Один и тот же логгер передаётся в WithLogger для событий библиотеки и в Recover для паник — оба потока идут в одно место и бьются в одном поиске. Без VerbosePrintfLogger событие run осталось бы невидимым даже при подключённом WithLogger. Здесь для наглядности стандартный log.Logger; дальше его место занимает структурный логгер.

### Проверка: что печатает PrintfLogger

**Вопрос:** Планировщик создан с WithLogger(cron.PrintfLogger(l)), задача отпаниковала, Recover поймал. Что увидим в l?

- Ничего: PrintfLogger отбрасывает и Info, и Error
- ✅ Только запись об ошибке: Info-события PrintfLogger не печатает
- Все события: start, run, стоп и ошибку с трейсом
- Ошибку только если рядом стоит VerbosePrintfLogger

PrintfLogger печатает только Error, а Info-события — start, stop, run, added — молча пропускает; чтобы видеть и их, ставят VerbosePrintfLogger. Отдельный случай — вообще без WithLogger: тогда логгером становится запись в io.Discard, и даже ошибки не увидят свет.

### zap и slog: адаптер на десять строк

Ни zap, ни slog не реализуют cron.Logger из коробки, поэтому между ними и планировщиком ставится адаптер — структура с двумя методами. Для slog связка естественна: методы Info и Error принимают msg и пары ключ-значение, и slog.Info(msg, kv...) с ними совпадает почти дословно. Для zap удобнее SugaredLogger: Infow и Errorw принимают те же пары, а в Errorw ошибку передают первой парой err. Адаптер лучше писать один на сервис и держать рядом с конфигурацией логгера — место, где его найдут все новые задачи. Уровни переводятся честно: события библиотеки — это Info, ошибки Recover и ошибок парсинга — Error. Если сервис уже ведёт поля окружения через [zap.With и Named](/courses/zap/lessons/12-with-named/), адаптеру можно присвоить имя cron, чтобы события планировщика отличались в поиске от HTTP-запросов. Базовую семантику уровней напомнит [урок про уровни логов](/courses/zap/lessons/03-urovni-logov/), а детали полей — [урок про структурные поля zap](/courses/zap/lessons/04-strukturnye-polya/).

### Адаптеры для slog и zap

```go
type slogCronLogger struct{ l *slog.Logger }

func (s slogCronLogger) Info(msg string, kv ...interface{}) {
    s.l.Info(msg, kv...)
}

func (s slogCronLogger) Error(err error, msg string, kv ...interface{}) {
    s.l.Error(msg, append([]interface{}{"err", err}, kv...)...)
}

type zapCronLogger struct{ l *zap.SugaredLogger }

func (z zapCronLogger) Info(msg string, kv ...interface{}) {
    z.l.Infow(msg, kv...)
}

func (z zapCronLogger) Error(err error, msg string, kv ...interface{}) {
    z.l.Errorw(msg, append([]interface{}{"err", err}, kv...)...)
}
```

Два адаптера — весь мост между интерфейсом библиотеки и структурными логгерами. Разница только в способе передать ошибку: slog ждёт её парой ключ-значение, zap — первой парой в Errorw. Оба адаптера потокобезопасны, поэтому один экземпляр разделяется планировщиком, задачами и Recover без оговорок.

### Проверка: имя задачи в логе

**Вопрос:** Десять задач пишут в общий slog-логгер. Как в записях различать задачи?

- Ничего не добавлять: slog сам проставит адрес функции
- ✅ Дочерний логгер на задачу через slog.With и поле job=cleanup
- Писать имя задачи в само сообщение текстом
- Заводить отдельный файл лога на каждую задачу

Поле job через дочерний логгер — то же, что zap.With в HTTP-слое: имя задачи становится атрибутом записи, по нему работают фильтры и поиск. Адрес функции в поле не приходит — библиотека его не знает, а в сообщение имя вкладывать хуже: его нельзя искать атрибутом. Имена задаются один раз при регистрации в конфиге задач.

### Поля лога задач: job, длительность, исход

Лог задачи становится полезным, когда поля повторяются от задачи к задаче и совпадают с метриками. Минимальный набор: job — имя из конфига, длительность тика, исход — ok или описание ошибки, при ретраях — номер попытки. Старт тика логировать целиком нет смысла: событие run от планировщика уже есть, а повторная строка только удваивает объём; информативнее запись после завершения. Уровни распределяются так: успешный тик — Info, пропуск и задержка от обёрток — Warn, ошибка и паника — Error. Выигрывает от того же правила, что и HTTP: одна запись на операцию со всеми полями, а не россыпь Println по ходу. Ошибки из глубины задачи оборачивают через fmt.Errorf с контекстом — механику %w и errors.Is разбирал [урок про оборачивание ошибок](/courses/go-advanced/lessons/15-oshibki-wrapping/). Согласованность с метриками проверяется просто: те же значения поля job и те же исходы, что и в CounterVec, — тогда связка «алерт поднял метрику, лог нашёл причину» работает без ручного сопоставления.

> **Чувствительные данные в логе задач.** Кейс: сервис напоминаний логировал текст сообщения и адрес получателя на каждом тике — через месяц в логе лежали персональные данные пользователей, а лог хранился год. Правило для задач то же, что и для HTTP: логируйте идентификаторы, а не содержимое. ID напоминания, число получателей, код ошибки внешнего сервиса — достаточно для разбора; текст сообщения, токен и адрес — не логировать вовсе. Отдельный случай — ошибки внешних API: текст ошибки может содержать данные запроса, поэтому перед записью ошибку приводят к типу и берут из него код. Политика хранения логов — тема соседнего курса про эксплуатацию, но её минимум на стороне задач: поля без персональных данных и единый формат.

### Что напечатает slog-запись?

```go
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
log := logger.With("job", "cleanup")
log.Info("тик завершён", "dur", "1.2s")
```

**Что выведет программа?**

- ✅ time=... level=INFO msg="тик завершён" job=cleanup dur=1.2s
- тик завершён job=cleanup dur=1.2s
- INFO job=cleanup: тик завершён dur=1.2s
- time=... level=INFO job=cleanup msg="тик завершён"

Текстовый хендлер печатает пары ключ-значение: время и уровень добавляются всегда, msg идёт после уровня, затем общие атрибуты дочернего логгера — job, затем пары конкретного вызова. По job фильтруют задачи, по msg ищут события, по dur строят выборки.

### Что логировать на каком уровне

| Событие | Уровень | Поля | Кто пишет |
| --- | --- | --- | --- |
| старт и стоп планировщика | Info | событие от библиотеки | cron.Logger через WithLogger |
| регистрация и снятие записи | Info | job, идентификатор EntryID | cron.Logger через WithLogger |
| завершение тика | Info | job, длительность, исход | код задачи |
| пропуск или задержка тика | Warn | job, причина | обёртки Skip и Delay |
| ошибка и паника задачи | Error | job, err, попытка | код задачи и Recover |

### Логи рядом с метриками: одна история инцидента

Логи задач go-сервиса живут в паре с метриками: одна сторона показывает, что деградация есть, вторая — почему. Рабочий сценарий: алерт поднялся по росту ошибок выгрузки, дежурный фильтрует логи по полю job=pull-orders и окну времени алерта, находит строку ошибки с кодом внешнего сервиса. Эта связка работает, только если поля совпадают: значение job в логе равно значению лейбла job в CounterVec, а исход в логе совпадает с лейблом status. Приём корреляции логов и метрик на примере веб-сервиса разобран в [уроке про логи и метрики](/courses/prometheus/lessons/21-logi-i-metriki/); для cron он ложится один в один. Второй уровень связи — трейс: если сервис раздаёт request id на HTTP-запросы из [урока про request id](/courses/zap/lessons/14-request-id/), задачам, которые дёргают API, полезно передавать свой id вызова — тогда лог задачи и лог внешнего запроса сшиваются. Ошибки и стектрейсы в zap — отдельная настройка, которую подробно делал [урок про ошибки и стеки](/courses/zap/lessons/18-oshibki-steki/).

### Шум против пользы: спорное про лог на каждый тик

Спорная привычка, которую стоит оспорить: логировать завершение каждого тика каждой задачи на Info. Двадцать задач с пятиминутным интервалом дают почти шесть тысяч записей в сутки только о благополучных тиках, и в них тонут записи о том, что действительно случилось. Мера разумности простая: лог должен уменьшать время разбора, а не увеличивать объём поиска. Для стабильных задач достаточно событий библиотеки — run в verbose-логгере показывает, что тик состоялся, без вашей записи; свою строку пишут при отклонении от нормы: длительность выше порога, ошибка, повтор. Второй инструмент против шума — сэмплирование: если запись нужна на каждый тик, например для аудита, их гасят сэмплером из [урока про сэмплирование zap](/courses/zap/lessons/11-sampling/). Граница, которую нельзя переступить: Warn и Error сэмплировать не стоит — это те записи, ради которых лог существует. Баланс пересматривают после инцидентов: если дежурный листает девять экранов ради одной строки ошибки, настройки шумят.

### Чеклист лога задач

- WithLogger подключён, события start, run, added видны в общем поиске.
- Адаптер cron.Logger один на сервис: для slog или zap, рядом с конфигом логгера.
- Поле job у каждой задачи из конфига, значение совпадает с лейблом метрики.
- Поля записи: длительность, исход, попытка — без персональных данных и токенов.
- Уровни по правилу: Info — завершение и отклонения, Warn — пропуски, Error — ошибки и паники.
- Записи о благополучных тиках стабильных задач не пишутся или сэмплируются.

### Соберите связку slog и планировщика

Впишите создание текстового хендлера, опцию логгера и метод для ошибок в адаптере.

```go
logger := slog.New(slog.___(os.Stdout, nil))
c := cron.New(cron.___(slogCronLogger{logger}))
c.AddFunc("0 2 * * *", digest)

func (s slogCronLogger) Error(err error, msg string, kv ...interface{}) {
    s.l.___(msg, append([]interface{}{"err", err}, kv...)...)
}
```

Подсказка: Хендлер — NewTextHandler; опция планировщика — WithLogger; метод интерфейса — Error.

Правильные ответы в пропусках: `NewTextHandler`, `WithLogger`, `Error`

### Расставьте сборку логирующего планировщика

Расставьте строки в порядке файла: логгер, адаптер, планировщик с двумя подключениями, регистрация, старт.

Правильный порядок строк:

1. `base := slog.New(slog.NewTextHandler(os.Stdout, nil))`
2. `cl := slogCronLogger{base}`
3. `c := cron.New(cron.WithChain(cron.Recover(cl)), cron.WithLogger(cl))`
4. `c.AddFunc("*/15 * * * *", pullOrders)`
5. `c.Start()`
6. `select {}`

### Карточки: логи задач

- **cron.Logger** — Интерфейс с Info и Error, который библиотека зовёт на событиях. Подключается опцией WithLogger; по умолчанию запись идёт в io.Discard.
- **PrintfLogger против Verbose** — PrintfLogger печатает только ошибки; VerbosePrintfLogger печатает и Info-события: start, stop, run, added, removed.
- **Адаптер для zap и slog** — Структура с двумя методами, переводящая вызовы в Infow/Errorw или slog.Info. Один экземпляр на сервис, потокобезопасен.
- **Поле job** — Имя задачи в дочернем логгере через With: совпадает с лейблом метрики, по нему фильтруется поиск. Адрес функции полем не приходит.
- **Уровни лога задач** — Info — завершение тика и отклонения, Warn — пропуски и задержки от обёрток, Error — ошибки и паники. Error не сэмплируется.
- **Корреляция с метриками** — Те же значения job и исходов, что в лейблах CounterVec: алерт по метрике мгновенно сшивается с логом по полю и окну времени.

> **Где запускать примеры.** Пакета cron и модулей slog и zap в песочнице нет, но механику адаптера и полей записи она закрывает: предикт о формате вывода переписывается на fmt.Println с теми же парами ключ-значение, а обёртка-логгер — обычная структура с двумя методами, которую песочница умеет исполнять через fmt.Println. Пакет log и log.New для PrintfLogger работают локально из модуля второго урока, там же ставятся zap и slog через go get. Упражнение для песочницы: функция, превращающая структурную запись в строку пар ключ-значение в формате текстового хендлера.

Логирование cron — настройка на старте, которая потом работает на весь сервис: WithLogger открывает события планировщика, адаптер переносит их в zap или slog, а поле job у каждой задачи сшивает записи в единый поток, совместимый с метриками. Правила полей повторяют HTTP-мир: идентификаторы вместо содержимого, одна запись на тик, уровни по факту события, Warn и Error без сэмплирования. Дальше по маршруту: [метрики задач](/courses/cron/lessons/18-metriki-zadach/) — вторая половина наблюдаемости, к которой лог теперь готов, и [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) — как остановка сервиса отражается в логе. Если возвращались с пропуска — [обёртки wrappers](/courses/cron/lessons/16-wrappers/) объясняют, откуда берутся записи о пропусках и паниках, а [конфиг и база](/courses/cron/lessons/14-raspisanie-iz-konfiga/) — где живут имена задач для поля job. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — адаптер, поле job и связка алерта с логом.

## Коротко: что запомнить

- По умолчанию cron пишет в io.Discard: без WithLogger события планировщика не видны нигде.
- PrintfLogger печатает только ошибки; для Info-событий start, run, added нужен VerbosePrintfLogger.
- Адаптер cron.Logger — структура с Info и Error: для slog почти дословно, для zap — через SugaredLogger.
- Поле job из конфига в дочернем логгере: совпадает с лейблом метрики, по нему ищут записи.
- Поля записи: длительность, исход, попытка; без персональных данных, токенов и текстов сообщений.
- Уровни: Info — завершение тика, Warn — пропуски, Error — ошибки и паники; шум стабильных тиков не пишут.

## Домашнее задание

Подключите WithLogger через адаптер: напишите slogCronLogger с Info и Error, передайте его и в WithLogger, и в Recover. Добавьте каждой задаче поле job из конфига расписания и логируйте завершение тика с длительностью и исходом. Проверьте в выводе все события: start, run, added, запись задачи и панику из тестовой задачи. Затем заведите сэмплер для Info-записей шумной задачи и убедитесь, что Warn и Error при этом остаются без потерь. Финал — свяжите значение job с лейблом метрики из следующего урока: алерт по ошибкам должен находить лог за один фильтр.

## Вопросы и ответы по уроку

### Почему события планировщика не видны без настройки?

Логгером по умолчанию служит PrintfLogger поверх log.Logger, пишущего в io.Discard: все Info и Error отбрасываются. Опция WithLogger подключает свой логгер, а PrintfLogger и VerbosePrintfLogger различаются тем, что первый печатает только ошибки.

### Как логировать имя задачи, если AddFunc принимает анонимную функцию?

Имя задаёт ваш код: дочерний логгер с полем job через slog.With или zap.Named, создаваемый рядом с регистрацией задачи. Библиотека в событиях даёт только EntryID, поэтому осмысленное имя держит конфиг задач и структура из урока про AddJob.

### Что делать с задачами, которые тикают каждые 30 секунд и шумят лог?

Записи о благополучных тиках стабильных задач не пишите или сэмплируйте: событие run от verbose-логгера уже показывает факт запуска. Warn и Error сэмплировать не стоит — они и есть материал разбора инцидентов.
