---
title: "Итоговый проект: наблюдаемость сервиса"
description: "Zap итоговый проект: HTTP-сервис observable-tasks с логированием микросервисов go — конфиг из окружения, request id, GORM, sampling, тесты observer."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

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

*Курс «Zap: структурное логирование на Go», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zap/lessons/25-itogovyj-proekt/*

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

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?

- Так принято в примерах net/http
- ✅ Он создаёт request_id и логгер в контексте, которыми пользуется accessLog
- Он быстрее и не тормозит остальные посредники
- Иначе паника не дойдёт до recovery

Порядок выполнения — это порядок оборачивания наизнанку: requestID выполняется первым, кладёт потомка с request_id в контекст, accessLog достаёт его оттуда. Переставьте — и access-лог пойдёт без идентификатора.

### Репозиторий: 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 из конфига.

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

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

atom.SetLevel(zap.WarnLevel)

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

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

- Одна запись: Info
- ✅ Две записи: Warn и Error
- Три записи: уровень на собранный логгер не действует
- Ни одной записи

SetLevel сменил порог до вызовов: Info отфильтрован на Check, Warn и Error проходят. Рестарта не было — в этом смысл AtomicLevel из урока 13: buildLogger запомнил ту же переменную, а не копию числа.

### Сэмплинг, ротация и ручка уровня в 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 проекта?

- Отрезает первые 100 записей и молчит дальше
- ✅ Пропускает первые 100 однотипных записей за тик, дальше по одной за тик
- Сэмплирует каждый сотый запрос целиком
- Держит буфер 100 записей и пишет пачками

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

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

| Урок | Деталь в observable-tasks | Где живёт |
| --- | --- | --- |
| 07 zap.Config | конфиг из окружения, Build, AtomicLevel в поле Level | loadConfig |
| 09 ротация | lumberjack: MaxSize, MaxBackups, MaxAge | приёмник в main |
| 11 сэмплинг | NewSamplerWithOptions 100/100 | ядро: шумные ручки |
| 13 AtomicLevel | /internal/loglevel и SetLevel | atom в main |
| 14 request id | request_id, LoggerFromContext | посредник, репозиторий |
| 15 middleware | статус → уровень, recovery | accessLog, recovery |
| 16 GORM | SlowThreshold, 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](/courses/zap/lessons/20-testirovanie-logov/). Прогон — 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?

- Ничего: паника рвёт цепочку посредников
- Fatal и смерть процесса
- ✅ Error со статусом 500 и полем request_id
- Info «request served» со статусом 200

Recovery внутри accessLog гасит панику, возвращает 500 и продолжает обработку; accessLog после записи статуса даёт Error по правилу статусов. Стек паники уходит отдельной записью — это тема урока про ошибки.

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

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

```go
atom := zap.NewAtomicLevel()
cfg.Level = atom
logger, err := cfg.___(zap.AddCaller())

handler := ___(logger)(
    ___(logger)(
        recovery(logger)(mux),
    ),
)
```

Подсказка: Метод конфига и порядок цепочки: создающий контекст — снаружи.

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

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

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

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

1. `cfg, atom, err := loadConfig()`
2. `sink := zapcore.AddSync(&lumberjack.Logger{Filename: cfg.OutputPaths[0]})`
3. `core := zapcore.NewSamplerWithOptions(zapcore.NewCore(enc, sink, atom), time.Second, 100, 100)`
4. `logger := zap.New(core, zap.AddCaller())`
5. `handler := requestID(logger)(accessLog(logger)(recovery(logger)(mux)))`
6. `http.ListenAndServe(":8080", handler)`

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

- **loadConfig** — Читает окружение: LOG_LEVEL, LOG_ENCODING, LOG_PATH; возвращает парой zap.Config и AtomicLevel.
- **request id** — 16 байт crypto/rand в hex: поле request_id в каждой записи, потомок логгера в контексте.
- **AtomicLevel** — Ссылочный уровень: SetLevel меняет порог живого логгера; держит ручку /internal/loglevel.
- **SlowThreshold** — Порог GORM-моста: превышение — Warn slow sql с полями elapsed и threshold.
- **observer** — zaptest/observer: ядро-накопитель для тестов; FilterMessageSnippet и FilterField — ассерты контракта.
- **100/100** — Параметры сэмплера: первые 100 однотипных записей за тик, дальше по одной за тик.

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

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

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

- Итоговый проект 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, и пройдите чеклист прод-запуска по пунктам.

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

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

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

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

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

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

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