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

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

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

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

Итоговый проект: наблюдаемость сервиса

Zap итоговый проект курса собирается сейчас целиком: observable-tasks — HTTP-сервис списка задач, в котором каждая деталь из двадцати трёх уроков стоит на своём месте. Конфиг из окружения, request id, логи по статусу, GORM с SlowThreshold, ошибки с контекстом, сэмплинг, AtomicLevel-эндпоинт, ротация lumberjack и тесты через observer — связываем всё это в один main.go.

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

Zap итоговый проект курса — сервис observable-tasks: HTTP-API списка задач с хранением в SQLite через GORM, один процесс, одна виртуалка и полный стек логирования за плечами. Ручки три: POST /tasks создаёт задачу, GET /tasks отдаёт список, POST /tasks/{id}/done закрывает. Требования к логам формулируем до кода — так делают опытные команды, потому что логи без требований превращаются в подслушанный разговор. Каждая запись несёт request_id, и цепочка посредник-хендлер-репозиторий восстанавливается одним поиском. Статусы переводятся в уровни: пятисотые — Error, четырёхсотые — Warn, остальное — Info. Медленный SQL пишется Warn с порогом из конфига. Ошибки уходят в Error с контекстом: идентификатор задачи, длительность, исходная ошибка. Шумная ручка /healthcheck прореживается сэмплером. Уровень меняется на живом сервисе без рестарта. Файл ротируется и не съедает диск. Записи проверяются тестами. Логирование микросервисов go-стека — это и есть такой набор договорённостей, а не один вызов zap.New: каждая строчка ниже отвечает за одну из них. Каждое требование уже имело свой урок; здесь они впервые встречаются в одном процессе, и порядок сборки важнее любого приёма по отдельности — перепутанное звено ломает не запись, а всю хронологию.

Zap итоговый проект: конфиг из окружения#

Всё начинается с конфига, потому что от него зависит каждая следующая деталь. Пресеты NewDevelopment и NewProduction годились для первых уроков, но observable-tasks живёт в трёх средах — ноутбук, staging, прод — и различается уровнем, форматом и приёмником. Решение из урока про zap.Config: базовый пресет плюс переопределение из переменных окружения. LOG_LEVEL задаёт стартовый порог, LOG_ENCODING выбирает json или console, LOG_PATH указывает файл для lumberjack или stderr по умолчанию, DB_SLOW_THRESHOLD настраивает порог медленного SQL, APP_ENV подписывает записи средой. Ключевая мелочь, на которой всё держится: loadConfig возвращает пару zap.Config и zap.AtomicLevel — тот самый ссылочный уровень из урока про динамические уровни. Логгер соберётся на нём, а переменная уйдёт в ручку смены уровня, и порог на живом процессе станет штатной операцией, а не хаком. Один и тот же приём env() читает и DB_SLOW_THRESHOLD при сборке моста GORM — конфигурация сервиса имеет один вход. Среды различаются только значениями: на ноутбуке LOG_LEVEL=debug и stderr, в staging — info и файл, в проде — info и файл под агентом; код при этом один и тот же.

Фрагмент 1: loadConfig

GO
func env(key, def string) string {
    if v, ok := os.LookupEnv(key); ok && v != "" {
        return v
    }
    return def
}

func loadConfig() (zap.Config, zap.AtomicLevel, error) {
    atom := zap.NewAtomicLevel()
    var lvl zapcore.Level
    if err := lvl.UnmarshalText([]byte(env("LOG_LEVEL", "info"))); err != nil {
        return zap.Config{}, atom, err
    }
    atom.SetLevel(lvl)

    cfg := zap.NewProductionConfig()
    cfg.Encoding = env("LOG_ENCODING", "json")
    cfg.OutputPaths = []string{env("LOG_PATH", "stderr")}
    cfg.Level = atom
    return cfg, atom, nil
}

loadConfig берёт NewProductionConfig за основу — JSON, caller, stacktrace — и переопределяет три поля из окружения. AtomicLevel создаётся руками: UnmarshalText принимает строки debug, info, warn, error; после SetLevel конфиг связывается с переменной через cfg.Level = atom. Build позже запомнит именно эту переменную, поэтому ручка смены уровня будет действовать на живой логгер. LOG_PATH по умолчанию — stderr: принцип 12-factor, агент на хосте заберёт поток сам, как в уроке про эксплуатацию.

Цепочка посредников: request id и логи по статусу#

Правило сборки цепочки одно: посредник, создающий контекст, всегда снаружи посредника, который его потребляет. request id из урока 14 генерирует идентификатор, кладёт поле request_id в логгер и прячет потомка в контекст — значит, он первый. Access-лог из урока 15 достаёт логгер из контекста, замеряет время и пишет запись по статусу: 5xx — Error, 4xx — Warn, остальное — Info, и правило живёт в одном switch, а не по всему проекту. Recovery идёт внутри access-лога: паника гасится до него, ответ получает 500, и запись access-лога честно покажет статус аварии рядом с request_id. Шумные ручки глушатся не посредником, а сэмплером ядра: /healthcheck ходит каждую секунду с одним и тем же сообщением, и NewSamplerWithOptions прореживает его до пары записей в тик — это урок 11, применённый по назначению. Цепочка короткая, но порядок в ней решает всё: переставьте два звена, и access-лог пойдёт без идентификатора. В поля access-лога уходят method, path, status, latency и client — минимум, по которому ищут и строят графики латентности из прошлых уроков.

Фрагмент 2: сборка цепочки

GO
func buildHandler(logger *zap.Logger, atom zap.AtomicLevel) http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /tasks", handleCreateTask(logger))
    mux.HandleFunc("GET /tasks", handleListTasks(logger))
    mux.HandleFunc("GET /healthcheck", handleHealth)

    mux.HandleFunc("POST /internal/loglevel", loglevelHandler(atom, logger))

    handler := requestID(logger)(
        accessLog(logger)(
            recovery(logger)(mux),
        ),
    )
    return handler
}

Оборачивание идёт от роутера наружу: recovery ближе всех к хендлерам, accessLog в середине, requestID снаружи — порядок выполнения именно такой. /internal/loglevel — ручка из урока про AtomicLevel: принимает level, зовёт SetLevel. В проде её закрывают внутренней сетью или токеном: Debug в чужих руках сжигает диск и выносит персональные данные — это была отдельная тема того урока.

Проверка: порядок цепочки

Почему requestID стоит в цепочке снаружи accessLog?

Репозиторий: GORM с SlowThreshold и zap.Error с контекстом#

Слой данных получает двух говорящих. Первый — мост GORM из урока 16: LogLevel Warn, SlowThreshold из конфига, IgnoreRecordNotFoundError true, чтобы ErrRecordNotFound не превращался в Error-алерт. Мост пишет slow sql с полями elapsed и threshold — это прямой материал для алерта на долю медленных запросов из прошлого урока. Второй говорящий — сам репозиторий: логгер достаётся из контекста через LoggerFromContext, значит, каждая запись несёт request_id без единого лишнего аргумента. При ошибке — Error с контекстом из урока 18: With добавляет task_id и took, рядом zap.Error с исходной ошибкой, и по записи можно ответить на вопрос «что упало и в каком запросе», не поднимая код. Ноль затронутых строк при апдейте — не ошибка, но подозрительное поведение: Warn с task_id, канал — чат, не пейджер. Разница между «не получилось» и «такой задачи нет» стоит двух уровней, и в разборе инцидента именно она экономит вечер. IgnoreRecordNotFoundError заслуживает отдельного слова: NotFound в API — штатный ответ, и превращать его в Error — значит кормить алерты обычным трафиком.

Фрагмент 3: репозиторий с ошибкой в контексте

GO
func (r *TaskRepo) MarkDone(ctx context.Context, publicID string) error {
    log := LoggerFromContext(ctx)
    start := time.Now()

    res := r.db.WithContext(ctx).Model(&Task{}).
        Where("public_id = ?", publicID).
        Update("done_at", time.Now())
    took := time.Since(start)

    if res.Error != nil {
        log.With(
            zap.String("task_id", publicID),
            zap.Duration("took", took),
        ).Error("task update failed", zap.Error(res.Error))
        return res.Error
    }
    if res.RowsAffected == 0 {
        log.Warn("task not found", zap.String("task_id", publicID))
    }
    return nil
}

Логгер из контекста приносит request_id, With наращивает task_id и took — контекст ошибки живёт рядом с zap.Error. Warn на RowsAffected == 0 различает «не получилось» и «такой задачи нет»: уровни работают и на границе данных. Мост GORM параллельно запишет slow sql, если Update перевалит за SlowThreshold из конфига.

Что запишет логгер после смены уровня?

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

atom := zap.NewAtomicLevel() // Info
logger := buildLogger(atom)

atom.SetLevel(zap.WarnLevel)

logger.Info("request served")
logger.Warn("slow sql")
logger.Error("task update failed")

Сэмплинг, ротация и ручка уровня в main#

main собирает всё в одном месте, и каждая строка там — ссылка на урок. Приёмник: stderr по умолчанию, а при заданном LOG_PATH — lumberjack с рабочими параметрами: MaxSize 100 МБ до ротации, пять бэкапов, хранение тридцать дней; механика из урока 09 съедает диск по расписанию, а не по факту падения. Пятый параметр, Compress, жмёт архивы gzip-ом: тот же MaxAge вмещается в заметно меньший объём. Ядро: JSON-энкодер, приёмник, атомарный уровень — и сверху NewSamplerWithOptions со схемой 100/100: первые сто однотипных записей за тик проходят, дальше по одной за тик; /healthcheck и циклы ретраев перестают топить поток, а уникальные сообщения проходят всегда. Ручка /internal/loglevel получает атом и логгера: каждая смена уровня логируется — кто и с какого уровня на какой перешёл, — порог возвращается после расследования. Ни один из этих кусков не нов — новым является только их порядок, и порядок этот продиктован зависимостями: конфиг до логгера, логгер до цепочки, цепочка до ListenAndServe.

Проверка: сэмплер на ядре

Что делает NewSamplerWithOptions(core, time.Second, 100, 100) в main проекта?

Какой урок дал какую деталь#

УрокДеталь в observable-tasksГде живёт
07 zap.Configконфиг из окружения, Build, AtomicLevel в поле LevelloadConfig
09 ротацияlumberjack: MaxSize, MaxBackups, MaxAgeприёмник в main
11 сэмплингNewSamplerWithOptions 100/100ядро: шумные ручки
13 AtomicLevel/internal/loglevel и SetLevelatom в main
14 request idrequest_id, LoggerFromContextпосредник, репозиторий
15 middlewareстатус → уровень, recoveryaccessLog, recovery
16 GORMSlowThreshold, slow sql в Warnмост GORM
18 ошибкиzap.Error с полями контекстарепозиторий
20 тестыobserver.New, FilterFieldтесты контракта

Фрагмент 4: main целиком

GO
func main() {
    cfg, atom, err := loadConfig()
    if err != nil {
        panic(err)
    }

    sink := zapcore.AddSync(os.Stderr)
    if cfg.OutputPaths[0] != "stderr" {
        sink = zapcore.AddSync(&lumberjack.Logger{
            Filename:   cfg.OutputPaths[0],
            MaxSize:    100, // МБ до ротации
            MaxBackups: 5,
            MaxAge:     30, // дней
        })
    }

    core := zapcore.NewSamplerWithOptions(
        zapcore.NewCore(
            zapcore.NewJSONEncoder(cfg.EncoderConfig),
            sink, atom,
        ),
        time.Second, 100, 100,
    )
    logger := zap.New(core, zap.AddCaller())
    defer logger.Sync()

    handler := buildHandler(logger, atom)
    logger.Info("service started", zap.String("env", env("APP_ENV", "dev")))

    if err := http.ListenAndServe(":8080", handler); err != nil {
        logger.Fatal("listen failed", zap.Error(err))
    }
}

Точка сборки: конфиг, приёмник с веткой lumberjack, сэмплированное ядро, логгер, цепочка, ListenAndServe. Fatal на ошибке слушателя — последний уровень курса: процесс умирает, и до тёплой системы сбора запись может не доехать, поэтому алерт ставят на отсутствие сигнала живости в хранилище. Запуск и проверка — только на локальном Go: go run ./cmd/tasks, curl по ручкам, наблюдение файла tasks.log.

Тесты через observer и чеклист запуска#

Требования из первого блока проверяются тестами, а не верой. Из урока про zaptest берётся observer.New(zapcore.InfoLevel): ядро подставляется в сборку логгера вместо боевого, код под тест гоняет настоящий zap.Logger, а тест читает записи как объекты. Три теста закрывают контракт: запрос с паникой даёт Error со статусом 500 и полем request_id; несуществующий путь даёт Warn; /healthcheck за один тик оставляет не больше сотни записей — контракт сэмплера. FilterMessageSnippet и FilterField делают проверки читаемыми: сначала по сообщению, потом по полю, Len после фильтра — ассерт количества. Тест живёт рядом с хендлерами: buildLogger принимает ядро параметром, и подмена боевого ядра на наблюдателя — одна строка в подготовке теста, а не хак. Полный разбор приёмов — в уроке Тестирование логов: zaptest и observer. Прогон — go test на локальном Go, в CI ставится так же без изменений. Ниже — чеклист, с которым observable-tasks выходит в прод; по нему же проверяется любой сервис, который вы соберёте после курса.

Чеклист прод-запуска observable-tasks#

  • LOG_LEVEL=info и LOG_ENCODING=json в окружении: порог и формат — из конфига, не из кода.
  • Приёмник — stderr или LOG_PATH: агент на хосте забирает поток.
  • request_id в каждой записи: поиск по нему восстанавливает цепочку запроса.
  • Статусы → уровни в одном switch: 5xx Error, 4xx Warn, остальное Info.
  • DB_SLOW_THRESHOLD выставлен, алерт на долю slow sql настроен.
  • Сэмплер 100/100 включён, дропы видны на графике.
  • /internal/loglevel закрыт внутренней сетью и логирует каждую смену уровня.
  • lumberjack: MaxSize, MaxBackups, MaxAge — диск не кончится в четыре утра.
  • Тесты observer зелёные в CI: контракт записей не поломан.

Проверка: паника под цепочкой

Обработчик запаниковал под нашей цепочкой. Что запишет accessLog?

Соберите логгер и цепочку

Восстановите сборку из main и buildHandler.

atom := zap.NewAtomicLevel() cfg.Level = atom logger, err := cfg.(zap.AddCaller()) handler := (logger)( (logger)( recovery(logger)(mux), ), )

Соберите запуск сервиса

Расставьте строки main по порядку выполнения.

Строки

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

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

Карточки: финальный стек

Термин

loadConfig

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

Читает окружение: LOG_LEVEL, LOG_ENCODING, LOG_PATH; возвращает парой zap.Config и AtomicLevel.

1 / 6

Поздравление и куда дальше#

Если observable-tasks отвечает на запросы, пишет по договорённости и не падает под curl — курс пройден, и это не фигура речи: от первого NewDevelopment до этой цепочки в main вы собрали полный прикладной стек. Честное поздравление: до финального проекта доходят не все, у вас получилось. Сам проект остаётся у вас: он ровно настолько живой, насколько вы его запускаете и дорабатываете. Дальше два естественных продолжения. Админ-CLI — команды tasks-admin loglevel и tasks-admin rotate из терминала — делает курс Cobra: там cmd/ и internal/, сигналы и версии через goreleaser. TUI-дашборд по хвосту логов — курс Bubble Tea: список, фильтры и статус-бар на тех же записях. Карта всех уроков — на странице курса zap — останется под рукой, когда понадобится освежить конкретную деталь.

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

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

Вывод

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

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

  • Итоговый проект observable-tasks собирает курс в один main.go: конфиг, цепочка, репозиторий, тесты.
  • Конфиг из окружения возвращает парой zap.Config и AtomicLevel — от этой пары зависит всё остальное.
  • Цепочка: requestID снаружи, accessLog по статусу в середине, recovery внутри.
  • GORM-мост с SlowThreshold и zap.Error с контекстом закрывают слой данных.
  • Сэмплер 100/100 глушит шумные ручки, /internal/loglevel меняет порог без рестарта.
  • lumberjack ротирует файл: MaxSize, MaxBackups, MaxAge.
  • Тесты observer проверяют контракт записей: уровень, сообщение, поля.

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

Соберите observable-tasks по фрагментам урока и добавьте одну свою деталь: поле version в InitialFields, тест на WarnNotFound или ручку с включённым сэмплингом. Проверьте тестом observer, что запись о 500 несёт request_id, и пройдите чеклист прод-запуска по пунктам.

Личные заметкиПусто
Хранятся только в вашем браузере (Zap · Итоговый проект: наблюдаемость сервиса)

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

Можно ли взять observable-tasks за основу продакшен-сервиса?

Как каркас — да: конфиг, цепочка посредников и контракт уровней переносятся один в один. Добавьте миграции, health-зонд с проверкой базы и метрики — логи отвечают на «что», метрики на «сколько».

Почему сэмплинг включён на всё ядро, а не только на /healthcheck?

Так делает production-пресет zap: схема 100/100 щадит поток, а уникальные сообщения проходят всегда. Боитесь потерять повторяющиеся ошибки — соберите отдельное ядро без сэмплинга для Error через NewTee.

Куда расти после курса zap?

Cobra для админ-CLI: команды смены уровня и ротации из терминала. Bubble Tea для TUI-дашборда по хвосту лога. Дальше — метрики и трейсы: урок 23 дал каркас наблюдаемости, логи в нём — первый сигнал.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси