---
title: "Логи и метрики задач"
description: "Что логировать в хендлере и что пишет сам asynq, LogLevel в Config, счётчики Processed и Failed из Inspector и Prometheus-middleware для очередей."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/23-nablyudaemost/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

# Логи и метрики задач

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 23 из 25. ~20 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/23-nablyudaemost/*

Тикет гласит «задачи не исполняются», дашборд молчит, а дежурный спрашивает «с чего вы это взяли». Ответ дают логи и метрики: первые рассказывают историю одной попытки, вторые — картину очереди целиком. Настроим логирование хендлера и сервера, выведем счётчики asynq в Prometheus и договоримся, на что ставить алерты. Настройка занимает полчаса, а экономит первую половину каждого инцидента.

Наблюдаемость очереди держится на трёх слоях. Первый — логи хендлера: что происходило с конкретной задачей в конкретной попытке. Второй — логи самого сервера asynq: старт, остановка, исчерпанные ретраи; они уже пишутся за вас, нужно лишь выставить уровень. Третий — метрики: числа, по которым видна динамика очереди, а не история одного сообщения. Ошибка новичка — пытаться вытащить всё из одного слоя: логи не покажут, что очередь буксует уже час, а метрика не скажет, почему упала конкретная задача. В этом уроке пройдём все три: что писать в лог из хендлера, что настроить у сервера, откуда взять счётчики очереди и какие из них превратить в алерты. Библиотека не решает это за вас: asynq отдаёт сырье — контекст исполнения, статистику очереди, точки для обёрток, — а логи, экспорт и пороги остаются вашей работой. Слои дополняют друг друга и читаются в порядке: сначала метрика показывает, где болит, затем логи по ID задачи объясняют, почему именно там.

### Логи хендлера: пять полей на попытку

Диагностика начинается с хендлера: именно здесь происходит работа, и здесь лог ценнее всего. Минимальный набор — пять полей на каждую попытку. Первое: тип задачи t.Type() — чтобы по строке лога сразу найти нужный код. Второе: идентификатор — asynq.GetTaskID(ctx) достаёт ID постановки из контекста; строка уникальна для каждой постановки, по ней связаны все попытки одной задачи, и дежурный отвечает на вопрос «что стало с задачей из заказа 4711» одним поиском по логам. Третье: номер попытки — asynq.GetRetryCount(ctx) возвращает, сколько попыток уже было; ноль означает первый заход, а растущий счётчик при одной и той же ошибке — прямой сигнал, что повтор не поможет. Четвёртое: длительность — time.Since(start) вокруг полезной работы; внезапный рост при неизменном коде чаще означает деградацию внешнего сервиса, чем баг. Длительность удобно сравнивать между попытками: резкий скачок на первой и на сотой попытке — разные истории. Пятое: сама ошибка — вместе с ретрай-политикой она объясняет, ушла ли задача в повтор или в архив. Пишите структурно: key=value, а не художественную прозу — парсер системы логов скажет спасибо. И держите одну строку на попытку, без дублей по пути — иначе при ретраях получится шум, в котором пропадает сигнал.

### Хендлер с логом попытки

```go
func HandleInvoice(ctx context.Context, t *asynq.Task) error {
    id, _ := asynq.GetTaskID(ctx)
    attempt, _ := asynq.GetRetryCount(ctx)
    start := time.Now()

    err := chargeCustomer(ctx, string(t.Payload()))
    log.Printf("task=%s id=%s attempt=%d took=%s err=%v",
        t.Type(), id, attempt, time.Since(start), err)
    return err
}
```

GetTaskID и GetRetryCount читают контекст исполнения: ID привязывает все попытки одной постановки к одной строке поиска, attempt отличает первый заход от повторов. Ошибка логируется до возврата и попадает в ретрай-политику без изменений. Время замеряется вокруг полезной работы, а не вокруг лога: разница между попытками видна сразу.

### Логи сервера и уровень LogLevel

Второй слой — логи самого asynq: сервер пишет их без вашего участия, и стоит знать какие. На старте появляется «Starting processing», на остановке — «Starting graceful shutdown», затем сообщения про остановку процессора и «Exiting»: по этим строкам проверяют, что деплой прошёл чисто и серверы действительно завершились. Когда попытки задачи заканчиваются, сервер пишет предупреждение «Retry exhausted for task id=...» — полезный хвост для поиска задач, которые упали все двадцать пять раз. Эту строку легко алертить: она означает, что задача закончила жизнь в архиве и пора смотреть LastErr. Управляет этим поле Config.LogLevel со знакомой шкалой: DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel. Нюанс, о который спотыкаются: если LogLevel не задан, asynq логирует на DebugLevel — в продакшене это стены отладочных сообщений при каждом опросе очередей. Поэтому в боевом конфиге ставьте InfoLevel, а на шумных инсталляциях WarnLevel: останутся старт и остановка, исчерпанные ретраи и предупреждения, остальное уйдёт. Уровень задаётся в Config, а не переменной окружения: меняется вместе с деплоем, и это честная цена явного конфигурационного кода. В локальной разработке оставьте DebugLevel: подробности выборки задач из очередей экономят часы при непонятных зависаниях.

### Config с уровнем логирования

```go
srv := asynq.NewServer(
    asynq.RedisClientOpt{Addr: "localhost:6379"},
    asynq.Config{
        Concurrency: 10,
        LogLevel:    asynq.InfoLevel,
    },
)
```

LogLevel — обычное поле Config, значение — константа пакета asynq: InfoLevel оставит старт, остановку и предупреждения об исчерпанных ретраях, WarnLevel уберёт и информационные сообщения. Явный уровень спасает от дефолтного DebugLevel, который в продакшене превращает лог в сплошной шум опросов Redis. Уровень один на весь сервер: его применяют и внутренние компоненты, поэтому тише станут сразу все внутренние сообщения.

### Проверка: уровень по умолчанию

**Вопрос:** Какой уровень логирования работает у asynq, если поле LogLevel в Config не задано?

- InfoLevel
- ✅ DebugLevel
- WarnLevel
- FatalLevel

Незаданное поле трактуется внутри библиотеки как «не указан», и тогда работает максимально подробный DebugLevel. В продакшене уровень задают явно — обычно InfoLevel или WarnLevel, иначе логи растут быстрее очереди.

### Метрики очереди: Inspector как источник

Логи отвечают на вопрос «что случилось с этой задачей», метрики — «как поживает очередь». Источник чисел — Inspector из [пятнадцатого урока](/courses/asynq/lessons/15-inspektor/): GetQueueInfo возвращает QueueInfo со счётчиками по состояниям — Pending, Active, Scheduled, Retry, Archived, Completed, Aggregating — и общим Size, суммой всех состояний. Счётчики обработок живут рядом: Processed и Failed считаются с начала суток и сбрасываются ежедневно, ProcessedTotal и FailedTotal — накопительные с рождения очереди. Отдельного счётчика «retried» нет: число задач в retry-состоянии — это поле Retry, и растёт оно, когда попытки откладываются. Дубликаты Redis тоже не считает: уникальные задачи из двенадцатого урока отсекаются ещё на постановке, клиент возвращает ErrDuplicateTask — их можно посчитать только в своей клиентской обёртке, инкрементируя метрику при этой ошибке. Состояние Completed появляется, когда задачи оставляются с CompletedTTL, а поле Groups считает группы агрегации из десятого урока. Для истории есть метод History(queue, n): он возвращает DailyStats за последние n суток — Processed, Failed и дату, сырьё для графиков пропускной способности. И главное число для алертов — Latency, возраст самой старой pending-задачи. Обновлять срез удобно периодической задачей из девятого урока: раз в минуту читаете QueueInfo и экспортируете значения в свою систему метрик. Тот же срез печатается в лог и руками при инциденте: дежурный скрипт и экспортер используют один вызов GetQueueInfo.

> **Живое место: Processed — это не успехи.** Поле Processed в QueueInfo включает и успешные, и упавшие задачи — доля ошибок считается у вас: Failed делённый на Processed. Вторая ловушка: MemoryUsage помечен как приближение — значение вычисляется сэмплированием и годится для сравнения очередей между собой, но не для отчёта по байтам. И третья: Latency растёт раньше, чем падает Processed, — ставьте алерт именно на Latency, это самый ранний сигнал затыка. Наконец, счётчики за сутки сбрасываются в полночь: график Processed выглядит как пила, для гладких рядов есть накопительные ProcessedTotal и FailedTotal.

### Prometheus-паттерн: middleware в mux

Метрики хендлеров удобно собирать не в каждом хендлере, а одной обёрткой: у ServeMux из восьмого урока есть метод Use, принимающий MiddlewareFunc — функцию, которая получает Handler и возвращает Handler. Внутри оборачиваете ProcessTask: замеряете длительность через гистограмму с меткой по типу задачи и инкрементируете счётчик результата — успех или ошибка. Такой middleware добавляется одним вызовом mux.Use и работает для всех зарегистрированных типов: хендлеры остаются чистыми, а метрика появляется у новых задач автоматически. Значения в контекст кладутся тем же способом, что в [семнадцатом уроке](/courses/asynq/lessons/17-kontekst-v-zadachah/): middleware идёт первым и обогащает контекст до начала работы хендлера. Ошибку middleware обязан вернуть дальше — проглотить её значит соврать очереди, что попытка удалась: задача будет засчитана успешной и ретрая не получит. Порядок Use имеет значение: обёртки применяются в порядке добавления, и метрика, добавленная раньше логирующей, увидит тот же самый код хендлера. Типы задач — отличная метка для метрик: «упало три процента» малоинформативно, а «после деплоя упали все задачи типа email_invoice» — уже начало расследования. Кардинальность меток держите в узде: тип задачи — хорошая метка, ID задачи — нет, каждая уникальная метка рождает новый ряд в хранилище и убивает память.

### Middleware с метриками

```go
var taskDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "asynq_task_seconds",
        Buckets: prometheus.ExponentialBuckets(0.01, 2, 12),
    },
    []string{"type"},
)

func metrics(next asynq.Handler) asynq.Handler {
    return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
        start := time.Now()
        err := next.ProcessTask(ctx, t)
        taskDuration.WithLabelValues(t.Type()).Observe(time.Since(start).Seconds())
        taskResult.WithLabelValues(t.Type(), resultLabel(err)).Inc()
        return err
    })
}

mux.Use(metrics)
```

MiddlewareFunc принимает Handler и возвращает Handler: внутри строится замыкание, которое замеряет время, пишет гистограмму по типу задачи и счётчик результата, а ошибку возвращает как есть. mux.Use подключает обёртку ко всем задачам сразу; ProcessTask — то самое место, где работа реально происходит.

### Проверка: тип middleware

**Вопрос:** Как выглядит тип MiddlewareFunc, который принимает mux.Use?

- ✅ func(asynq.Handler) asynq.Handler
- func(http.Handler) http.Handler
- func(*asynq.Task) error
- func(asynq.HandlerFunc) asynq.Handler

Middleware в asynq оперирует собственным интерфейсом Handler с методом ProcessTask: функция получает исходный хендлер и возвращает обёрнутый. HTTP-типы тут ни при чём: очередь не знает про net/http, а обёртка живёт в мире asynq.Handler.

### Что запишет middleware?

```go
func loud(next asynq.Handler) asynq.Handler {
    return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
        log.Println("before", t.Type())
        err := next.ProcessTask(ctx, t)
        log.Println("after, err =", err)
        return err
    })
}
// loud подключён через mux.Use(loud)
// хендлер вернул ошибку io.EOF, MaxRetry равен 3
```

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

- ✅ В логе будет before и after с ошибкой, задача уйдёт в retry
- Middleware проглотит ошибку, и задача засчитается успешной
- В логе будет только before: after не выполнится
- Задача сразу уйдёт в архив без ретраев

Обёртка возвращает ошибку как есть: очередь видит неудачу и ставит задачу в retry по обычной политике, до исчерпания трёх попыток. Строка after выполнится всегда — она стоит до возврата, а не в ветке успеха, поэтому в логе окажутся обе строки.

### Что алертить: сигналы вместо графиков

Метрики без алертов — просто красивые графики, на которые смотрят после инцидента. Работающих сигналов немного. Первый — Latency из QueueInfo: возраст самой старой pending-задачи; пока воркеры успевают, он близок к нулю, и рост начинается раньше, чем очередь превратится в пробку. Второй — соотношение Retry и Archived к Processed: растущий Retry при плоском Processed означает, что зависимый сервис деградировал и попытки копятся; растущий Archived — что пошли фатальные ошибки, которые повтор не исправит. Третий — ошибки по типам задач: счётчик из middleware с меткой t.Type() покажет, что сломался конкретный тип, а не вся система. Четвёртый — Processed, упавший до нуля при непустом Size: воркеры не запущены, стоят на паузе или не видят Redis — классика первого инцидента. Для каждого сигнала нужны порог и окно, и пороги задаёт бизнес: письма и платежи терпят разное. И не алертите на всё сразу: три работающих сигнала полезнее десяти шумных. Дашборд asynqmon из [шестнадцатого урока](/courses/asynq/lessons/16-asynqmon-ui/) — отличный смотровой пункт, но алерт должен приходить сам, а не ждать, когда кто-то заглянет. Когда Latency растёт без видимых ошибок, проверяйте настройки конкурентности из [двадцатого урока](/courses/asynq/lessons/20-masshtabirovanie/). А под Latency держите runbook: шаги из таблицы ниже должны выполняться одной рукой, не заглядывая в документацию.

### Правила алертов очереди

- Алерт на Latency важнее алерта на длину очереди: длинная, но движущаяся очередь не инцидент.
- Долю ошибок считайте сами: Processed включает и неудачи, готовой доли библиотека не отдаёт.
- Метку типа задачи добавляйте в middleware-счётчик: без неё ошибки ищутся только вручную.
- Processed равен нулю при backlog — первая гипотеза: воркеры не запущены или очередь на паузе.
- Пороги задаёт бизнес: письма терпят минуты, платежи — секунды.
- Алерт без первого действия — шум: рядом с каждым правилом пишите, что делать.

### Сигнал, источник, первое действие

| Сигнал | Источник | Первое действие |
| --- | --- | --- |
| Latency превышает порог | GetQueueInfo → Latency | Servers(): живы ли воркеры, не на паузе ли очередь |
| Retry растёт, Processed плоский | QueueInfo → Retry | ListRetryTasks и LastErr: какой сервис деградировал |
| Archived растёт | QueueInfo → Archived | ListArchivedTasks: фатальные ошибки в коде хендлера |
| Рост failed по типу задачи | Middleware-счётчик | Лог попытки по ID задачи: последняя ошибка |
| Processed стоит при backlog | QueueInfo → Processed и Size | Стартовые логи сервера, LogLevel, конфиг очередей |

### Соберите лог-строку попытки

Допишите чтение контекста исполнения в хендлере.

```go
id, _ := asynq.___(ctx)
attempt, _ := asynq.___(ctx)
log.Printf("task=%s id=%s attempt=%d", t.Type(), id, attempt)
```

Подсказка: ID постановки и число предыдущих попыток достаются функциями пакета asynq из контекста.

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

### Соберите дежурный алерт-скрипт

Расставьте строки: коннект, срез очереди, проверка Latency, обход retry, итоговый лог.

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

1. `inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: "localhost:6379"})`
2. `defer inspector.Close()`
3. `info, err := inspector.GetQueueInfo("default")`
4. `if err != nil { log.Fatal(err) }`
5. `if info.Latency > time.Minute {`
6. `    retries, _ := inspector.ListRetryTasks("default", asynq.PageSize(10))`
7. `    log.Printf("latency=%v retry=%d processed=%d", info.Latency, len(retries), info.Processed)`
8. `}`

### Карточки: наблюдаемость

- **GetTaskID** — Читает ID постановки из контекста хендлера: связывает все попытки одной задачи в один поиск по логам.
- **GetRetryCount** — Число уже совершённых попыток: ноль — первый заход. Растущий счётчик при той же ошибке сигналит, что ретраи не помогут.
- **Config.LogLevel** — Минимальный уровень логов сервера: DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel. Не задан — работает DebugLevel.
- **QueueInfo.Processed** — Задачи за текущие сутки, включая упавшие. Рядом Failed и накопительные ProcessedTotal, FailedTotal. Долю ошибок считает ваш код.
- **Inspector.History** — DailyStats за последние n суток: Processed, Failed и дата — сырьё для графиков пропускной способности очереди.
- **mux.Use** — Подключение MiddlewareFunc: обёртка Handler над Handler пишет длительность и статус по типу задачи, ошибку возвращает как есть.

> **Про песочницу: счётчики без Redis.** Песочница сайта запускает тематическую программу на чистом Go без Redis: она эмулирует middleware с логом попыток и счётчиками результата — можно менять ошибки хендлера и смотреть, как растут счётчики попыток и меняется доля ошибок. Локально всё по классике: Go и Redis в Docker, и срез QueueInfo, и middleware-метрики исполняются как есть.

Наблюдаемость собрана: пять полей в логе попытки, LogLevel в конфиге сервера, счётчики QueueInfo, middleware для экспорта и четыре сигнала для алертов. Мини-эксперимент: запустите два воркера с разным Concurrency и следите за Latency и Processed — разница покажет, как нагрузка размазывается по конкурентности, а веб-морда очереди из двадцать первого урока поможет смотреть на то же глазами. Формат прежний: код исполняется локально с Go и Redis в Docker, песочница показывает тематическую программу на чистом Go без Redis. Следующий урок — грабли продакшена: там эти метрики превращаются в скорую помощь.

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

- Лог хендлера: тип задачи, ID из GetTaskID, попытка из GetRetryCount, длительность, ошибка.
- GetTaskID и GetRetryCount читают контекст: ID связывает попытки, счётчик отличает первый заход.
- Сервер asynq пишет старт и остановку сам; LogLevel в Config задаёт минимум, по умолчанию DebugLevel.
- QueueInfo: счётчики состояний, Processed и Failed за сутки, ProcessedTotal и FailedTotal, Latency.
- Processed включает и упавшие задачи: долю ошибок считает ваш скрипт.
- Счётчика retried нет: рост повторов виден по полю Retry, дубликаты — только на клиенте через ErrDuplicateTask.
- MiddlewareFunc через mux.Use даёт метрики всем хендлерам: длительность и статус по типу задачи.
- Latency — возраст самой старой pending-задачи и самый ранний алерт-сигнал очереди.
- Middleware-счётчик с меткой по типу задачи заменяет поиск по логам одним графиком.
- Периодическая задача из девятого урока выносит срез QueueInfo на дашборд раз в минуту.
- Алерты: Latency, Retry и Archived против Processed, ошибки по типам задач, Processed равен нулю при backlog.

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

Добавьте наблюдаемость в проект из прошлых уроков. В хендлер — пять полей попытки: тип, ID из GetTaskID, номер попытки, длительность и ошибка. В Config — InfoLevel, и проверьте, что отладочные стены исчезли из логов. Через Inspector выводите в лог срез очереди — Pending, Retry, Archived, Latency, Processed и Failed — и посчитайте долю ошибок за сутки через History. Оберните mux в middleware с гистограммой длительности по типу задачи и счётчиком результата. Напоследок устроите дрейл: остановите воркер и убедитесь, что Latency растёт, а алерт-условие срабатывает. Гистограмму проверьте на реальных длительностях: экспоненциальные buckets должны покрывать и быстрые, и зависшие попытки.

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

### Нужен ли отдельный экспортер метрик или всё есть в asynq?

Встроенного Prometheus-эндпоинта у asynq нет: библиотека отдаёт числа через Inspector, а экспорт — ваша работа. Самый короткий путь — периодическая задача или отдельный процесс, которые раз в минуту читают QueueInfo и History и обновляют gauge-метрики; пер-хендлерные метрики собираются middleware в mux. Кардинальность меток ограничьте типом задачи и очередью: ID задачи в метки не попадает никогда.

### Логировать тело payload в логах хендлера?

Только с осторожностью: в payload живут персональные данные — адреса, суммы, содержимое писем. В лог достаточно ID и типа: по ним payload достанется из очереди или из хранилища задач. Если очень нужно — логируйте проекцию из разрешённых полей и помните про политики хранения логов.

### Что выбрать для алертов: asynqmon или свои метрики?

Asynqmon отлично закрывает взгляд глазами: что происходит прямо сейчас. Активные алерты на порогах — свои метрики: Inspector даёт числа программно, а ваш алертер умеет писать в чат и звонить дежурному. Рабочая комбинация: свои метрики для сигналов, asynqmon для разбора инцидента.
