---
title: "Логирование в Echo: e.Logger, slog и RequestLogger"
description: "echo логирование в деталях: интерфейс e.Logger с уровнями, структурированные логи slog, RequestLogger с RequestLoggerConfig и корреляция по RequestID."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/09-logirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Логирование в Echo: e.Logger, slog и RequestLogger

*Курс «Echo: минималистичный быстрый фреймворк», урок 9 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/09-logirovanie/*

echo логирование складывается из двух потоков, которые новички часто сливают в один: собственные события приложения и журнал запросов — access log. События приложения пишутся через e.Logger, интерфейс из log.go, за которым стоит пакет labstack/gommon: уровни, SetOutput, SetHeader и Fatalf, на котором висит запуск сервера. Журнал запросов в свежих v4 — это middleware.RequestLogger с RequestLoggerConfig: флаги LogStatus, LogURI, LogRequestID и ваша LogValuesFunc, а старый middleware.Logger из каталога [седьмого урока](/courses/echo/lessons/07-vstroennye-middleware/) помечен Deprecated. Записи будем делать структурированными — пакетом slog из стандартной библиотеки — и склеим с идентификатором запроса из RequestID, а строки метрик metricsTimer из [прошлого урока](/courses/echo/lessons/08-kastomnye-middleware/) наконец переедут из fmt.Printf в настоящий логгер.

### echo логирование: два потока вместо одного

У любого веб-приложения два лог-потока, и у них разные заказчики. Первый — журнал запросов: одна строка (или запись) на каждый HTTP-запрос, его читают дежурные и метрики, в нём важны метод, путь, статус и латентность. Второй — события самого приложения: «не смогли открыть конфиг», «миграция прошла», «задача создана»; их читают разработчики, и в них важны атрибуты — id сущности, имя пользователя, длительность операции. echo логирование устроено ровно так: за первый поток отвечает middleware, за второй — e.Logger, и смешивать их не стоит.

В седьмом уроке вы уже разобрали штатный middleware.Logger: fasttemplate-формат с тегами ${...}, LoggerConfig, CustomTagFunc, скипер. Свежие версии v4 подвели черту: в v4.14 фабрики Logger и LoggerWithConfig получили комментарий Deprecated с прямой рекомендацией — «please use middleware.RequestLogger or middleware.RequestLoggerWithConfig instead». Сегодняшний урок рассказывает, что пришло на смену, и прихватывает второй вопрос, который всё время зависал рядом: уровень лога — почему одни записи печатаются, а другие молча съедаются.

### e.Logger: интерфейс из log.go и gommon под капотом

Поле Logger у структуры Echo объявлено как Logger Logger — интерфейс, который живёт в файле log.go корневого пакета. Контракт широкий: пары Output() io.Writer / SetOutput(w io.Writer), Prefix() / SetPrefix(p string), Level() log.Lvl / SetLevel(v log.Lvl) плюс SetHeader(h string); семейство Print и Printf без уровня; по пять семейств с уровнями — Debug, Info, Warn, Error, Fatal, Panic — в трёх вариантах каждое (обычный, с форматом f и JSON-вариант с суффиксом j). Реализация по умолчанию — не из Echo: конструктор присваивает Logger: log.New("echo"), где log — это github.com/labstack/gommon/log, зависимость из go.mod (v0.4.2). Префикс "echo" вы видите в каждой записи.

Внутри echo.New() происходит ещё два незаметных, но важных действия. Первое: e.Logger.SetLevel(log.ERROR) — сразу после создания уровень лога устанавливается в ERROR, и это объясняет половину жалоб «а почему Info не печатается». Второе: e.StdLogger = stdLog.New(e.Logger.Output(), e.Logger.Prefix()+": ", 0) — Echo создаёт стандартный *log.Logger с префиксом "echo: ", разделяющий тот же вывод, и отдаёт его http.Server-у как ErrorLog в configureServer. Наконец, глобальный DefaultHTTPErrorHandler тоже пишет через e.Logger: если отправить ответ не удалось (ошибка внутри c.JSON), он зовёт e.Logger.Error(err). То есть всё, что Echo сообщает о себе, течёт через один интерфейс — и весь этот поток перенастраивается тремя методами из следующего блока.

### SetLevel, SetOutput, SetHeader: ручки e.Logger

```go
import (
        "io"
        "os"

        "github.com/labstack/echo/v4"
        "github.com/labstack/gommon/log" // уровни и реализация по умолчанию
)

func main() {
        e := echo.New()

        // New() уже поставил log.ERROR — поднимаем порог до INFO,
        // иначе e.Logger.Info будет молчать
        e.Logger.SetLevel(log.INFO)

        // заголовок записи — fasttemplate с тегами уровня gommon;
        // заголовок в форме JSON-объекта валиден и удобен для парсеров
        e.Logger.SetHeader(`{"time":"${time_rfc3339_nano}","level":"${level}","prefix":"${prefix}","file":"${short_file}","line":"${line}"}`)

        // вывод: консоль + файл; в файл уйдёт плоский текст —
        // gommon сам отключит цвет для не-терминала
        f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
        if err != nil {
                e.Logger.Fatal(err) // печать + os.Exit(1)
        }
        e.Logger.SetOutput(io.MultiWriter(os.Stdout, f))

        e.Logger.Infof("приложение стартует, режим %s", "dev")

        e.Logger.Fatal(e.Start(":1323"))
}
```

Три ручки — три абстракции: SetLevel решает «печатать ли вообще», SetHeader — как выглядит заголовок записи, SetOutput — куда падают байты. Теги заголовка у gommon свои: time_rfc3339, time_rfc3339_nano, level, prefix, long_file, short_file, line — это НЕ те теги, что у middleware.Logger из седьмого урока (${uri}, ${status}, ${latency_human}): два шаблона живут в разных пакетах и не перепутаны. Если заголовок заканчивается закрывающей скобкой объекта, gommon печатает запись как JSON — дописывает "," и поле "message" с вашим текстом, выходит одна валидная строка. SetOutput с файлом или пайпом автоматически выключает цвет: внутри стоит проверка *os.File плюс isatty.IsTerminal. И две детали про Fatal-семейство: оно всегда печатает независимо от уровня и завершает процесс os.Exit(1), а Panic-семейство паникует. Про песочницу — как и весь курс Echo, этот урок не исполняется браузерным движком сайта: runner-ов здесь нет, код запускается локально — go mod init, go get github.com/labstack/echo/v4, go run и curl.

### Что напечатает свежий e := echo.New()

```go
func main() {
        e := echo.New() // SetLevel(log.ERROR) уже внутри

        e.Logger.Info("приложение запущено")
        e.Logger.Warnf("медленный запрос: %s", "3.2s")
        e.Logger.Errorf("не смогли открыть конфиг: %v", err)
        e.Logger.Printf("слушаю порт %s", ":1323")

        e.Logger.Fatal(e.Start(":1323"))
}
```

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

- ✅ Только Errorf и Printf: уровень ERROR пропускает записи уровня ERROR и безуровневый Print, Info и Warn молчат
- Все четыре строки: INFO — дефолтный уровень, всё остальное тоже проходит
- Только Printf: ERROR тоже глушится уровнем по умолчанию
- Ни одной строки: свежий Echo создаётся с уровнем OFF

echo.New() заканчивается вызовом e.Logger.SetLevel(log.ERROR) — дефолтный уровень вовсе не INFO. Правило gommon такое: запись печатается, если её уровень >= текущего или запись безуровневая (Print-семейство, уровень 0). Info (2) и Warn (3) против ERROR (4) не проходят — тишина; Errorf (4) проходит; Printf с уровнем 0 проходит всегда. Кладите сообщения приложения на Error-уровень, только если это действительно ошибка, иначе поднимите порог SetLevel(log.INFO) сразу после echo.New().

### Уровень лога: как gommon решает, печатать ли

Тип уровня — log.Lvl (uint8) с константами DEBUG=1, INFO=2, WARN=3, ERROR=4, OFF=5; внутренние уровни Panic и Fatal стоят выше и не экспортируются. Вся логика решения — одна строка в методе log: запись печатается, если level >= l.Level() или level == 0. Отсюда три практических следствия. Первое: OFF глушит всё уровневое — живут только Print и Printf; при этом Fatal и Panic напечатаются даже при OFF (их уровни выше), потому что они обязаны успеть сообщить причину смерти: Fatal завершает процесс os.Exit(1), Panic паникует. Второе: SetLevel атомарен — внутри atomic.StoreUint32, чтение atomic.LoadUint32, — можно крутить уровень в рантайме без гонок: по сигналу, по env-флагу, по кнопке в админке. Третье: уровень тесно связан с debug-режимом — configureServer при e.Debug == true зовёт e.Logger.SetLevel(log.DEBUG), а заодно заезжает бонусом в другой файл: DefaultHTTPErrorHandler в Debug-режиме добавляет в JSON-ответ поле "error" с текстом ошибки, которого в обычном режиме нет.

### Проверка: что переживает SetLevel(log.OFF)

**Вопрос:** Вы вызвали e.Logger.SetLevel(log.OFF). Какие из вызовов что-то напечатают?

- Ничего: OFF глушит даже Fatal
- ✅ Только Print и Printf: у Print-семейства уровень 0, он проходит при любом пороге; Debugf и Errorf молчат, но Fatalf всё равно напечатает и завершит процесс
- Printf и Errorf: уровень ERROR всегда проходит
- Все четыре семейства: OFF влияет только на Debug

Решение — level >= l.Level() || level == 0. Debugf (1) и Errorf (4) против OFF (5) не проходят, Print и Printf с уровнем 0 проходят всегда. Fatal-семейство стоит выше OFF: печать происходит при любом уровне, после чего os.Exit(1) — поэтому «глушить» им нельзя ни один путь: выход из процесса всё равно случится. Практический вывод: OFF годится как временный тумблер на тестах, но не как способ «спрятать» ошибку фатальности.

### Структурированные логи: slog из стандартной библиотеки

Строка «user alice created todo 42» удобна человеку и неудобна машине: чтобы посчитать задачи по пользователям, её надо парсить. Структурированные логи переворачивают формат: событие — это сообщение плюс набор атрибутов ключ-значение, а сериализация (текст или JSON) — забота хендлера. Со времён Go 1.21 она решается стандартной библиотекой: пакет log/slog. Примитивы короткие: slog.New(h) оборачивает хендлер в *slog.Logger; slog.NewJSONHandler и slog.NewTextHandler — две штатные сериализации; опции — slog.HandlerOptions{Level, ReplaceAttr}: порог уровня и перерисовка атрибутов на выходе; атрибуты — slog.String, slog.Int, slog.Duration, slog.Int64, slog.Group; logger.With("service", "api") возвращает НОВОГО логгера с приклеенными атрибутами.

Отдельный метод — slog.SetDefault(l): он делает l логгером пакетных функций slog.Info/slog.Errorf и заодно перепривязывает пакет log: внутри вызываются log.SetOutput и log.SetFlags(0), после чего всё, что печатает пакетный log, течёт через ваш хендлер на уровне Info. Тонкость, на которой ловятся: перепривязывается только ДЕФОЛТНЫЙ пакетный log.Logger — чужие экземпляры *log.Logger, созданные через log.New, живут своей жизнью. К ним относится и e.StdLogger из Echo. Именно поэтому связка «slog echo» в рецептах собирается не одной строкой SetDefault, а тремя путями, которые разберём ниже.

### slog echo: JSONHandler, With и SetDefault

```go
// структурированный логгер приложения: JSON в stdout
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug, // порог ниже дефолтного Info
}))

// постоянные атрибуты: клеймо сервиса на каждой записи
logger = logger.With("service", "todos-api", "env", "dev")

// в обработчике (продолжение канона урока 08):
func createTodo(c echo.Context) error {
        in, _ := c.Get(ctxTodoInputKey).(TodoInput)
        t := Todo{ID: nextID, Title: in.Title, Priority: in.Priority}
        nextID++
        todos = append(todos, t)

        logger.Info("todo created",
                slog.Int("id", t.ID),
                slog.String("title", t.Title),
                slog.Duration("took", 421*time.Microsecond),
        )
        return c.JSON(http.StatusCreated, t)
}

// строка в stdout — одна запись, парсится без regexp:
// {"time":"2026-02-14T10:22:31.104+03:00","level":"INFO","msg":"todo created",
//  "service":"todos-api","env":"dev","id":7,"title":"купить хлеб","took":421000}

// делаем логгером по умолчанию: пакетные slog.Info и пакет log
// тоже потекут через JSONHandler
slog.SetDefault(logger)
```

Порядок полей в JSON-записи фиксирован: time, level, msg — служебные, затем With-атрибуты (service, env), затем атрибуты конкретного вызова. Duration сериализуется в наносекундах — 421000, это нормально: человекочитаемую латентность держите отдельным атрибутом со строкой. logger.With возвращает нового логгера — переприсваивайте переменную, атрибуты «на месте» не приклеиваются. И про пороги: HandlerOptions{Level: slog.LevelDebug} — зеркало SetLevel у gommon, только у slog уровни типизированы (slog.LevelDebug, LevelInfo, LevelWarn, LevelError), а не числа 1-5.

### Проверка: slog.SetDefault и e.StdLogger

**Вопрос:** После slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) — куда попадут сообщения об ошибках http.Server, которые Echo отдаёт в e.StdLogger?

- В JSON-записи slog: SetDefault перепривязывает весь пакет log, включая любые *log.Logger
- ✅ В прежний вывод — e.Logger.Output(): e.StdLogger создан log.New-ом при echo.New() как отдельный экземпляр, SetDefault его не трогает
- Никуда: после SetDefault все *log.Logger молчат
- В stderr напрямую, минуя любые логгеры

slog.SetDefault вызывает log.SetOutput только для пакетного логгера log.Default() — того самого, которым пользуется log.Printf. e.StdLogger — самостоятельный *log.Logger, созданный в echo.New() строкой stdLog.New(e.Logger.Output(), e.Logger.Prefix()+": ", 0); его вывод зашит при создании. Хотите слить и этот поток в общий JSON — перенаправляйте его вручную: e.Logger.SetOutput(...) (StdLogger унаследует новый writer, потому что писала в Output()), либо создавайте свой *log.Logger на хендлере slog.

### Три пути присадки slog в Echo

Путь первый — грубая сила: e.Logger.SetOutput(адаптер), где адаптер превращает строки gommon в вызовы slog. Работает, но честно скажем, что это костыль: адаптер видит ГОТОВУЮ строку, атрибуты уже сериализованы, и вы либо парсите их обратно, либо пишете текстом. Спасает только JSON-режим заголовка из блока выше — тогда каждая строка gommon сама валидный JSON. Этот путь живёт для старых проектов, где весь код уже пишет через e.Logger.

Путь второй — установить slog.SetDefault и подключить middleware.RequestLogger() без конфига: его дефолтная LogValuesFunc пишет именно пакетными slog.LogAttrs, значит, попадает в ваш JSONHandler автоматически. Минимум кода, но формат записи фиксирован — REQUEST со стандартным набором полей, и приклеить атрибут из c.Get не выйдет.

Путь третий — явный и рекомендуемый: middleware.RequestLoggerWithConfig с собственной LogValuesFunc, в замыкании которой живёт ваш *slog.Logger. Полный контроль над уровнем, сообщением и атрибутами. Любопытно, что доккомментарий request_logger.go содержит готовые примеры для slog, fmt.Printf, zerolog, zap и logrus — Echo прямо документирует, что middleware не навязывает библиотеку: LogValuesFunc получает структуру значений и пишет хоть в бумажную газету.

### RequestLogger: RequestLoggerConfig по списку из исходников

RequestLoggerConfig — это Skipper (первое поле по канону каталога), два крючка и полтора десятка флагов. Крючки: BeforeNextFunc func(c echo.Context) — вызывается перед next(c), удобно поставить метку начала; LogValuesFunc func(c echo.Context, v RequestLoggerValues) error — обязательная, документ помечает её словом Mandatory. Флаги-булевы, каждый вытаскивает своё значение в v: LogLatency (длительность цепочки), LogProtocol, LogRemoteIP (через c.RealIP()), LogHost, LogMethod, LogURI (полный URI с query), LogURIPath (только путь), LogRoutePath (шаблон маршрута c.Path() — тот же, что тег ${route}), LogRequestID, LogReferer, LogUserAgent, LogStatus, LogError, LogContentLength, LogResponseSize. Списковые поля: LogHeaders []string, LogQueryParams []string, LogFormValues []string — называют имена, и в v.Headers/v.QueryParams/v.FormValues падают срезы значений.

RequestLoggerValues — зеркало с типами: StartTime, Latency time.Duration, Protocol, RemoteIP, Host, Method, URI, URIPath, RoutePath, RequestID, Referer, UserAgent string, Status int, Error error, ContentLength string, ResponseSize int64. Если LogValuesFunc не задана, ToMiddleware возвращает ошибку "missing LogValuesFunc callback function for request logger middleware", а фабрика RequestLoggerWithConfig на ней паникует — то же правило «ошибка конфигурации стреляет при подключении», которое вы видели у KeyAuth в восьмом уроке. Дефолтный RequestLogger() включает latency, remoteIP, host, method, uri, requestID, userAgent, status, error, contentLength, responseSize плюс HandleError: true и печатает REQUEST или REQUEST_ERROR.

### RequestLoggerWithConfig и slog: корреляция и честный статус

```go
func main() {
        e := echo.New()
        logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

        // RequestID подключаем — он ставит X-Request-Id в ответ
        e.Use(middleware.RequestID())

        e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
                LogLatency:   true,
                LogMethod:    true,
                LogURI:       true,
                LogStatus:    true,
                LogError:     true,
                LogRequestID: true,
                HandleError:  true, // ошибку оформляет глобальный обработчик ДО записи
                LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
                        level, msg := slog.LevelInfo, "request"
                        if v.Error != nil {
                                level, msg = slog.LevelError, "request_error"
                        }
                        user, _ := c.Get(ctxUserKey).(string) // bearerAuth из урока 08
                        logger.LogAttrs(context.Background(), level, msg,
                                slog.String("request_id", v.RequestID),
                                slog.String("method", v.Method),
                                slog.String("uri", v.URI),
                                slog.Int("status", v.Status),
                                slog.Duration("latency", v.Latency),
                                slog.String("user", user),
                        )
                        return nil
                },
        }))

        api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))
        api.GET("/profile", profile)

        e.Logger.Fatal(e.Start(":1323"))
}
```

Читается так: запрос прошёл цепочку (или упал), значения сняты, LogValuesFunc превратила их в одну JSON-запись с id пользователя и запроса. Три момента, где свежие исходники отвечают заранее. HandleError: true — сразу после next(c) middleware зовёт c.Error(err): глобальный обработчик оформляет ответ, поэтому v.Status уже честный 401/400/500, а v.Error остаётся заполненным для ветки request_error. HandleError: false — ответ может быть не оформлен, и тогда LogStatus достаёт код из echo.HTTPError сам, через errors.As; иначе взял бы дефолтный 200 из response.go. Ошибка, возвращённая вашей LogValuesFunc, заменит исходную — return nil, если не хотите портить раскрутку цепочки. И ctxUserKey достаётся c.Get-ом: RequestLogger — обычное звено, весь контекст к нему доступен.

### Проверка: HandleError и статус 401 в записи

**Вопрос:** bearerAuth вернул 401-ошибку. В RequestLoggerConfig стоят LogStatus: true, LogError: true, HandleError: true. Что увидят LogValuesFunc и клиент?

- Ответ ещё не оформлен: v.Status = 200 (дефолт из response.go), клиент получит 200
- ✅ c.Error(err) вызван middleware-ом: ответ оформлен глобальным обработчиком, v.Status = 401, v.Error != nil, клиент получит 401
- v.Status = 401, но клиент получит 200: RequestLogger проглотил ошибку
- Паника: HTTPError нельзя проводить через RequestLogger

HandleError: true — это ровно приглашение вызвать c.Error(err) до снятия значений: if err != nil && config.HandleError { c.Error(err) }. Ответ фиксируется глобальным обработчиком, статус становится настоящим — в записи 401 и в ветке request_error. Вариант «v.Status = 200» — ловушка из восьмого урока: она стреляет, когда вы печатаете статус БЕЗ c.Error; здесь c.Error вызван. Если HandleError выключить, код 401 всё равно попадёт в запись, но уже другим механизмом — errors.As до echo.HTTPError, — а ответ оформится позже, после выхода middleware из цепочки. Упавшие строки лога при HandleError не дублируют обработку: возвращённая наверх ошибка встречает Committed=true в DefaultHTTPErrorHandler и игнорируется.

### Конфиг без LogValuesFunc

```go
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
        LogStatus: true,
        LogURI:    true,
        // LogValuesFunc кто-то забыл
}))

// что произойдёт при старте?
```

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

- ✅ Паника сразу при e.Use: ToMiddleware вернёт ошибку «missing LogValuesFunc callback function...», фабрика паникует
- Слой зарегистрируется и молча пропустит запись на каждом запросе
- Ошибка компиляции: конфиг без LogValuesFunc не типобезопасен
- Первая запись напечатается дефолтным форматтером REQUEST

Поле LogValuesFunc — func-тип, его нулевое значение nil типобезопасно, поэтому компилятор молчит. Но RequestLoggerWithConfig не регистрирует слой, а сначала зовёт config.ToMiddleware() — тот проверяет обязательность и возвращает ошибку; фабрика на ней паникует. Ошибка конфигурации стреляет при подключении, а не на первом запросе — тот же принцип, что у KeyAuth без Validator и вашего bearerAuthWithConfig с panic-ом на пустом токене из восьмого урока. Дефолтный форматтер REQUEST здесь ни при чём: он живёт в отдельной фабрике RequestLogger(), у которой LogValuesFunc уже написана.

### Корреляция по RequestID: три способа положить id в запись

Механику middleware.RequestID вы разобрали в седьмом уроке: заголовок клиента переиспользуется, иначе генерируется randomString(32), заголовок X-Request-Id ставится в ответ до next(c). Здесь — только то, как идентификатор попадает в структурированную запись. Способ первый, встроенный: LogRequestID: true — RequestLogger сам читает константу echo.HeaderXRequestID из заголовков запроса, а если клиент её не прислал, берёт из заголовка ответа (там RequestID к моменту снятия значений уже стоит). Порядок относительно RequestID в цепочке не критичен: значения снимаются на раскрутке, после next(c), — но RequestID первым остаётся доброй привычкой.

Способ второй — RequestIDHandler func(c echo.Context, string) в RequestIDConfig: положите id в контекст своей константой c.Set(ctxRequestIDKey, id) и читайте в LogValuesFunc через c.Get вместе с пользователем — так id попадает и в собственные события приложения, не только в access-запись. Способ третий — протянуть заголовок дальше: если ваш сервис ходит в бэкенды, копируйте X-Request-Id в исходящие запросы, и строки логов разных программ склеятся по одному значению. И прибережём сантиметр честности: не создавайте slog.With(logger, ...) на каждый запрос — With аллоцирует нового логгера; в access-логе передавайте id атрибутом в LogAttrs, как в коде выше.

### Куда что пишет: карта вывода приложения

| Источник строк | Пишет по умолчанию | Как перенаправить |
| --- | --- | --- |
| Собственные события приложения | пакетный slog: текст в stdout через пакет log | slog.SetDefault(JSONHandler) или свой *slog.Logger в замыкании |
| Access-запись на каждый запрос | middleware.Logger — Deprecated с v4.14, fasttemplate-строка | RequestLoggerWithConfig + LogValuesFunc со slog |
| e.Logger (Fatal, Info, Error) | gommon log: JSON-заголовок по умолчанию, stdout | e.Logger.SetOutput / SetHeader / SetLevel |
| Ошибки http.Server (ErrorLog) | e.StdLogger → e.Logger.Output() | унаследует e.Logger.SetOutput — writer общий |
| Ошибка отправки ответа в DefaultHTTPErrorHandler | e.Logger.Error(err) | тот же e.Logger: SetOutput влияет и сюда |
| Строки метрик metricsTimer (урок 08) | fmt.Printf в stdout | заменить на slog.Info/Warn с атрибутами |

### Куда увести строки metricsTimer

В восьмом уроке metricsTimer печатала fmt.Printf("%s: total=%d err=%d avg=%s last=%s") — работало, но строку нельзя было ни отфильтровать по уровню, ни распарсить без regexp. Переезд делается в три действия. Первое: в MetricsConfig добавляется поле Log *slog.Logger, nil-резолв в теле фабрики ставит slog.Default() — тот же паттерн «дефолты на первой ступени», что и для Prefix. Второе: fmt.Printf заменяется на logger.Info с атрибутами: slog.Int64("total", total), slog.Int64("errored", errored), slog.Duration("avg", elapsed/time.Duration(total)), slog.Duration("last", latency). Третье — уровни по смыслу: обычная строка Info, а если вы делали SlowThreshold из домашнего задания, медленные запросы заслуживают Warn — дежурный увидит их в алертах, не переписывая фильтры.

Помните и про отличие потоков: метрика — агрегат за жизнь процесса, лог — событие. Строка лога с total на каждый запрос — это деградация до Printf с лишними расходами; настоящие счётчики стоит отдавать в Prometheus-экспортёр, а в логе оставлять событие «случилось что-то, достойное внимания». И правило статуса никуда не делось: c.Error(err) до печати нужно и здесь — при HandleError: true у RequestLogger тот же контракт выполняет middleware сам.

> **Приватные данные в структурированных записях.** Флаги RequestLoggerConfig тянут сырые значения: LogURI включает query целиком — uri="/reset?token=..." уедет в JSON как есть; LogHeaders и LogQueryParams дублируют то же в срезах. Пароль в теле POST не попадёт (тело не логируется ни одним флагом), а вот токен в query и заголовок Authorization — легко; про запрет логировать Authorization вы уже слышали в восьмом уроке. Лекарства два: для приватных API берите LogURIPath вместо LogURI (только путь, без query), а маскирование полей — ReplaceAttr в HandlerOptions: функция получает атрибут на выходе и может подрезать значение. Одноразовые ссылки и токены восстановления — первая проверка любого нового флага.

### Соберите конфиг RequestLogger

Заполните пропуски: фабрика, флаг RequestID, обязательная функция, пакетный вызов записи и атрибут статуса.

```go
e.Use(middleware.___WithConfig(middleware.RequestLoggerConfig{
        LogStatus:   true,
        Log___:      true, // id запроса в каждой записи
        LogError:    true,
        HandleError: true,
        ___: func(c echo.Context, v middleware.RequestLoggerValues) error {
                logger.___(context.Background(), slog.LevelInfo, "request",
                        slog.___("status", v.Status),
                )
                return nil
        },
}))
```

Подсказка: Фабрика с конфигом — RequestLoggerWithConfig; флаг идентификатора — LogRequestID; обязательная — LogValuesFunc; запись — logger.LogAttrs с контекстом и уровнем; статус — slog.Int.

Правильные ответы в пропусках: `RequestLogger`, `RequestID`, `LogValuesFunc`, `LogAttrs`, `Int`

### Соберите main() со slog и access-логом

Расставьте строки в правильном порядке: логгер создаётся и становится дефолтным до подключения middleware, RequestID стоит раньше access-лога, запуск — последним.

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

1. `func main() {`
2. `  e := echo.New()`
3. `  logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))`
4. `  slog.SetDefault(logger)`
5. `  e.Use(middleware.RequestID())`
6. `  e.Use(middleware.RequestLogger())`
7. `  e.GET("/ping", ping)`
8. `  e.Logger.Fatal(e.Start(":1323"))`
9. `}`

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

- **echo.Logger (интерфейс)** — type Logger interface в log.go: Output/SetOutput, Prefix/SetPrefix, Level/SetLevel, SetHeader, Print/Printf, пять семейств Debug-Info-Warn-Error-Fatal-Panic по три варианта (обычный, f, j). Реализация по умолчанию — gommon log.New("echo"). New() ставит SetLevel(log.ERROR); StdLogger разделяет вывод с префиксом "echo: ".
- **Уровень лога у gommon** — log.Lvl: DEBUG=1, INFO=2, WARN=3, ERROR=4, OFF=5. Печать при level >= l.Level() || level == 0: Print/Printf (0) проходят всегда, OFF глушит всё уровневое, Fatal/Panic печатаются при любом уровне и завершают процесс/паникуют. SetLevel атомарен — можно менять в рантайме. e.Debug = true включает log.DEBUG.
- **slog: базовые примитивы** — slog.New(slog.NewJSONHandler(w, &HandlerOptions{Level, ReplaceAttr})); атрибуты slog.String/Int/Duration/Int64/Group; logger.With(k, v) возвращает нового логгера с постоянными атрибутами; slog.SetDefault(l) — пакетные slog.* и пакет log идут через хендлер (log.SetOutput + SetFlags(0), уровень Info). Чужие *log.Logger (e.StdLogger) не перепривязываются.
- **RequestLoggerConfig (v4.15)** — Skipper; BeforeNextFunc; LogValuesFunc func(c, v RequestLoggerValues) error — обязательная (Mandatory), без неё ToMiddleware вернёт ошибку, фабрика паникует; HandleError bool — c.Error(err) до снятия значений; флаги LogLatency/Protocol/RemoteIP/Host/Method/URI/URIPath/RoutePath/RequestID/Referer/UserAgent/Status/Error/ContentLength/ResponseSize; списки LogHeaders/LogQueryParams/LogFormValues []string.
- **RequestLoggerValues** — StartTime, Latency time.Duration, Protocol, RemoteIP, Host, Method, URI, URIPath, RoutePath, RequestID, Referer, UserAgent string, Status int, Error error, ContentLength string, ResponseSize int64, Headers/QueryParams/FormValues map[string][]string. При HandleError=false и HTTPError статус берётся errors.As-ом из ошибки; при HandleError=true — из res.Status после глобального обработчика.
- **Корреляция по RequestID** — LogRequestID: true — middleware читает X-Request-Id из запроса, фолбэк — заголовок ответа (RequestID ставит его до next(c)). RequestIDHandler func(c, id) — кладём в контекст своей константой и читаем c.Get-ом. Входящий заголовок клиента переиспользуется — трассировка между сервисами. Порядок RequestID/RequestLogger не критичен: значения снимаются на раскрутке.

### Что дальше: ошибки и их формат

Мы разложили echo логирование по двум потокам и свели их на общем языке JSON: e.Logger управляется тремя ручками с уровнями DEBUG-OFF и атомарной сменой порога, slog даёт структурированные записи с With и SetDefault, а middleware.RequestLogger заменяет deprecated Logger из каталога и снимает честный статус через HandleError или errors.As. Идентификатор запроса из RequestID доезжает до записи тремя способами, строки metricsTimer переезжают в slog.Info/Warn с атрибутами, а ReplaceAttr прикрывает токены в query. order-задание — репетиция вашего main: логгер, SetDefault, RequestID, access-лог, маршруты, запуск.

Белым пятном осталась ошибка как объект: мы упоминали echo.HTTPError по имени, но не разбирали — поле Internal для исходной причины, Message, принимающий не только строки, собственный ErrorHandler с единым форматом всего API и его дружбу с HandleError: true из этого урока. Полный разбор — дело десятого урока. Пока проверьте себя карточками, соберите fill и order ещё раз и перенесите конфиг RequestLogger в учебный сервер восьмого урока: bearerAuth уже кладёт пользователя в контекст — одна запись с request_id, user, uri и статусом закрывает половину вопросов дежурного. Навигация по курсу — на [странице курса Echo](/courses/echo/), фабрики звеньев и c.Set/c.Get — в [уроке про кастомные middleware](/courses/echo/lessons/08-kastomnye-middleware/), каталог штатных слоёв и судьба старого Logger — в [уроке про встроенные middleware](/courses/echo/lessons/07-vstroennye-middleware/).

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

- echo логирование — два потока: журнал запросов (middleware) и события приложения (e.Logger). Смешивать их — значит лишать оба фильтров по уровню и парсинга.
- e.Logger — интерфейс из log.go поверх labstack/gommon: SetLevel, SetOutput, SetHeader плюс семейства Debug/Info/Warn/Error/Fatal/Panic. New() ставит уровень ERROR — Info по умолчанию молчит.
- Уровень лога: gommon печатает при level >= текущего; Print (0) проходит всегда, OFF глушит уровневое, Fatal/Panic печатаются и завершают процесс. SetLevel атомарен — меняется в рантайме.
- slog: New + JSONHandler/TextHandler, HandlerOptions{Level, ReplaceAttr}, With для постоянных атрибутов, SetDefault перепривязывает пакет log (только пакетный logger — e.StdLogger живёт своей жизнью).
- middleware.Logger помечен Deprecated с v4.14; замена — RequestLogger()/RequestLoggerWithConfig с RequestLoggerConfig: флаги Log* вытаскивают значения в RequestLoggerValues, LogValuesFunc обязательна и паникуется при отсутствии на e.Use.
- HandleError: true — middleware сам зовёт c.Error(err): статус в записи честный (401/400/500); без HandleError код достаётся errors.As-ом из echo.HTTPError.
- Корреляция: LogRequestID читает X-Request-Id (запрос, фолбэк — ответ), RequestIDHandler кладёт id в контекст для c.Get, заголовок протягивается между сервисами.

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

Соберите всё в учебном сервере восьмого урока. Первое: добавьте в MetricsConfig поле Log *slog.Logger с nil-резолвом в slog.Default() и переведите печать на logger.Info/Warn с атрибутами total/errored/avg/last — медленные запросы из SlowThreshold пусть идут с уровнем Warn. Второе: включите в RequestIDConfig RequestIDHandler и положите id в контекст константой ctxRequestIDKey; в LogValuesFunc добавьте атрибуты request_id из v.RequestID и user из c.Get(ctxUserKey). Третье: включите LogURI и убедитесь curl-ом, что uri содержит query целиком, затем замените на LogURIPath и напишите ReplaceAttr, маскирующую значение атрибута uri до первой буквы пути — сравните две записи и объясните себе, какая из них утечёт токен из query в продакшен.

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

### Чем e.Logger отличается от middleware.RequestLogger?

Это два разных потока. e.Logger — интерфейс логгера приложения (файл log.go поверх labstack/gommon): вы зовёте его методы сами, у него уровни DEBUG-OFF, SetOutput/SetHeader и префикс "echo". RequestLogger — middleware access-лога: одна запись на запрос, собирается из флагов Log* в структуру RequestLoggerValues, а печать делегирует вашей LogValuesFunc — хоть slog, хоть zap. Старый middleware.Logger из каталога с v4.14 помечен Deprecated именно в пользу RequestLogger.

### Почему e.Logger.Info ничего не печатает?

Потому что echo.New() ставит уровень ERROR: e.Logger.SetLevel(log.ERROR). Правило gommon — печать при level >= текущего: Info (2) и Warn (3) ниже порога и глушатся, Error (4) проходит, Print/Printf проходят всегда. Лечение: e.Logger.SetLevel(log.INFO) сразу после создания или e.Debug = true — configureServer включит DEBUG. SetLevel атомарен, порог можно менять в рантайме.

### Как подружить slog и стандартный пакет log?

slog.SetDefault(l): пакетные функции log.Printf/log.Fatal начнут писать через хендлер l на уровне Info — внутри это log.SetOutput + log.SetFlags(0). Но перепривязывается только пакетный логгер: экземпляры, созданные log.New (среди них e.StdLogger от Echo), продолжают писать туда, куда настроены при создании. Их перенаправляйте вручную — либо через общий writer e.Logger.SetOutput, либо собственным адаптером io.Writer на хендлере slog.
