Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~20 мин +50 XP

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

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

Содержание урока · 10 разделов

Наблюдаемость очереди держится на трёх слоях. Первый — логи хендлера: что происходило с конкретной задачей в конкретной попытке. Второй — логи самого сервера 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 не задано?

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

Логи отвечают на вопрос «что случилось с этой задачей», метрики — «как поживает очередь». Источник чисел — Inspector из пятнадцатого урока: 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.

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

Метрики хендлеров удобно собирать не в каждом хендлере, а одной обёрткой: у ServeMux из восьмого урока есть метод Use, принимающий MiddlewareFunc — функцию, которая получает Handler и возвращает Handler. Внутри оборачиваете ProcessTask: замеряете длительность через гистограмму с меткой по типу задачи и инкрементируете счётчик результата — успех или ошибка. Такой middleware добавляется одним вызовом mux.Use и работает для всех зарегистрированных типов: хендлеры остаются чистыми, а метрика появляется у новых задач автоматически. Значения в контекст кладутся тем же способом, что в семнадцатом уроке: 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?

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

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

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

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

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

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

GetTaskID

нажмите, чтобы перевернуть
Определение

Читает ID постановки из контекста хендлера: связывает все попытки одной задачи в один поиск по логам.

1 / 6

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

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Лог хендлера: тип задачи, 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 · Логи и метрики задач)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Kafka25 уроков~12 чпрактика25 уроков уже доступны

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once