---
title: "Динамические уровни: AtomicLevel"
description: "zap AtomicLevel: меняем уровень логов на живом сервисе без рестарта — NewAtomicLevel, SetLevel, HTTP-endpoint /loglevel для админа и риски Debug в проде."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/13-dinamicheskie-urovni/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Динамические уровни: AtomicLevel

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

zap AtomicLevel решает проблему, знакомую каждому, кто ловил баг на проде ночью: уровень логов нужно поднять до Debug прямо сейчас, а рестарт процесса разорвёт соединения и потеряет состояние. Разберём привязку уровня к конфигу, смену на живом сервисе, защищённый HTTP-endpoint /loglevel и цену такой ручки в чужих руках.

Сбой в 23:47: платёжный сервис отвечает 500 на каждый пятый запрос, но в логах только Info — «request accepted», «request served». Подозрение падает на новую ветку валидации купонов, и чтобы её увидеть, нужен Debug. Классический путь — включить уровень в конфиге, пересобрать, выкатить: минус тёплые соединения к базе, минус состояние в памяти, плюс десять минут раскатки, пока инцидент тлеет. Сервисы на zap умеют короче: уровень логов может быть атомарной переменной, которую ядро читает на каждой записи, — и её можно менять изнутри живого процесса, хоть по HTTP. Подняли до Debug, воспроизвели, посмотрели, вернули Info — процесс ни разу не перезапускался, трафик не дрогнул. В этом уроке разберём тип zap.AtomicLevel, его место в конфиге, смену уровня на лету и риски, из-за которых в половине команд эту ручку запрещают. Пока процесс перезапускается, теряется и ещё одна вещь, о которой часто забывают: пул соединений прогревается заново, и первые секунды после подъёма сервис отвечает медленнее — для инцидента это ещё одна порция пятисотых.

### Zap AtomicLevel: обёртка над уровнем

zap.NewAtomicLevel() возвращает значение типа zap.AtomicLevel — тонкую обёртку над атомарным целым. Контракт у неё тот же, что у любого фильтра ядра: интерфейс zapcore.LevelEnabler с методом Enabled(zapcore.Level) bool — именно его ядро вызывает на каждой записи, решая, пропускать ли событие дальше. Внутри обёртки — указатель на atomic int32, поэтому все копии значения делят одну переменную: SetLevel с любой копии мгновенно виден всем логгерам, собранным на этом уровне. Это не случайность дизайна: поле cfg.Level в zap.Config имеет тип zap.AtomicLevel, а не числовой уровень, — конфиг задуман так, чтобы динамическая смена уровня логов была штатной опцией, а не хаком. По умолчанию NewAtomicLevel даёт Info; нужен другой старт — NewAtomicLevelAt(zap.DebugLevel). Пара zap AtomicLevel встречается в проекте дважды: в поле конфига при старте и в переменной, которую держат для ручки смены, — и оба раза это одно значение: логгер собирается на нём, а ссылка живёт рядом с конфигурацией.

### Логгер, привязанный к атомарному уровню

```go
level := zap.NewAtomicLevel() // Info по умолчанию

cfg := zap.NewProductionConfig()
cfg.Level = level

logger, err := cfg.Build()
if err != nil {
        panic(err)
}
defer logger.Sync()

// позже, из любого места процесса:
level.SetLevel(zapcore.DebugLevel)
logger.Debug("теперь видно и такие записи")
```

Build захватил ту же переменную, что лежит в level: ядро спрашивает Enabled у неё на каждой записи, поэтому SetLevel после Build действует на уже собранный логгер — пересоздавать ничего не нужно. В JSON Debug-записи пойдут в те же приёмники, куда писался Info: меняется фильтр, а не вывод. Понадобится импорт zapcore рядом с zap. Пример целиком запускается только на локальном Go — песочница курса zap не исполняет.

### Динамическая смена уровня логов: SetLevel

Динамическая смена уровня логов — это один вызов level.SetLevel(zapcore.WarnLevel). Аргумент — обычный zapcore.Level: от Debug(-1) до Fatal(5). Текущее значение читается методом Level(), строковое имя для админки — методом String(). Момент применения стоит понимать точно: решение «пропускать ли запись» принимается в момент вызова Info или Debug, а не заранее, поэтому никаких перезагрузок и прогрева не существует — следующая же запись оценивается по новому порогу. При этом сам факт смены никуда не пишется: логгер не знает, что его фильтр изменили. Приучите себя логировать смену вручную: кто, когда и с какого уровня на какой перешёл. Без такой записи ночной Debug, забытый включённым до утра, превращается в загадку «почему диск кончился в четыре часа». Уровень при этом — операционный параметр, а не константа кода: он меняет поведение наблюдения, не трогая данные, поэтому и обращаться с ним стоит как с изменением конфигурации — с логом, автором и правом откатить.

### Проверка: что меняет SetLevel

**Вопрос:** logger собран через cfg.Build() с атомарным уровнем. Что попадёт в вывод после level.SetLevel(zapcore.DebugLevel)?

- Только новые Debug-записи: Info и Warn исчезнут
- ✅ Все записи от Debug и выше: фильтр применяется к каждой записи в момент её создания
- Ничего не изменится: уровень фиксируется при Build
- Debug появится, но только после вызова Sync

AtomicLevel опрашивается через LevelEnabler на каждой записи: порог опустился — пошли записи от Debug и выше. Info и Warn не исчезают: Debug ниже них, порог означает «Debug и всё, что важнее».

### HTTP-endpoint /loglevel: POST для админа

Смена уровня по HTTP — самый практичный способ достать до ручки из админки или curl-а. Endpoint принимают методом POST: запрос меняет состояние сервиса, а GET по семантике обязан быть безопасным и его кешируют прокси. Тело — короткая строка уровня, которую разбирает zapcore.ParseLevel. Обязательные атрибуты: проверка прав (токен, mTLS, список адресов), ограничение размера тела и запись в лог самого факта смены с инициатором. Ниже — обработчик целиком; он маленький, но каждая его строчка отвечает за реальный инцидент, который где-то уже случался.

```go
func loglevelHandler(level zap.AtomicLevel, logger *zap.Logger) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                if r.Method != http.MethodPost || !isAdmin(r) {
                        http.Error(w, "forbidden", http.StatusForbidden)
                        return
                }

                body, err := io.ReadAll(io.LimitReader(r.Body, 32))
                if err != nil {
                        http.Error(w, "bad request", http.StatusBadRequest)
                        return
                }

                lvl, err := zapcore.ParseLevel(string(body))
                if err != nil {
                        http.Error(w, "unknown level", http.StatusBadRequest)
                        return
                }

                level.SetLevel(lvl)
                logger.Warn("log level changed",
                        zap.String("level", lvl.String()),
                        zap.String("remote", r.RemoteAddr),
                )
        }
}

// mux.Handle("/loglevel", loglevelHandler(level, logger))
```

ParseLevel принимает строки debug, info, warn, error, dpanic, panic, fatal — на неизвестную отвечает ошибкой, которую мы превращаем в 400. LimitReader режет тело: даже закрытый endpoint не обязан читать мегабайты. Смена пишется на Warn с инициатором — это изменение поведения сервиса, оно должно оставлять след. Функцию isAdmin подставьте свою: токен, mTLS, внутренняя сеть.

### Зачем это в проде: дебаг без рестарта

Сценарии, где ручка окупается сразу. Первый — локализация бага на живом трафике: гипотеза «падает валидация купонов» проверяется пятиминутным Debug-окном, а не полчаса выката. Второй — переходные периоды: миграция базы, смена балансера, свежий релиз — на час поднимается Debug, потом снимается. Третий, обратный: шумный модуль заваливает поток записями — на живом сервисе уровень поднимается до Warn без пересборки, пока команда готовит патч. Моё мнение, спорное для части команд: /loglevel должен быть в каждом сервисе по умолчанию — но закрытый авторизацией, с логированием смены и с таймером, который сам возвращает Info через десять минут, если оператор забыл. Без таймера ручка превращается в мину: Debug, включённый в марте, находят в декабре, когда кончается диск. Отдельный случай — многослойные сервисы: ручка меняет уровень только вашего процесса и на соседние не распространяется. Если трассировать нужно всю цепочку, endpoint ставят в каждый сервис либо выравнивают уровень через конфиг-сервис.

> **Ручка без замка — ручка для злоумышленника.** Неавторизованный /loglevel даёт два рычага. Поставить Fatal — и сервис перестаёт писать ошибки: инцидент становится невидимым для алертов, построенных на уровнях. Поставить Debug на гигабайтном трафике — и утопить диски, ротацию и систему сбора: lumberjack прореживание не спасёт, а сэмплирование продолжит ронять часть Debug. Минимум защиты: авторизация, POST вместо GET, логирование смены.

### Смена уровня и конфиг: что переживает рестарт

SetLevel меняет память процесса: рестарт вернёт уровень из YAML, переменных окружения или дефолта пресета. Это осознанный дизайн, и он удобнее, чем кажется: временный debug не прилипает к окружению навсегда, а постоянное желание «всегда видеть Debug у billing» — повод править конфиг, а не дёргать ручку. Если устойчивость нужна, уровень хранят снаружи: переменная окружения на старте или ключ в config-сервисе, который приложение читает при подъёме. Второй нюанс — несколько ядер: если логгеры собраны через tee, у каждого ядра свой LevelEnabler, и ваша переменная меняет только то ядро, которому её отдали. Держите один AtomicLevel как источник истины, если явно не хотите раздельных фильтров. Наследники тут без сюрпризов: потомки от With и Named делят ядро с родителем и собственных фильтров не имеют, поэтому одна смена уровня охватывает и их — модульных настроек по умолчанию не существует.

### Debug-окно и сэмплирование

Отдельного разговора стоит связь динамического уровня с сэмплированием из урока про NewSamplerWithOptions. Сэмплер сидит над ядром и прореживает повторяющиеся сообщения по счётчику уровень-плюс-текст; включение Debug ручкой не трогает его правила. Итог честный: после подъёма уровня вы увидите первые записи каждого вида, а хвост — с пропусками. Для короткого окна это нормально: цель окна — подтвердить гипотезу, а не собрать полную выкладку. Если нужна полнота, соберите на время диагностики второй логгер без сэмплера в отдельный файл — и уберите, когда закончите. Обратная связка тоже полезна: ручка уровня — безопасный способ снизить шум, когда сэмплер настроить некогда. Практическое правило: Debug-окно оценивают по первым записям каждого вида, а не по полноте потока. Если гипотезе нужен полный массив — это работа для воспроизведения на стенде, а не для прод-ручки.

### Чек-лист ручки /loglevel

- POST и авторизация: токен админа, mTLS или список адресов — анонимная смена уровня запрещена.
- Ограничение тела: io.LimitReader — принимаем короткую строку уровня, а не что попало.
- zapcore.ParseLevel с ответом 400 на неизвестную строку: без самодельных парсеров.
- Логирование смены на Warn: кто, когда, с какого уровня на какой.
- Таймер возврата к Info: забытый Debug закрывается сам.
- Понимание сэмплирования: Debug будет неполным, и это ожидаемо.

### Строки, которые понимает ParseLevel

| Строка | Уровень | Число |
| --- | --- | --- |
| "debug" | zapcore.DebugLevel | -1 |
| "info" | zapcore.InfoLevel | 0 |
| "warn" | zapcore.WarnLevel | 1 |
| "error" | zapcore.ErrorLevel | 2 |
| "dpanic" | zapcore.DPanicLevel | 3 |
| "panic" | zapcore.PanicLevel | 4 |
| "fatal" | zapcore.FatalLevel | 5 |

### Что выведет логгер?

```go
level := zap.NewAtomicLevelAt(zap.WarnLevel)

cfg := zap.NewProductionConfig()
cfg.Level = level

logger, _ := cfg.Build()

level.SetLevel(zapcore.InfoLevel)
logger.Info("deploy finished", zap.String("version", "1.7.2"))
```

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

- Запись не попадёт в вывод: логгер собран с уровнем Warn
- ✅ Запись попадёт в вывод: порог уже опущен до Info на момент вызова
- Паника: уровень нельзя менять после Build
- Запись попадёт в вывод с полем level: warn

Build зафиксировал не число, а ссылку на атомарную переменную. К моменту вызова Info порог уже Info — запись проходит. NewAtomicLevelAt задал только стартовый Warn, SetLevel его сменил.

### Проверка: почему POST

**Вопрос:** Почему для endpoint смены уровня обычно выбирают POST, а не GET?

- POST шифрует тело, GET — нет
- ✅ Запрос меняет состояние сервера: для таких операций семантически предназначен POST, а GET могут кешировать или предзагружать
- GET не умеет передавать тело запроса
- Разницы нет: POST выбирают по привычке

Метод — контракт: GET обязан быть безопасным и идемпотентным, смена уровня — нет. Тело передают оба, шифрует TLS, а ревизеры ссылок и кеши ходят именно по GET. Не хочется, чтобы уровень менял предзагрузчик.

### Соберите привязку уровня

Восстановите три строки: конструктор, привязка к конфигу, смена на живом сервисе.

```go
level := zap.___()

cfg := zap.NewProductionConfig()
cfg.Level = ___

// включить подробности без рестарта:
level.___(zapcore.DebugLevel)
```

Подсказка: Конструктор без аргументов даёт Info, в поле конфига кладут переменную, менять порог — SetLevel.

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

### Соберите поток: от уровня до Debug-записи

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

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

1. `level := zap.NewAtomicLevel()`
2. `cfg := zap.NewProductionConfig()`
3. `cfg.Level = level`
4. `logger, err := cfg.Build()`
5. `level.SetLevel(zapcore.DebugLevel)`
6. `logger.Debug("теперь видно")`

### Карточки: динамические уровни

- **zap.AtomicLevel** — Обёртка над атомарным уровнем: реализует LevelEnabler, копии делят одну переменную — SetLevel виден всем логгерам на ней.
- **zap.NewAtomicLevel()** — Конструктор с уровнем Info по умолчанию. Нестандартный старт — NewAtomicLevelAt(zap.DebugLevel).
- **level.SetLevel(lvl)** — Смена порога на живом процессе: применяется к каждой следующей записи. Сам факт смены не логируется — пишите вручную.
- **zapcore.ParseLevel** — Строка в zapcore.Level: debug, info, warn, error, dpanic, panic, fatal; ошибка на неизвестной строке.
- **LevelEnabler** — Интерфейс zapcore с методом Enabled: ядро спрашивает его на каждой записи, решая судьбу события.
- **Рестарт и уровень** — SetLevel живёт в памяти: после рестарта уровень вернётся из YAML или ENV. Устойчивость — через конфиг.

> **Пресеты тоже атомарны.** NewDevelopmentConfig и NewProductionConfig кладут в поле Level настоящие AtomicLevel: захватите cfg.Level до Build в переменную и меняйте потом. При чтении конфига из YAML тоже всё честно — у AtomicLevel есть UnmarshalText, строка info разложится в уровень сама.

### Что дальше по курсу

Ручка уровня живёт рядом с логирующим middleware: в уроке про [логирование HTTP-запросов](/courses/zap/lessons/15-http-middleware/) /loglevel встраивается в тот же роутер, что и админские маршруты. Цену лишнего Debug в цифрах — аллокации и пропускную способность — меряют и обсуждают в уроке про [производительность zap](/courses/zap/lessons/19-proizvoditelnost/), а про связь уровней с алертами и метриками честно сказано в материале про [логи и наблюдаемость](/courses/zap/lessons/23-observability/). Карта остальных тем — на [странице курса](/courses/zap/). Ручка уровня — последний кирпич операционной зрелости логов: дальше идут перехватчики gRPC и эксплуатация, но с ней ночная смена уже проходит без рестартов.

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

- cfg.Level — это zap.AtomicLevel: атомарная обёртка, которую ядро опрашивает на каждой записи.
- SetLevel действует мгновенно на уже собранный логгер: пересборка и рестарт не нужны.
- /loglevel — POST-эндпоинт с авторизацией, ParseLevel, лимитом тела и логированием смены.
- Смена уровня не переживает рестарт: временный Debug не прилипает, устойчивость — через конфиг.
- Сэмплирование продолжает прореживать Debug: полноты ручка не даёт.
- Открытая ручка — риск: Fatal прячет ошибки, Debug топит диски и систему сбора.
- Сам факт смены пишите в лог: кто, когда, с какого уровня на какой.

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

Соберите локально сервис с атомарным уровнем: NewAtomicLevel, привязка к cfg.Level, обработчик POST /loglevel с проверкой токена и логированием смены на Warn. Поднимите уровень до Debug curl-запросом, убедитесь по JSON-выводу, что Debug-записи пошли, верните Info и проверьте, что они пропали. Добавьте таймер, который сам возвращает Info через десять минут после последней смены.

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

### Можно ли менять уровень у логгера из пресета NewProduction без своего конфига?

Да: поле Level в пресетах — настоящий AtomicLevel. Возьмите cfg := zap.NewProductionConfig(), сохраните level := cfg.Level, соберите логгер через cfg.Build() — и вызывайте level.SetLevel в любой момент.

### Какой уровень ставить по умолчанию?

Info для прода, Debug для дев-окружения — выбор живёт в конфиге окружения. Ручка /loglevel — временное средство: окно Debug закрывается возвратом к Info вручную или таймером, а постоянная потребность в подробностях решается правкой конфига.

### Почему после SetLevel(Debug) записи всё равно частично пропадают?

Если у ядра стоит сэмплирование, оно продолжает прореживать повторяющиеся сообщения и на Debug. Настройки сэмплера задаются при сборке логгера: для полной выкладки соберите второй логгер без сэмплера в отдельный файл.
