---
title: "Сэмплирование: не тонем в логах"
description: "zap sampling: сэмплирование логов — NewSamplerWithOptions, Initial и Thereafter, пример 100/100, риски пропустить уникальную ошибку. С кодом и метриками дропов."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/11-sampling/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Сэмплирование: не тонем в логах

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

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

Пятница, 19:40. У третьего сервиса падает сеть до базы, и код с ретраями честно повторяет попытку каждые полсекунды, логируя каждый провал: error «dial tcp 10.0.0.5:5432: connect: connection refused». За минуту — сто двадцать тысяч записей, по содержанию одинаковых. Что произошло дальше, знакомо многим: сборщик логов захлебнулся, диск подобрался к лимиту, а алерт по ошибкам сработал сто двадцать раз подряд, и дежурный его замьютил. Ни одной новой информации после первой записи этот поток не принёс — но съел CPU на сериализации, I/O на записи и нервы на алертах. Правильный ответ — не писать все сто двадцать тысяч: первые записи пройти целиком, дальше прореживать. Это и есть сэмплирование: механизм, встроенный в zap, — и, что важно, уже включённый в production-пресете, о чём многие не догадываются.

### zap sampling: как устроен сэмплер

Сэмплирование логов в zapcore реализовано как обёртка-ядро: NewSamplerWithOptions(core, tick, first, thereafter) принимает ядро и возвращает ядро — поэтому сэмплер встанет и на ручную сборку из прошлого урока, и под пресет. Механика zap sampling — арифметика на счётчиках. Параметры: tick — длительность окна, обычно time.Second; first (Initial) — сколько записей проходят целиком в начале окна; thereafter (Thereafter) — какая по счёту из дальнейших пропускается: значение 100 означает «каждая сотая». Счётчик ведётся по паре уровень плюс сообщение: сто ошибок с текстом «connection refused» — это один счётчик, а «connection refused» и «timeout» — два разных. Ключевой момент: окно прокручивается каждые tick — в новую секунду счётчики обнуляются и снова идут первые N записей. Устаревшая функция NewSampler до сих пор в пакете, но помечена Deprecated: пишите NewSamplerWithOptions, она же даёт опции вроде хука решений, о нём ниже.

### Сэмплер на ручной сборке

```go
base := zapcore.NewCore(
        zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
        zapcore.AddSync(os.Stdout),
        zapcore.InfoLevel,
)

sampled := zapcore.NewSamplerWithOptions(base, time.Second, 100, 100)

logger := zap.New(sampled)
defer logger.Sync()

for i := 0; i < 500; i++ {
        logger.Error("connection refused", zap.Int("attempt", i))
}
```

Базовое ядро то же, что в уроке про zapcore; NewSamplerWithOptions оборачивает его. За одну секунду пятьсот одинаковых ошибок превратятся в сто четыре записи: первые сто целиком, затем каждая сотая. Поля у записей сохраняются — экономится запись, а не формат. Пример рассчитан на локальный Go: в песочнице zap не исполнить.

### Пример 100/100: что уже включено в проде

Пара сто и сто — не случайность: ровно столько живёт в production-пресете. В zap.Config за это отвечает поле Sampling: указатель на zap.SamplingConfig с полями Initial и Thereafter; пресет NewProductionConfig выставляет Initial: 100, Thereafter: 100 при окне в одну секунду. Считается это так: в течение секунды первые сто записей с одинаковым уровнем и сообщением уходят как есть; далее zap держит счётчик и пропускает каждую сотую; в новую секунду всё заново. Практический вывод: если ваш сервис штормит, то есть пишет тысячи одинаковых записей в секунду, вы и так под сэмплером — просто не знали об этом. И обратный вывод: при спокойном трафике сэмплирование не включается вовсе, пока счётчик не дойдёт до сотни за окно; обычные сервисы с десятками записей в секунду живут без прореживания. Отключить сэмплер в конфиге просто — присвойте Sampling nil, но подумайте дважды: ваш сборщик логов скажет спасибо в следующий шторм.

### Параметры NewSamplerWithOptions

| Параметр | Смысл | Типовое значение |
| --- | --- | --- |
| core | оборачиваемое ядро — энкодер, приёмник, уровень | результат NewCore |
| tick | длительность окна, после которой счётчики обнуляются | time.Second |
| first (Initial) | сколько записей проходят целиком в начале окна | 100 |
| thereafter (Thereafter) | каждая M-я из дальнейших пропускается; 0 — тишина после first | 100 |
| opts | опции: SamplerHook для метрик решений | — |

### Зачем: шумные ретраи и циклы

Сэмплирование нужно там, где одна и та же запись воспроизводится в цикле, и таких мест в реальных сервисах несколько. Ретраи исходящих вызовов — хрестоматийный случай из начала урока: каждая попытка логирует провал, и при деградации зависимости частота провалов равна частоте попыток. Фоновые поллеры: очередь пуста, крон тикает раз в секунду, Info «nothing to process» капает круглосуточно. Валидаторы и парсеры в цикле по данным: один кривой файл на миллион строк превращается в миллион записей. Во всех этих случаях после сотни одинаковых записей новой информации нет — есть только стоимость: CPU на кодирование, диск и сеть на доставку, квоты в системе сбора. Сэмплирование режет именно это, сохраняя сигнал: начало шторма видно с первой записи, конец — по последней, а середина представлена десятой долей процента. Заметьте честную оговорку документации: реализация оптимизирована под скорость, а не под точность, — под нагрузкой окно может пропустить чуть больше или меньше расчётного. Для защиты от шторма это неважно, для бухгалтерии по записям — учитывайте.

### Проверка: арифметика сэмплера

**Вопрос:** Сэмплер NewSamplerWithOptions(core, time.Second, 100, 100). За одну секунду пришло 500 записей с одинаковым уровнем и сообщением. Сколько из них будет записано?

- 500 — сэмплер не включается до 1000 записей
- 100 — всё после сотой отбрасывается
- ✅ 104 — первые сто плюс каждая сотая из оставшихся
- 5 — каждое сотое из всех

Первые 100 проходят целиком (first), далее из оставшихся 400 пропускается каждая сотая — ещё 4. Итого 104. Счётчик сбрасывается в новом окне, поэтому в следующую секунду снова пойдут первые сто.

### Риски: пропустили уникальную ошибку

Теперь неприятная часть, о которой молчат туториалы. Счётчик сэмплера — по уровню и тексту сообщения, и поля в этот ключ не входят. Ошибка «payment failed» с полем order_id равным «a-77» и ошибка «payment failed» с order_id «b-42» — один и тот же счётчик: детали живут в полях, а считается текст. Сценарий провала: за секунду проходит тысяча падений платежей с сотней разных order_id в полях, первые сто записываются, а ваш конкретный заказ c-99 выпадает из логов в числе прореженных — и расследование упирается в дыру. Лечение на уровне конвенций: в сообщении несут то, что отличает запись — «payment failed for order c-99» — и тогда уникальные события получают свой счётчик; либо дублируют критичные события в метрики, где потерь нет. Второй риск — искажение объёма: в логе сто ошибок, кажется, что шторм небольшой, а реально было сорок тысяч; сравните счётчик алертов с числом записей. Третий — арифметика: thereafter 100 при Initial 10 означает, что между сотыми пропускается девяносто из ста, — настраивайте оба числа осознанно.

> **Где сэмплировать НЕЛЬЗЯ.** Аудит-логи входов, изменений прав, финансовых операций — любое событие, которое обязано существовать по регламенту, сэмплеру не подлежит: пропущенная запись здесь не шум, а нарушение. Также без сэмплирования оставляют записи, по которым ведётся биллинг и юридически значимые цепочки. Для этих потоков — отдельный логгер с отдельным ядром без сэмплера.

### Читаем прореженный поток

После включения сэмплирования в логах появляется новая реальность, и к ней надо привыкнуть глазами заранее. В спокойный час поток выглядит как обычно: сэмплер не трогает записи, пока счётчик не доходит до порога. В шторм — куски: первые сто записей подряд, потом разреженные одиночные, потом снова тишина — это не сбои приёмника, а работа окна. Расследование по такому потоку ведут от краёв: начало шторма и его конец записаны целиком, и если нужна хронология — она есть в краях; середину восстанавливают по счётчикам метрик и по времени между записями. Что важно не потерять: уникальные события с собственным текстом сообщения сэмплер не трогает — их счётчик только начался. Поэтому конвенция из раздела о рисках — информативные сообщения — работает и здесь: пока текст отличает запись от шума, она защищена. Всё, что должно остаться полным — аудит, биллинг, регуляторика, — уходит в отдельный логгер без сэмплера, и тогда прореживание не навредит ни одной строке, которой полагено существовать.

### SamplerHook: метрики дропов

Одна из опций NewSamplerWithOptions закрывает вопрос «а сколько мы резали»: zapcore.SamplerHook принимает функцию, которую сэмплер вызывает на каждое своё решение. Аргументы — запись и битовая маска решения: LogDropped означает, что запись отброшена, LogSampled — что пропущена как пробная. Внутри функции удобно инкрементировать атомарный счётчик и отдавать его в метрики. Это лечит второй риск из предыдущего раздела: разница между «сэмплер пропустил 104» и «шторм был на 40 тысяч» становится видимой в дашборде, а не догадкой по логам. Заведите две метрики — дропнутые и прошедшие — по уровням, и сэмплирование перестанет быть чёрным ящиком: видно, когда оно срабатывает, на каком уровне и насколько агрессивно. Для того же предназначено поле Hook в zap.SamplingConfig — хук из конфига получает те же решения, если вы живёте на пресете, а не на ручной сборке.

### Счётчик отброшенных записей

```go
var dropped atomic.Int64

sampled := zapcore.NewSamplerWithOptions(
        base,
        time.Second,
        100, 100,
        zapcore.SamplerHook(func(ent zapcore.Entry, dec zapcore.SamplingDecision) {
                if dec&zapcore.LogDropped > 0 {
                        dropped.Add(1)
                }
        }),
)

logger := zap.New(sampled)
defer logger.Sync()
```

Хук вызывается на каждое решение сэмплера; битовая маска проверяется оператором & — бит LogDropped означает отброшенную запись, LogSampled — пропущенную пробную. Счётчик atomic.Int64 безопасно растёт из горутин, а его значение уходит в экспортер метрик раз в несколько секунд. Документация zap прямо предлагает хук как способ наблюдать за производительностью сэмплера — у дежурного появляется честный ответ на вопрос, сколько записей зарезали сегодня.

### Thereafter: 0 — что останется?

```go
sampled := zapcore.NewSamplerWithOptions(base, time.Second, 50, 0)

// за одну секунду — 200 записей с одинаковым уровнем и сообщением
```

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

- 200 — thereafter ноль ничего не режет
- ✅ 50 — первые пятьдесят целиком, дальше тишина
- 1 — только первая запись
- 0 — окно не открылось

Thereafter: 0 означает, что после первых first записей в окне ничего не проходит: 50 записей пишутся, остальные 150 отбрасываются. Жёсткий режим для совсем шумных каналов — например, для поллеров, у которых Info повторяется без изменений.

### Проверка: где сэмплеру нет места

**Вопрос:** Какой поток логов НЕЛЬЗЯ отдавать под сэмплирование?

- Отладочный Info фонового поллера
- ✅ Аудит изменений прав доступа пользователей
- Warn о медленных HTTP-запросах под нагрузкой
- Error из цикла ретраев внешнего API

Аудит-события — юридический и регуляторный контракт: каждая запись обязана существовать, пропуск — нарушение, а не оптимизация. Остальные варианты — классические кандидаты на прореживание: шум без потери уникальной информации.

### Сэмплирование против других рулей

Сэмплер — не единственная ручка объёма, и полезно видеть иерархию. Уровень — грубая регулировка: подняли порог до Warn — Info исчез целиком; дешево, но грубо, и динамика уровня — тема отдельного урока про AtomicLevel. Сэмплирование — точечная: уровень сохраняется, прореживается только повторяющееся; это способ жить с Debug-подробностью в Info-штормах. Ротация из урока о lumberjack вообще не про объём: она делит поток на файлы, суммарный объём не уменьшая. Ретраи и очередь в самом коде — правильное место, чтобы не логировать каждую попытку: логируйте первую, последнюю и каждую десятую попытку руками, тогда сэмплеру останется только непредвиденное. Правильная конфигурация прод-сервиса обычно такова: прод-уровень Info, сэмплирование пресетное 100/100, отдельное ядро для ошибок, метрики дропов через хук. Отклоняйтесь от этого по факту нагрузки, а не на всякий случай: лишние ручки — лишние сюрпризы.

### Соберите сэмплированный конфиг

Восстановите обёртку сэмплера и поле конфига.

```go
sampled := zapcore.___WithOptions(base, time.Second, 100, ___)

cfg := zap.NewProductionConfig()
cfg.___ = &zap.SamplingConfig{Initial: 100, Thereafter: 100}
```

Подсказка: Обёртка — NewSamplerWithOptions; thereafter из примера 100/100; поле zap.Config называется Sampling.

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

### Соберите сэмплированный логгер

Расставьте строки: от базового ядра до первой записи.

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

1. `cfg := zap.NewProductionEncoderConfig()`
2. `base := zapcore.NewCore(zapcore.NewJSONEncoder(cfg), zapcore.AddSync(os.Stdout), zapcore.InfoLevel)`
3. `sampled := zapcore.NewSamplerWithOptions(base, time.Second, 100, 100)`
4. `logger := zap.New(sampled)`
5. `defer logger.Sync()`
6. `logger.Error("connection refused")`

### Карточки: сэмплирование

- **Сэмплирование** — Прореживание повторяющихся записей: первые N за окно целиком, дальше каждая M-я. Обёртка-ядро в zapcore, в прод-пресете включена по умолчанию.
- **NewSamplerWithOptions** — zapcore.NewSamplerWithOptions(core, tick, first, thereafter, opts...) — оборачивает ядро в сэмплер. NewSampler устарел: пишите с опциями.
- **Initial (first)** — Сколько записей с одинаковым уровнем и сообщением проходят целиком в начале окна; в проде — 100.
- **Thereafter** — Каждая M-я из дальнейших пропускается; 0 — полная тишина после first. Счётчик сбрасывается новым окном.
- **Ключ счётчика** — Уровень + текст сообщения; ПОЛЯ В КЛЮЧ НЕ ВХОДЯТ: уникальные детали в полях выпадают из прореженного потока.
- **SamplerHook** — Опция сэмплера: функция получает Entry и решение (LogDropped / LogSampled). Считайте дропы в метрики — иначе объём шторма невидим.

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

Почему прореживание нужно именно zap — то есть откуда берётся его скорость при тысячах записей в секунду, — разбирает урок про [производительность](/courses/zap/lessons/19-proizvoditelnost/): пулы, ноль аллокаций и CheckedEntry. Вторая ручка объёма — уровень, меняющийся без перезапуска, — живёт в уроке про [AtomicLevel](/courses/zap/lessons/13-dinamicheskie-urovni/). Карта курса — на [странице курса](/courses/zap/).

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

- Сэмплер — обёртка-ядро: NewSamplerWithOptions(core, tick, first, thereafter); NewSampler устарел.
- Прод-пресет уже сэмплирует: Initial 100, Thereafter 100, окно в секунду; отключение — Sampling: nil.
- Счётчик по уровню и тексту сообщения, поля не входят: уникальные детали прореживаются вместе с шумом.
- Кандидаты на сэмплирование — ретраи, поллеры, циклы; где НЕЛЬЗЯ — аудит, биллинг, регуляторные события.
- Thereafter: 0 — тишина после первых N; начальные записи всегда проходят, начало шторма видно.
- SamplerHook считает дропы: без метрик решения сэмплера — чёрный ящик.
- Сэмплирование точечно против грубого уровня и бесполезно против ротации: у каждой ручки своя работа.

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

Соберите локально логгер с сэмплером NewSamplerWithOptions(base, time.Second, 100, 100) и прогоните цикл из 5000 одинаковых Error за секунду — посчитайте по выводу, сколько прошло. Затем поменяйте thereafter на 0 и повторите. Добавьте SamplerHook со счётчиком дропов и проверьте, что он совпал с вашими подсчётами. Напишите три потока вашего сервиса: какой из них вы оставите без сэмплирования и почему.

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

### Сэмплирование искажает отладку?

Да, и об этом надо помнить: прореженный поток показывает форму шторма, а не каждую запись. Для расследования конкретного инцидента поднимайте уровень подробности динамически через AtomicLevel, а не отменяйте сэмплирование: одно окно подробных логов дешевле, чем тонущий диск.

### Чем Initial отличается от Thereafter?

Initial — сколько записей проходят целиком в начале каждого окна; Thereafter — шаг прореживания после этого: каждая M-я. Пара 100/100 означает сто записей без купюр и далее каждую сотую; Thereafter: 0 — ничего после первых Initial.

### Сэмплирует ли сэмплер по разным сообщениям отдельно?

Да: счётчики ведутся по паре уровень и сообщение. Десять разных текстов ошибок — десять независимых счётчиков, каждый получит свои Initial записей. Именно поэтому подробные сообщения вместо общих «failed» сохраняют уникальные события.
