---
title: "Первый лог в zerolog: New и JSON-событие"
description: "Первый лог в zerolog: go get, zerolog.New(os.Stdout), With().Timestamp(), ключи level, time и message, глобальный log, stdout против stderr и почему нет Sync."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/02-pervyj-log/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Первый лог в zerolog: New и JSON-событие

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 2 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/02-pervyj-log/*

Первый лог в zerolog собирается из трёх вызовов: zerolog.New(os.Stdout), цепочка .With().Timestamp() и Info().Msg() — и сразу печатает однострочный JSON. В этом уроке напишем первую программу, разберём каждый ключ записи, сравним глобальный логгер с собственным и поймём, почему у zerolog нет метода Sync, который вы привыкли искать после zap.

### Установка: go get github.com/rs/zerolog

Начните с пустого модуля: mkdir demo, cd demo, go mod init demo. Дальше — go get github.com/rs/zerolog/log: команда подтянет последнюю версию ветки v1 и пропишет её в go.mod. В коде вы встретите два пакета. Ядро — github.com/rs/zerolog: типы Logger, Event, уровни, ConsoleWriter, конструктор zerolog.New. Над ним — github.com/rs/zerolog/log: пакет с готовым глобальным логгером для коротких программ и утилит. Один модуль, два входа; импортируете тот, что нужен. Оговорка о формате курса: библиотечный код здесь не исполняется — песочница сайта показывает тематическую программу на чистом Go, а примеры урока вы запускаете локально, где zerolog честно печатает свои строки. Разобраны они построчно, так что вывод вы предскажете до запуска. Пакет годами держит одну мажорную версию: код из туториалов 2018 года собирается сегодняшним компилятором без правок, а go.mod после установки остаётся почти пустым — цепочек транзитивных зависимостей, как у веб-фреймворков, здесь нет. Если проект уже живёт на другом логгере, ставьте zerolog рядом и переводите на него модуль за модулем: конфликтов имён у двух логгеров не бывает.

### Первая программа: zerolog.New(os.Stdout)

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        logger := zerolog.New(os.Stdout).With().Timestamp().Logger()

        logger.Info().Msg("service started")
        logger.Error().Str("reason", "port busy").Msg("shutdown")
}

// Output:
// {"level":"info","time":"2024-04-06T12:00:00+03:00","message":"service started"}
// {"level":"error","time":"2024-04-06T12:00:00+03:00","reason":"port busy","message":"shutdown"}
```

zerolog.New принимает io.Writer — сюда уедет каждая запись; здесь это os.Stdout. Метод New возвращает логгер без времени, поэтому .With().Timestamp().Logger() — каноническая достройка: контекстное поле time добавляется к каждому событию, а .Logger() собирает итоговый тип. Info() и Error() начинают события разных уровней, Msg дописывает message и печатает строку. Поле reason стоит перед message — сообщение всегда приходит последним. Уберите .With().Timestamp() — и ключ time пропадёт из обеих строк: predict ниже закрепит это наблюдение.

### Читаем JSON: level, time, message

Каждая запись zerolog — одна строка JSON, и ключи в ней стоит назвать по именам. level — уровень строчными: info, error, debug, warn. time — время в формате RFC3339 вроде 2024-04-06T12:00:00+03:00: zerolog по умолчанию пишет человекочитаемую строку, а не секунды от эпохи, как zap с его ts; если предпочитаете unix-time, выставьте zerolog.TimeFieldFormat = zerolog.TimeFormatUnix. message — сообщение; обратите внимание на полное слово: в zap ключ называется msg, и пальцы первое время путаются. Ваши поля идут между контекстом и сообщением, в том порядке, в котором вы их дописали. Все эти имена — не приговор, а константы пакета: TimestampFieldName, MessageFieldName и прочие можно переопределить и подстроить схему под систему сбора — [этому посвящён урок про структуру JSON](/courses/zerolog/lessons/19-log-formaty-json/). Запись при этом всегда ровно одна строка: zerolog не переносит JSON и разделяет события переводом строки — формат NDJSON. Из этого следуют приятные вещи: tail -f показывает по записи на строку, поток безопасно резать по переводу строки, а системы сбора читают такие ленты стримом, не буферизуя весь файл. Внутри записи переносов не бывает: переводы строк внутри значений zerolog экранирует, поэтому правило «одна строка — одно событие» держится без исключений. Формат ключа time управляется глобальной переменной zerolog.TimeFieldFormat. По умолчанию она равна time.RFC3339, и запись выглядит как time: «2024-04-06T12:00:00+03:00» — человекочитаемо, но строка на два десятка символов в каждой записи. Если система сбора предпочитает unix-time, выставьте zerolog.TimeFieldFormat = zerolog.TimeFormatUnix при старте: время станет числом секунд от эпохи, коротким и дешёвым в парсинге; рядом есть TimeFormatUnixMs с миллисекундами. Выбор — не вкусовщина: Loki и ClickHouse переваривают обе формы, но числовое значение индексируется дешевле, а лента в целом становится короче. Меняйте константу один раз при инициализации, до первых записей: переменная читается на каждом событии, и перезапись её на живом процессе из другой горутины — та гонка, которую не стоит устраивать даже ради эксперимента. Если оставляете RFC3339, помните про зону: zerolog пишет локальную зону процесса, и в контейнере с UTC она не совпадёт с вашей машиной — первое, что стоит проверить, сравнивая логи двух сред.

### Stdout или stderr: куда уедет запись

Куда именно уедет запись — решает writer, переданный в New, и это второй важный выбор после уровня. os.Stdout — самый частый вариант для сервисов: в контейнере stdout собирает рантайм Docker, дальше логи едут в Loki или ELK. Глобальный логгер из пакета zerolog/log настроен иначе: он пишет в os.Stderr и добавляет время из коробки — вы могли заметить это по выводу первого урока. Разделение потоков — конвенция Unix: данные программы идут в stdout, диагностика — в stderr, и CLI-инструменты этим живут, что хорошо видно в [курсе по Cobra](/courses/cobra/), где вывод команды и логи разделены с первого дня. Для веб-сервиса выбор stdout безобиден: сборщикам всё равно, для CLI — stdout держите чистым под данные. Проверить разделение можно без инструментов: запустите программу с 2>/dev/null — и увидите, что именно ушло в stdout, отдельно от диагностики. Docker и journald собирают оба потока, но помечают по-разному, а часть сборщиков теряет порядок между ними: если порядок записей в доли секунды важен, пишите весь лог одним потоком.

### Глобальный логгер: log.Info().Msg()

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
        "github.com/rs/zerolog/log"
)

func main() {
        log.Info().Str("user", "ann").Msg("signed in")

        log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger()
        log.Info().Str("user", "ann").Msg("signed in again")
}

// Первый вызов уходит в stderr, второй — в stdout.
```

Пакет zerolog/log хранит переменную log.Logger, созданную как zerolog.New(os.Stderr).With().Timestamp().Logger() — именно поэтому время в записях есть сразу, без With-достроек. До первого присваивания записи идут в stderr; перезаписали log.Logger — все новые вызовы уехали в stdout. Для коротких программ и экспериментов глобальный логгер быстрее на старте, для сервиса со структурой зависимостей — см. tip ниже. После присваивания новые вызовы log уехали в stdout — существующий код подхватил новую поверхность вывода без единой правки.

Перезапись log.Logger из примера выше безопасна в одном месте — при старте, до первых записей. Глобальная переменная не защищена мьютексом: если одна горутина присвоит log.Logger новое значение, пока другая печатает, компилятор промолчит, а go run -race покажет гонку. Поэтому развилка такая: утилита на один файл — живите с глобальным log, он настроен из коробки и пишет в stderr с временем; сервис с горутинами — соберите логгер в main и передайте зависимостью, тогда гонка не станет возможной в принципе. В тестах глобальный логгер удобно подменять: сохранить old := log.Logger, подставить логгер поверх буфера, вернуть old в конце — но и это лучше делать один раз в TestMain, а не в каждом тесте, где про возврат забудут.

### Почему в zerolog нет Sync

Если вы пришли из zap, рука тянется дописать defer logger.Sync() — в zerolog компилятор её остановит: такого метода нет, и это не упущение, а следствие архитектуры. Zap собирает запись во внутренние буферы и сбрасывает их по расписанию — без Sync хвост логов теряется. Zerolog при каждом Msg дописывает message в буфер события и тут же вызывает Write у вашего writer: строка уходит наружу сразу, буферизовать нечего, терять нечего. Памятью вы расплачиваетесь один раз — при записи в файл системный вызов происходит на каждый Msg, и если это станет узким местом, оберните writer в буфер сами (bufio.Writer, Flush в конце — тема урока про файлы) или возьмите асинхронный diode.Writer из поставки zerolog. Правило простое: синхронность — дефолт, буферизация — ваше осознанное решение, а не спрятанное в библиотеке. Заметьте симметрию: у zap буферизация спрятана внутри, и Sync обязателен; у zerolog всё синхронно, и буфер — ваша забота. Первая модель удобнее на старте, вторая честнее: увидев в профиле подозрительный Write, вы точно знаете, кто замедлился, и чем это лечить, а про файлы и Flush есть отдельный [урок про запись в файл](/courses/zerolog/lessons/08-log-fail/).

> **Цепочка без финализатора — события нет.** Msg, Msgf и Send — единственные вызовы, которые пишут запись. Событие, дошедшее до .Str("user", "ann") и остановившееся, растворится молча: ни строки, ни ошибки, ни паники. Что сломалось бы на проде: половина записей просто пропала бы, и искали бы это как «проблему writer-а». Ставьте Msg сразу, как начали цепочку.

> **Логгер рождается в main.** Глобальный log удобен в утилитах, но в сервисе лучше создать логгер один раз в main и передать вниз через зависимости. Получите один источник настроек и возможность подставить в тесты логгер поверх буфера — этот приём разбирается в уроке про тестирование логов.

### Ключи записи: zerolog против zap

| Что в записи | zerolog | zap (production) |
| --- | --- | --- |
| Уровень | level: "info" | level: "info" |
| Время | time: RFC3339-строка | ts: секунды от эпохи |
| Сообщение | message (полное слово) | msg (короткое) |
| Ваши поля | после контекста, в порядке вызовов | после msg |
| Ошибка | error | error |
| Место вызова | caller (если включён) | caller (включён по умолчанию) |

### Чек-лист первой программы

- writer: os.Stdout — для сервиса под сборщиком логов, os.Stderr — для диагностики и утилит
- время: With().Timestamp() почти всегда; формат — zerolog.TimeFieldFormat при старте, если система сбора хочет unix
- глобальный log — для утилит и экспериментов; собственный zerolog.New — для сервиса с зависимостями
- финализатор: Msg по умолчанию, Send — события без сообщения, Msgf — с осторожностью, данные лучше в поля
- Sync в API нет: вместо него вы сами решаете, буферизовать writer или писать синхронно
- уровень по умолчанию Debug: перед деплоем поднимите порог SetGlobalLevel — тема следующего урока

Три способа завершить событие — Msg, Msgf и Send — закрывают разные задачи. Msg принимает готовую строку: Msg("order created") — случай по умолчанию, факт без данных. Msgf тянет за собой fmt.Sprintf: Msgf("retry %d for order %s", attempt, id) — удобно для быстрых записей, но данные внутри сообщения недоступны фильтрам, так что в боевом коде данные лучше уводить в поля, а Msgf оставить редким гостем. Send пишет событие без сообщения вовсе: log.Info().Int("count", batch).Send() — запись из одних полей, полезная для событий-метрик, которые читает не человек, а система. Выбор поля под задачу — тема следующего-следующего урока, а пока запомните: Msg — по умолчанию, Send — для безмолвных событий, Msgf — с осторожностью. Ориентир для ревью: Msgf с двумя плейсхолдерами и больше — почти всегда сигнал, что данные просятся в поля. Одиночная деталь в сообщении терпима, но если завтра по этому месту понадобится график, Msgf придётся переписывать, а поля — просто добавить.

### Проверка: глобальный логгер

**Вопрос:** Куда пишет глобальный логгер из пакета github.com/rs/zerolog/log?

- В os.Stdout
- ✅ В os.Stderr, с полем time из коробки
- В файл zerolog.log в текущей папке
- Никуда: его надо настроить перед первым вызовом

log.Logger создан как zerolog.New(os.Stderr).With().Timestamp().Logger(): поток ошибок, время на месте. Это делает его рабочим сразу после импорта — и одновременно источником сюрпризов для тех, кто ждал stdout.

### Проверка: ключ сообщения

**Вопрос:** Какой ключ в JSON zerolog содержит сообщение?

- msg
- text
- ✅ message
- Зависит от уровня записи

Zerolog пишет message, zap — msg. Имена задаются константами пакета вроде MessageFieldName, поэтому схему можно поменять — но по умолчанию это message, и систему сбора настраивают под него.

### Проверка: With().Timestamp()

**Вопрос:** Что добавляет достройка zerolog.New(os.Stdout).With().Timestamp().Logger()?

- Формат времени для ConsoleWriter
- ✅ Ключ time к каждому событию этого логгера
- Асинхронную запись через буфер
- Ничего: New уже добавляет время

zerolog.New создаёт логгер без времени: {"level":"info","message":"..."}. Timestamp() в контексте добавляет поле time каждому событию — с форматом RFC3339 по умолчанию. Проверьте предсказанием ниже.

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

```go
logger := zerolog.New(os.Stdout)

logger.Info().Msg("hi")
```

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

- {"level":"info","time":"2024-04-06T12:00:00+03:00","message":"hi"}
- ✅ {"level":"info","message":"hi"}
- {"message":"hi"}
- Ничего: без времени zerolog не пишет запись

New не добавляет время автоматически — глобальный логгер из zerolog/log снабжён им, а собственный логгер из New нет. Поле time появляется только через .With().Timestamp() или явный вызов. Запись при этом всё равно уходит: время — необязательное поле.

### Соберите конструктор логгера

Восстановите каноническую конструкцию: writer, контекстное время, событие с полем и финализатор.

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        logger := zerolog.___(os.Stdout).With().___().Logger()

        logger.___().Str("service", "billing").___("service started")
}
```

Подсказка: Writer оборачивает zerolog.New; время в контекст добавляет Timestamp; штатное событие начинает Info; завершает цепочку Msg.

Правильные ответы в пропусках: `New`, `Timestamp`, `Info`, `Msg`

### Первая программа целиком

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

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

1. `package main`
2. `import (`
3. `  "os"`
4. `  "github.com/rs/zerolog"`
5. `)`
6. `func main() {`
7. `  logger := zerolog.New(os.Stdout).With().Timestamp().Logger()`
8. `  logger.Info().Str("user", "ann").Msg("signed in")`
9. `}`

### Карточки: New и первое событие

- **zerolog.New(w io.Writer)** — Конструктор логгера поверх любого io.Writer. Записи уходят в writer при каждом Msg — синхронно, без внутренней буферизации.
- **With().Timestamp().Logger()** — Каноническая достройка: контекстное поле time к каждому событию. Без него в JSON не будет ключа time.
- **log.Logger (zerolog/log)** — Глобальный логгер: zerolog.New(os.Stderr).With().Timestamp().Logger(). Готов к работе сразу после импорта.
- **message против msg** — Zerolog пишет ключ message, zap — msg. Частая опечатка при миграции между логгерами и настройке систем сбора.
- **Send()** — Финализатор без сообщения: log.Info().Int("n", 1).Send() — запись из одних полей, ключ message не пишется.
- **Нет Sync — почему?** — Zerolog не буферизует сам: каждый Msg делает Write в writer. Буферизацию добавляете вы — bufio или diode.Writer, тогда Flush на вас.

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

Следующий урок — [уровни логов](/courses/zerolog/lessons/03-urovni-logov/): восемь констант от Trace до Disabled, SetGlobalLevel и то, почему отключённый Debug почти бесплатен. Потом — поля события: Str, Int, Err и долг жизни Any. Если хочется сразу увидеть записи глазами, а не JSON — загляните в урок про ConsoleWriter, он переделывает вывод этого урока в цветные колонки. Карта всех двадцати пяти тем — на странице курса zerolog.

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

- go get github.com/rs/zerolog/log; ядро — пакет zerolog, глобальный логгер — пакет zerolog/log.
- Канонический конструктор: zerolog.New(os.Stdout).With().Timestamp().Logger().
- Запись: {"level":"info","time":"...","message":"..."} — message пишется последним, после ваших полей.
- Глобальный логгер пишет в os.Stderr и даёт time из коробки; собственный из New — только с With().Timestamp().
- Sync в zerolog нет: каждый Msg синхронно пишет в writer; буферизацию и Flush добавляете сами.
- Цепочка завершается Msg, Msgf или Send — без финализатора запись не уедет.
- Время задаётся zerolog.TimeFieldFormat: RFC3339-строка по умолчанию, TimeFormatUnix — число секунд для систем сбора.

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

Соберите программу урока и запустите её дважды: с глобальным логгером из zerolog/log и с собственным zerolog.New(os.Stdout). Сравните потоки: у какого варианта вывод ушёл в stderr — проверьте перенаправлением 2>/dev/null. Потом удалите With().Timestamp() и убедитесь, что ключ time пропал из JSON. Наконец, напишите событие с Send() вместо Msg и посмотрите, как изменилась запись. Потом выставьте zerolog.TimeFieldFormat = zerolog.TimeFormatUnix и сравните два вида ключа time.

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

### Как сделать первый лог в zerolog?

Установите пакет (go get github.com/rs/zerolog/log), создайте логгер zerolog.New(os.Stdout).With().Timestamp().Logger() и вызовите logger.Info().Msg("...") — в stdout уйдёт JSON-строка с level, time и message. Ещё быстрее: импортируйте zerolog/log и вызовите log.Info().Msg() — глобальный логгер настроен за вас. Формат записи — NDJSON: одна строка на событие, разделитель — перевод строки.

### Нужен ли в zerolog logger.Sync()?

Нет, такого метода нет и он не нужен: zerolog пишет запись в writer синхронно при каждом Msg, ничего не буферизуя. Буферизацию вы добавляете сами (bufio.Writer, diode.Writer) — тогда и сброс делаете сами. Если задержки Write станут видны в профиле — вот сигнал оборачивать writer в буфер.

### Почему в JSON zerolog нет поля time?

Собственный логгер из zerolog.New не добавляет время автоматически. Добавьте .With().Timestamp().Logger() — или пользуйтесь глобальным логгером из zerolog/log, у которого время настроено с самого начала. Для unix-time выставьте TimeFieldFormat = TimeFormatUnix — ключ time станет числом.
