---
title: "Логирование и recovery"
description: "Управляем gin логированием: подмена DefaultWriter и DefaultErrorWriter, LoggerWithFormatter, ошибки в c.Errors, свой slog-адаптер и CustomRecovery."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/08-logirovanie-recovery/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Логирование и recovery

*Курс «Gin: самый популярный веб-фреймворк Go», урок 8 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/08-logirovanie-recovery/*

Строку [GIN] и спасатель Recovery мы разобрали в уроке про встроенные middleware — теперь хозяйственные вопросы: gin логирование позволяет направить вывод в файл, буфер теста или структурный логгер, подменив пакетные DefaultWriter и DefaultErrorWriter. Разберём LoggerWithFormatter и тип LogFormatter, копилку ошибок c.Errors, из которой в строку лога попадает ErrorMessage, и CustomRecovery — свой обработчик паники, который отдаёт клиенту честный JSON.

### gin логирование: кто куда пишет и как это перенастроить

В [уроке про встроенные middleware](/courses/gin/lessons/06-middleware/) мы разобрали анатомию строки [GIN] и поведение Recovery при панике; в [прошлом уроке](/courses/gin/lessons/07-kastomnye-middleware/) — фабрику-замыкание, по которой эти звенья написаны. Тема этого урока — управление gin логированием: три письменных стола, на которые фреймворк кладёт свои записи, и все три можно перенести. Первый — gin.DefaultWriter: туда пишут и строка лога Logger, и отладочные [GIN-debug] строки движка. Второй — gin.DefaultErrorWriter: туда уходит трассировка паники от Recovery. Третий — stderr самого net/http, куда без Recovery падает сообщение http: panic serving — его мы трогать не будем, просто помним, что он существует. Вокруг этих столов соберём четыре сюжета: подмена вывода и её главный подводный камень — момент захвата writer-а; свои форматтеры через LoggerWithFormatter; копилка ошибок c.Errors и поле ErrorMessage, которое связывает хендлер с логом; и, наконец, CustomRecovery — паника, которая превращается не в пустой 500, а в JSON-конверт для клиента и структурированную запись для вашего логгера.

### Два writer-а на весь пакет: DefaultWriter и DefaultErrorWriter

Откройте mode.go в исходниках gin — там всего две строчки, которые объясняют всё gin логирование целиком. var DefaultWriter io.Writer = os.Stdout — так объявлен первый стол; док-комментарий в исходнике честно перечисляет, кто им пользуется: «debug output and middleware output like Logger() or Recovery()», то есть и отладка движка, и строки логов. var DefaultErrorWriter io.Writer = os.Stderr — второй стол, «для отладочных ошибок»: именно туда Recovery пишет [Recovery] panic recovered со стеком, а движок — [GIN-debug] [ERROR] строки. Обе переменные пакетные, то есть присваиваются из вашего кода: gin.DefaultWriter = файл — и весь вывод уезжает с консоли в журнал. Отсюда три практических вывода. Первый: подменять выгодно до создания движка, потому что звенья захватывают writer в момент регистрации — LoggerWithConfig делает out := conf.Output, а при nil подставляет текущее значение DefaultWriter ровно один раз, и замыкание дальше живёт с этим значением; Recovery() устроен так же — он равен RecoveryWithWriter(DefaultErrorWriter). Второй: DefaultWriter обслуживает не только [GIN]-строки, но и [GIN-debug] — сообщения о маршрутах печатаются при регистрации; SetMode(gin.ReleaseMode) или переменная окружения GIN_MODE=release глушат их разом. Третий: подмена глобальна на весь процесс — в параллельных тестах за один writer начнут драться, поэтому там чаще подставляют локальный буфер в конкретный LoggerWithConfig, а не пакетную переменную.

### Подмена writer-ов: файл вместо консоли

```go
func main() {
        // Режим до всего: Release глушит [GIN-debug] строки движка.
        gin.SetMode(gin.ReleaseMode)

        // Открываем файл ДО gin.Default(): звенья запомнят writer
        // в момент регистрации, поздние подмены на них не подействуют.
        f, err := os.OpenFile("gin.log",
                os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
        if err != nil {
                panic(err)
        }
        gin.DefaultWriter = f      // [GIN]-строки Logger и [GIN-debug]
        gin.DefaultErrorWriter = f // [Recovery]-трассировки при паниках

        r := gin.Default()
        r.GET("/ping", func(c *gin.Context) {
                c.String(http.StatusOK, "pong")
        })
        r.Run(":8080")
}
```

Порядок строк здесь и есть главный сюжет. SetMode перед созданием движка — иначе [GIN-debug] предупреждения успеют уйти в старый stdout: debugPrint читает DefaultWriter в момент вызова, а не хранения. Присваивание writer-ов до gin.Default() — иначе Logger и Recovery внутри Default захватят os.Stdout и os.Stderr, и подмена останется пустым жестом. В Release-режиме бонус: Recovery напечатает трассировку короче — без дампа запроса, который он добавляет только в debug. Для тестов вместо файла берите bytes.Buffer: buf := &bytes.Buffer{}; gin.DefaultWriter = buf — и в конце проверяете buf.String().

### Проверка: момент захвата writer-а

**Вопрос:** В тесте сделали gin.DefaultWriter = buf, но ПОСЛЕ r := gin.Default(). Куда уйдут строки [GIN]?

- ✅ В os.Stdout: Logger захватил значение DefaultWriter в момент регистрации звена
- В buf: подмена распространяется и на уже созданные middleware
- Никуда: после создания движка gin блокирует подмену writer-ов
- В buf и в os.Stdout одновременно: gin дублирует вывод

LoggerWithConfig резолвит вывод один раз: out := conf.Output, при nil — текущее значение DefaultWriter, — и замыкание навсегда связано с этим значением. Переменную gin.Default() уже прочитал, позднее присваивание ей ничего не меняет. Правило одно: подменяйте пакетные writer-ы до gin.Default() и до Use — либо вообще не трогайте их и передавайте writer в LoggerWithConfig локально.

### Цвета под контролем: isatty, isTerm и consoleColorMode

В уроке 06 мы сказали: в файле цвета исчезают, потому что isatty. Теперь заглянем глубже — тем более что от этого зависит, как будут выглядеть ваши подменённые логи. Пакетная переменная consoleColorMode хранит один из трёх режимов: autoColor по умолчанию, disableColor после gin.DisableConsoleColor() и forceColor после gin.ForceConsoleColor(). Решение о цвете принимает метод IsOutputColor(): force включён — красим всегда; иначе нужно auto И флаг isTerm. Сам isTerm вычисляется в LoggerWithConfig ровно один раз, при регистрации, цепочкой из трёх условий: если вывод не *os.File — цветы нет (буфер и пайп автоматически остаются плоским текстом); если TERM=dumb — тоже; если isatty.IsTerminal и IsCygwinTerminal оба сказали «не терминал» — снова нет. Отсюда приятное следствие для тестов: буфер не требует никакого отключения цветов руками, ANSI-коды в него не попадут. И ещё одна деталь, мимо которой мы прошли в прошлый раз: у латентности есть собственная палитра LatencyColor — до 100 мс белый, до 200 зелёный, до 300 бирюзовый, до 500 синий, до секунды жёлтый, до двух пурпурный, дольше — красный. Медленные запросы видно краем глаза ещё до чтения цифр.

### LoggerWithFormatter и тип LogFormatter: своя строка лога

Свой формат логов в gin подключается одной строкой, но под ней стоит интересная конструкция. В logger.go объявлен тип LogFormatter — и здесь вас ждёт маленькое разочарование, честно вынесенное из исходников: это не интерфейс, а именованный тип-функция, type LogFormatter func(params LogFormatterParams) string. LoggerWithFormatter(f) — тонкая обёртка: она возвращает LoggerWithConfig(LoggerConfig{Formatter: f}), вся остальная механика общая. Если форматтер не задан, конфиг берёт defaultLogFormatter — непакетированную переменную с шаблоном [GIN] %v |%s %3d %s|…, который мы читали в уроке 06; его не заменить точечно, но можно написать свой за десять минут. Контракта два. Первый: форматтер возвращает ГОТОВУЮ строку — fmt.Fprint не дописывает перевод строки, поэтому 
 в конце ставите вы (а его отсутствие склеит все записи в одну строку). Второй: вернуть пустую строку — легально: writer ничего не получит, и это не баг, а дверь для структурных логгеров, о которых дальше. Наконец, LogFormatterParams несёт не только поля из урока 06, но и Keys — слепок c.Keys: всё, что звенья положили в контекст через c.Set, доступно форматтеру прямо в строке лога.

### Форматтер с ключами контекста и ошибками

```go
// requestID из прошлого урока пишет метку в контекст: c.Set("requestID", id).
// LogFormatterParams приносит Keys целиком — метка доезжает до лога.

r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
        // ErrorMessage приходит готовой строкой: "Error #01: ...\n"
        // на каждую ошибку или пустая строка, если ошибок не было.
        return fmt.Sprintf("%s %s -> %d за %v req=%v\n%s",
                param.Method, param.Path,
                param.StatusCode, param.Latency,
                param.Keys["requestID"], param.ErrorMessage)
}))
```

Форматтер вызывается после c.Next() — все ошибки хендлера к этому моменту уже в копилке, а статус уже финальный, включая Abort-ответы. Ключи контекста приходят картой any: обращение param.Keys["requestID"] вернёт nil-интерфейс на запросах без метки — для строгих типов заводите хелпер чтения, как мы делали в прошлом уроке. Обратите внимание: ErrorMessage в нашем шаблоне стоит после \n — каждая ошибка уедет на отдельную строку под строкой запроса, потому что String() копилки завершает каждую запись переводом строки.

### Какая ошибка попадёт в строку лога

```go
r := gin.Default() // Logger из Default, форматтер дефолтный

r.GET("/flaky", func(c *gin.Context) {
        c.Error(errors.New("кэш недоступен")).
                SetType(gin.ErrorTypePublic)
        c.Error(errors.New("timeout после 2s"))

        c.JSON(http.StatusServiceUnavailable,
                gin.H{"error": "unavailable"})
})

// curl localhost:8080/flaky
// Что напечатает Logger в поле ErrorMessage?
```

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

- ✅ Только Error #01: timeout после 2s — публичная ошибка отфильтрована
- Обе ошибки: кэш недоступен и timeout после 2s
- Ни одной: c.Error не пишет в лог вообще ничего
- Только публичную: приватные ошибки Logger не показывает

Logger собирает поле так: param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String() — в лог едут только приватные ошибки. c.Error помечает ошибку приватной по умолчанию, но первый вызов переопределил тип на ErrorTypePublic — она ушла клиенту, и фильтр её пропустил мимо. Публичный канал — для ответа, приватный — для лога; Default напечатал только timeout.

### c.Errors: копилка ошибок запроса

Раз мы заговорили про публичное и приватное — время разобрать саму копилку. В errors.go определён type Error struct { Err error; Type ErrorType; Meta any }: обёртка над любой ошибкой с битовой меткой и опциональными метаданными. Типы — битовая маска uint64: ErrorTypePrivate (1<<0) и ErrorTypePublic (1<<1) — наше приватное/публичное разделение; ErrorTypeBind (1<<63) и ErrorTypeRender (1<<62) — служебные; ErrorTypeAny — все биты сразу, «фильтр не работает». Метод c.Error(err) — вход в копилку: он паникует на nil («err is nil» — проверено по исходникам), при необходимости оборачивает ошибку в Error с типом ErrorTypePrivate, дописывает в срез c.Errors и — важно — возвращает *Error. Возврат сделан под чейнинг: SetType и SetMeta возвращают ту же запись, поэтому c.Error(err).SetType(...).SetMeta(...) — одна строка. Над срезом живёт тип errorMsgs с помощниками: ByType возвращает отфильтрованную копию, Last — последнюю ошибку, Errors — срез строк, String — ту самую нумерованную распечатку Error #01: …, по одной строке на запись, с Meta отдельной строкой, если она есть. Договорённость о приватности простая: приватные ошибки могут содержать внутренности — адреса, SQL, секреты, — их место в логе; публичные читает клиент.

### Копилка в действии: публичное клиенту, приватное в лог

```go
r.GET("/flaky", func(c *gin.Context) {
        // c.Error только копит ошибку — ответ клиенту она НЕ пишет!
        c.Error(errors.New("кэш недоступен")).
                SetType(gin.ErrorTypePublic) // читать сможет клиент
        c.Error(errors.New("timeout после 2s")) // приватная: в лог

        // Публичный срез строк — в тело ответа.
        c.JSON(http.StatusServiceUnavailable, gin.H{
                "errors": c.Errors.ByType(gin.ErrorTypePublic).Errors(),
        })
})

// Звено-помощник из logger.go: после цепочки отдаёт все ошибки
// ответом. r.Use(gin.ErrorLogger()) — удобно в тестах и отладке.
```

Клиент получит 503 и {"errors":["кэш недоступен"]}, Logger в ErrorMessage напечатает только приватный timeout. c.Errors.String() при этом содержит обе записи. ErrorLogger внутри — это ErrorLoggerT(ErrorTypeAny): после c.Next() при непустой копилке вызывается c.JSON(-1, errors); спец-статус -1 означает «не менять уже записанный» — responseWriter игнорирует коды не больше нуля. Отдельная тонкость, о которой редко пишут: SetType ЗАМЕНЯЕТ биты, а не добавляет. Bind-семейство из урока 04 ставит ошибкам тип ErrorTypeBind — ровно его, без бита приватности, — поэтому ошибки binding в ErrorMessage дефолтного лога не видны.

### Проверка: звено ErrorLogger

**Вопрос:** Что делает звено gin.ErrorLogger(), поставленное в цепочку?

- ✅ После c.Next() отдаёт накопленные c.Errors JSON-ответом: c.JSON(-1, errors)
- Печатает ошибки в DefaultErrorWriter и продолжает цепочку
- Прерывает цепочку Abort'ом, если в копилке есть ошибки
- Ничего: это фабрика, и её нужно вызвать с типом ошибки

ErrorLogger() — готовое звено: после прохождения цепочки оно проверяет копилку и при наличии ошибок сериализует её в ответ, не меняя уже установленный статус. Фабричная форма тоже существует — ErrorLoggerT(typ) с фильтром ErrorTypeAny, Public или Private. Логированием в файл это звено не занимается — только ответом; за файл отвечает ваш форматтер или LoggerWithWriter.

### ErrorMessage: приватные ошибки в строке лога

Теперь весь путь ошибки собран. Хендлер вызывает c.Error — запись ложится в копилку приватной; кто-то переопределил тип через SetType — канал сменился. Ниже по цепочке отрабатывает abort-звено: AbortWithError из прошлого урока — это AbortWithStatus плюс c.Error, то есть отказ клиенту и отметка в копилке одной строкой. А затем Logger, стоящий выше, печатает строку лога: ErrorMessage он собирает выражением c.Errors.ByType(ErrorTypePrivate).String() ПОСЛЕ c.Next() — механика «лога после Abort», которую мы проследили на тайминг-звене в прошлом уроке, здесь работает на поле ошибки: даже отклонённый запрос оставит и строку лога, и свои ошибки в ней. Полный набор фактов на выходе: [GIN] строка со статусом 401, а следом — Error #01 с текстом отказа. Практическое правило вытекает само: хотите видеть ошибку в логе — держите её приватной; хотите показать клиенту — публичной; транспорт для публичных — Envelope-конверт из урока про JSON, а не ErrorMessage. И ещё одно следствие: ошибки валидации binding не видны в дефолтном логе — их тип после SetType стал ErrorTypeBind, поэтому для форм бинд-ошибки логируйте сами, руками.

### Куда что пишет gin: сводная таблица

| Источник записи | Writer по умолчанию | Что именно пишет |
| --- | --- | --- |
| gin.Logger и свой форматтер | DefaultWriter (os.Stdout) | строка [GIN] после ответа: статус, латентность, IP, путь, ErrorMessage |
| gin.Recovery и CustomRecovery | DefaultErrorWriter (os.Stderr) | [Recovery] panic recovered: время, значение паники, стек; в debug — ещё дамп запроса |
| отладка движка [GIN-debug] | DefaultWriter (os.Stdout) | маршруты при регистрации, предупреждения New/Default, [ERROR] — только в debug-режиме |
| debugPrintError движка | DefaultErrorWriter (os.Stderr) | [GIN-debug] [ERROR] строки, например ошибка Run |
| net/http без Recovery | stderr сервера | http: panic serving — соединение обрывается без статуса (разбор в уроке 06) |
| подмена на файл / буфер / slog | назначаете вы | присваивайте до gin.Default() и Use — звенья захватывают writer при регистрации |

### CustomRecovery и RecoveryWithWriter: паника под контролем

Второй стол — ошибки — не менее настраиваем, чем первый. Собственно gin recovery — это Recovery(), и в исходниках он однострочный: return RecoveryWithWriter(DefaultErrorWriter). RecoveryWithWriter(out io.Writer, recovery ...RecoveryFunc) — переходник с variadic-хвостом: передали обработчик — он вызовет CustomRecoveryWithWriter(out, handler), не передали — с defaultHandleRecovery, который мы видели в уроке 06: приведение значения паники к error (если не error — через fmt.Errorf("%v", err)), c.Error в копилку, AbortWithStatus(500) с пустым телом. CustomRecovery(handle) — тот же RecoveryWithWriter, но с дефолтным stderr: паника всё равно попадает в лог, а клиент получает то, что решил ваш обработчик. Внутри CustomRecoveryWithWriter создаётся log.New(out, "\n\n\x1b[31m", log.LstdFlags) — два пустых перевода строки и красный ANSI-префикс, чтобы трассировка не слипалась с [GIN]-строками. Два факта, которых в уроке 06 не было. Первый: дамп запроса в трассировке — привилегия debug-режима; в release печатаются только время, значение паники и стек (IsDebugging() решает, какую из двух печатных форм использовать). Второй: стек собирает функция stack — она идёт по кадрам runtime.Caller и для каждого читает ИСХОДНЫЙ ФАЙЛ С ДИСКА, чтобы показать строку кода, где случилась паника; поэтому [Recovery]-трассировка такая человекочитаемая, но и требует, чтобы исходники лежали рядом.

### CustomRecovery: JSON-конверт вместо пустого 500

```go
// panicToEnvelope — наш RecoveryFunc: лог и честный ответ клиенту.
// Сигнатура задана типом RecoveryFunc: func(c *Context, err any).
func panicToEnvelope(c *gin.Context, err any) {
        // err — any: паниковать можно любым значением, не только error.
        slog.Error("паника в хендлере", "panic", fmt.Sprintf("%v", err))

        // Envelope-конверт из урока про JSON: ошибка со схемой, не стектрейс.
        c.AbortWithStatusJSON(http.StatusInternalServerError, Envelope{
                Error: &APIError{
                        Code:    "INTERNAL",
                        Message: "внутренняя ошибка сервера",
                },
        })
}

func main() {
        r := gin.New()
        r.Use(gin.Logger())
        // CustomRecovery = RecoveryWithWriter(DefaultErrorWriter, panicToEnvelope):
        // стек уйдёт в stderr, клиенту — наш JSON.
        r.Use(gin.CustomRecovery(panicToEnvelope))

        r.GET("/boom", func(c *gin.Context) {
                var m map[string]int
                m["x"] = 1 // паника: assignment to entry in nil map
        })
        r.Run(":8080")
}
```

В отличие от дефолтного defaultHandleRecovery, наш обработчик сам решает и лог, и тело ответа: в логе — slog с полем panic, клиенту — 500 с Envelope, где нет ни стектрейса, ни текста паники. Заметьте: запись в nil-карту — ещё одна классическая паника рантайма рядом с делением на ноль из урока 06. Если стек нужен не в stderr, а в файл — берите RecoveryWithWriter(f, panicToEnvelope): writer и обработчик настраиваются независимо.

### Проверка: сигнатура обработчика паники

**Вопрос:** Какую сигнатуру требует gin.CustomRecovery от функции-обработчика?

- ✅ func(c *gin.Context, err any) — тип RecoveryFunc
- func(c *gin.Context, err error) — только error
- func(err any) http.Handler
- func(c *gin.Context) error — обработчик сам паникует

type RecoveryFunc func(c *Context, err any) — второй параметр именно any, потому что panic() в Go принимает любое значение: строку, ошибку, структуру, nil. Обработчик сам решает, приводить ли значение к error — дефолтный defaultHandleRecovery на не-ошибках делает fmt.Errorf("%v", err) перед c.Error. Внутри обработчика доступны все abort-методы — обычно это AbortWithStatusJSON с конвертом.

### slog как writer-адаптер: практика

Теперь практика, ради которой затевались подмены. Стандартная библиотека Go с версии 1.21 даёт структурный логгер slog, и два моста между gin логированием и slog строятся за десять минут. Мост первый — адаптер writer-а: любой io.Writer годится для LoggerWithWriter и RecoveryWithWriter, а значит, достаточно типа с методом Write(p []byte) (int, error), который пересылает кусок в slog.Info. Контракт io.Writer строгий: вернуть длину принятых байт и nil — именно по возвращаемой длине fmt и log решают, что запись состоялась. Нюанс: gin пишет [GIN]-строку целиком одним вызовом fmt.Fprint, а Recovery — через log.Logger, который добавляет дату и свой префикс, поэтому адаптер получает готовые строки с \n на хвосте — TrimSpace перед отдачей slog. Мост второй, более аккуратный для access-лога: LoggerWithFormatter, который ничего не печатает — форматтер забирает поля LogFormatterParams и отдаёт их slog как атрибуты, возвращая пустую строку. Получаете настоящие структурные записи: method, path, status, latency как отдельные поля, по которым ищет любой сборщик логов. zap и zerolog, упомянутые ещё в уроке про первый сервер, подключаются той же дорогой — направление показано, пакет выберете сами.

### Два моста: адаптер для Recovery, форматтер для access-лога

```go
// slogWriter — адаптер io.Writer поверх структурного логгера.
type slogWriter struct {
        logger *slog.Logger
}

// Write пересылает запись gin в slog одной строкой.
func (w slogWriter) Write(p []byte) (int, error) {
        w.logger.Info(strings.TrimSpace(string(p)))
        return len(p), nil // io.Writer: вернуть длину принятого
}

// slogAccessLog — access-лог gin средствами slog: без [GIN]-строк.
func slogAccessLog(logger *slog.Logger) gin.HandlerFunc {
        return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
                logger.Info("http",
                        "method", param.Method,
                        "path", param.Path,
                        "status", param.StatusCode,
                        "latency", param.Latency.String(),
                        "errors", param.ErrorMessage)
                return "" // в writer ничего не уходит
        })
}

func main() {
        logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
        r := gin.New()
        r.Use(slogAccessLog(logger))
        r.Use(gin.RecoveryWithWriter(slogWriter{logger})) // стек паники — тоже в slog
        r.Run(":8080")
}
```

Тип и функция демонстрируют оба моста. slogAccessLog превращает каждый запрос в структурную запись с полями — поиск по status=500 в журнале становится тривиальным, а строка [GIN] не печатается вовсе. RecoveryWithWriter(slogWriter{logger}) уводит трассировку паники в тот же логгер: запись придёт многострочной — со стеком целиком, потому что log.Logger внутри Recovery склеивает её в один Write. Импорты: log/slog, strings, os, net/http, time — при желании.

### Куда уйдёт трассировка паники

```go
r := gin.Default() // Recovery() уже в цепочке

buf := &bytes.Buffer{}
gin.DefaultErrorWriter = buf

r.GET("/boom", func(c *gin.Context) {
        panic("взрыв")
})

// curl localhost:8080/boom
// Куда напечатается [Recovery] panic recovered?
```

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

- ✅ В os.Stderr: Recovery() захватил DefaultErrorWriter в момент gin.Default()
- В buf: подмена действует на все звенья, включая созданные раньше
- Никуда: Recovery пишет только через DefaultWriter, а не ErrorWriter
- В buf и в os.Stderr: Recovery дублирует трассировку в оба writer-а

Тот же закон захвата, что и у Logger: Recovery() равен RecoveryWithWriter(DefaultErrorWriter), и это выражение вычислилось в момент создания движка — замыкание запомнило os.Stderr. Поздняя подмена пакетной переменной на работающий движок не влияет. Если подмена нужна — она до gin.Default(); либо звено собирается вручную: r.Use(gin.RecoveryWithWriter(buf)).

### Допишите подмену вывода на файл

Соберите каркас: тихий режим, файл в качестве writer-а, корректный ответ хендлера.

```go
func main() {
        gin.___(gin.ReleaseMode) // без [GIN-debug] строк в журнале

        f, err := os.Create("gin.log")
        if err != nil {
                panic(err)
        }
        // Подмена до gin.Default(): звенья захватят writer при регистрации.
        gin.___ = f
        gin.DefaultErrorWriter = f

        r := gin.Default()
        r.GET("/ping", func(c *gin.Context) {
                c.___(http.StatusOK, "pong")
        })
        r.Run(":8080")
}
```

Подсказка: Режим ставит gin.SetMode; строка [GIN] пишется в DefaultWriter; текстовый ответ — c.String.

Правильные ответы в пропусках: `SetMode`, `DefaultWriter`, `String`

### Соберите запрос с паникой: от nil-карты до строки лога

Цепочка: Logger, затем CustomRecovery(panicToEnvelope). Хендлер пишет в nil-карту. Разложите события в порядке исполнения.

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

1. `Logger: start := time.Now()`
2. `Logger: вызов c.Next() — цепочка уходит внутрь`
3. `Recovery: defer с recover() зарегистрирован`
4. `Хендлер: запись в nil-карту — паника assignment to entry in nil map`
5. `Recovery: recover() поймал значение паники`
6. `Recovery: log.Logger печатает [Recovery] panic recovered со стеком в DefaultErrorWriter`
7. `Recovery: обработчик panicToEnvelope — slog.Error и AbortWithStatusJSON(500, Envelope)`
8. `Клиент получает 500 и JSON с error-конвертом`
9. `Logger: c.Next() вернулся — код после него исполняется`
10. `Logger: ErrorMessage := c.Errors.ByType(ErrorTypePrivate).String()`
11. `Logger: c.Writer.Status() вернул 500`
12. `Logger: строка лога напечатана — паника видна и в трассировке, и в access-логе`

### Шесть правил вывода gin

- подменяйте DefaultWriter и DefaultErrorWriter до gin.Default() и Use: звенья захватывают writer в момент регистрации
- DefaultWriter обслуживает и [GIN]-логи, и [GIN-debug]: SetMode(gin.ReleaseMode) или GIN_MODE=release глушат отладку
- свой формат — LoggerWithFormatter: функция от LogFormatterParams, \n в конце ставите сами, return "" отдаёт вывод slog
- c.Error только копит ошибки: приватные едут в ErrorMessage, публичные — клиенту; сама по себе c.Error ответа не пишет
- CustomRecovery — когда клиент должен получить JSON при панике; RecoveryFunc получает err any и несёт ответ сама
- RecoveryWithWriter(file) отправляет стек паники в файл; в Release-режиме трассировка короче — без дампа запроса

### Карточки: логирование и recovery

- **DefaultWriter и DefaultErrorWriter** — Пакетные io.Writer gin: os.Stdout и os.Stderr. Обслуживают Logger, Recovery и [GIN-debug]. Захватываются звеньями при регистрации — подменять до gin.Default().
- **LogFormatter** — type LogFormatter func(LogFormatterParams) string — тип-функция, не интерфейс. Подключается LoggerWithFormatter; строку с \n возвращает сам форматтер, "" — ничего не печатать.
- **c.Errors** — Копилка *Error{Err, Type, Meta}. c.Error возвращает *Error — чейнинг SetType/SetMeta; по умолчанию тип ErrorTypePrivate. ByType — фильтр-копия, String — «Error #01: …».
- **ErrorMessage в строке лога** — c.Errors.ByType(ErrorTypePrivate).String(), собирается после c.Next() — лог печатается и после Abort. Публичные и bind-ошибки (SetType заменяет биты) в неё не попадают.
- **CustomRecovery** — RecoveryWithWriter(DefaultErrorWriter, handle); RecoveryFunc = func(c *Context, err any). Дефолт defaultHandleRecovery: c.Error + AbortWithStatus(500) без тела. Стек пишется всегда.
- **slog-адаптер** — io.Writer с методом Write → slog.Info(string(p)); вернуть len(p), nil. Для access-лога — LoggerWithFormatter со slog-атрибутами и return ""; для стека паники — RecoveryWithWriter(адаптер).

> **Проверьте всё с curl за пять минут.** Соберите main с подменёнными writer-ами и двумя маршрутами: /ping и /boom с записью в nil-карту. curl -i localhost:8080/ping — и в gin.log появится строка запроса; curl -i localhost:8080/boom — 500 с JSON-конвертом, а в журнале — структурная запись от CustomRecovery и трассировка со стеком. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше по курсу — урок 09-validaciya: валидация входных данных с библиотекой validator, теги validate и свои сообщения об ошибках.

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

- Весь вывод gin обслуживают две пакетные переменные: DefaultWriter (os.Stdout — [GIN]-строки и [GIN-debug]) и DefaultErrorWriter (os.Stderr — трассировки Recovery).
- Звенья захватывают writer в момент регистрации: LoggerWithConfig подставляет DefaultWriter один раз, Recovery() равен RecoveryWithWriter(DefaultErrorWriter) — подменяйте до gin.Default().
- Цвета решаются при регистрации: consoleColorMode (auto/disable/force) и одноразовый замер isTerm — буфер и файл остаются плоским текстом без всяких телодвижений.
- LogFormatter — тип-функция func(LogFormatterParams) string: LoggerWithFormatter подменяет формат строки, в Keys приходит слепок c.Keys из звеньев.
- c.Errors — копилка ошибок с типами private/public/bind: c.Error возвращает *Error для чейнинга SetType и SetMeta; ErrorMessage лога — только приватные, и собирается он после c.Next().
- CustomRecovery заменяет пустой 500 на ваш ответ: RecoveryFunc получает err any, стек паники всё равно уходит в writer; RecoveryWithWriter направляет его в любой io.Writer.
- slog подключается двумя мостами: адаптер io.Writer для трассировок и LoggerWithFormatter с slog-атрибутами и return "" для access-лога.

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

Возьмите main из урока и доведите до продакшен-вида: подмените DefaultWriter и DefaultErrorWriter на файл, добавьте slogAccessLog со структурными полями и панику /boom с CustomRecovery, отдающей Envelope с кодом INTERNAL. Затем добавьте звено requestID из прошлого урока и добейтесь, чтобы идентификатор запроса попал в access-лог через param.Keys. Проверьте curl'ом: строка /ping — с req=..., /boom — 500, конверт и две записи в журнале (структурная от форматтера и трассировка со стеком).

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

### Как полностью отключить логи gin?

Независимо управляются два потока. [GIN-debug] строки глушатся SetMode(gin.TestMode) или gin.ReleaseMode (или GIN_MODE=release). Access-лог — просто не добавляйте gin.Logger, либо направьте его в никуда: gin.LoggerWithConfig(gin.LoggerConfig{Output: io.Discard}). Трассировки Recovery отключать не стоит: замените их CustomRecovery со своим компактным обработчиком, если шумят.

### Чем c.Error отличается от AbortWithError?

c.Error(err) лишь дописывает ошибку в копилку c.Errors — статус и тело она не трогает, и без последующего ответа клиент получит пустой 200. AbortWithError(code, err) — это AbortWithStatus плюс c.Error: refusal со статусом и отметка в копилке разом. Обе ошибки по умолчанию приватные и попадут в ErrorMessage лога.

### Куда направлять логи gin в продакшене?

В структурный логгер: LoggerWithFormatter со slog/zap-атрибутами и return "" для access-лога, RecoveryWithWriter с адаптером io.Writer для трассировок. Писать [GIN]-строки в файл тоже можно — подменой DefaultWriter до gin.Default(), — но искать по полю status в структурной записи сильно удобнее, чем grep'ать по плоскому тексту.
