Логирование
Winter логирует через пакет winter-logger — слой поверх PSR-3. Логгер приходит в класс сам и уже подписан его именем, поля контекста добавляются к каждой записи запроса, а секреты маскируются до записи.
Что такое логирование и зачем
Логирование — запись того, что происходит в приложении, чтобы это можно было разобрать потом.
Проблема. Разрозненные error_log() и echo дают поток строк без уровней, без
привязки к запросу и без единого места назначения. Разобрать по такому логу, что
случилось с конкретным пользователем в конкретный момент, невозможно: строки
нескольких одновременных запросов перемешаны, и понять, какая к какой относится,
не по чему.
Хуже другое — в лог легко утекает лишнее. Достаточно записать в него тело запроса или заголовки, и там окажется пароль или токен.
Решение. Единый логгер с уровнями, каналами и полями контекста, который сам подписывает записи запросом и вычищает из них чувствительные значения.
Быстрый старт
Объявите свойство типа LoggerInterface — и пишите:
use Flytachi\Winter\DI\Attribute\Autowired;
use Psr\Log\LoggerInterface;
class OrderService
{
#[Autowired] private LoggerInterface $logger;
public function place(Order $order): void
{
$this->logger->info('order placed', [
'id' => $order->id,
'total' => $order->total,
]);
}
}[INFO ] -http- [4821] (MainOrderService): order placed {"id":128,"total":99}Имя в скобках подставилось само — логгер приходит уже названным по классу, в
который его внедрили. Писать getLogger(self::class) не нужно.
Когда логи включаются
Устройство здесь такое: писать в лог можно всегда, а пишется ли что-то — зависит
от двух условий. Вызов $this->logger->info(...) безопасен в любом случае и
никогда не приводит к ошибке — даже если логирование не настроено вовсе.
Условий два, и они независимы:
| Условие | Если не выполнено |
|---|---|
В сборке есть monolog/monolog |
Логгер подменяется заглушкой — записи молча отбрасываются |
Задан LOG_LEVEL |
Механизм работает, но вывод направлен в «никуда» |
Пакет monolog/monolog
Ядро тянет winter-logger — слой поверх PSR-3, — но сам monolog, на котором тот
работает, не тянет. Это сделано намеренно: приложение, которому логи не нужны, не
обязано нести лишнюю зависимость.
Если пакета нет, LoggerFactory отдаёт Psr\Log\NullLogger: все вызовы проходят,
ничего не записывается, ошибок нет. Код с логированием работает в обеих сборках
одинаково — меняется только то, появляются ли строки на выходе.
composer require monolog/monologКак только пакет оказывается в сборке, механизм включается сам — ни настраивать, ни регистрировать ничего не нужно.
Проверить, что именно у вас
LoggerFactory::getLogger('check')::class
// Flytachi\Winter\Logger\Logger → пишет
// Psr\Log\NullLogger → monolog не установленПеременная LOG_LEVEL
Второе условие — порог. Пока переменная не задана, канал настроен на вывод null:
вызовы отрабатывают, но никуда не попадают.
LOG_LEVEL=infoЭтого достаточно — остальное имеет разумные значения по умолчанию: вывод в stdout,
формат line, цвет по наличию терминала.
Итого минимум для рабочих логов: пакет monolog в сборке плюс одна строка в
.env. Ни конфигураторов, ни регистрации каналов.
Молчаливое отсутствие логов — почти всегда одно из двух
Оба условия отказывают тихо: приложение работает, ошибок нет, а логов не видно. На
новом окружении проверяйте сначала LOG_LEVEL, потом наличие monolog — этим
объясняется подавляющее большинство случаев «логирование сломалось».
Как получить логгер
Три способа, от предпочтительного к запасным.
Внедрением — основной
Работает в любом классе, который строит контейнер: контроллеры, сервисы, middleware, задачи планировщика.
#[Autowired] private LoggerInterface $logger;Он уже есть — в процессах и демонах
Стереотипы Process и Daemon
дают логгер готовым, объявлять его не нужно:
public function run(): void
{
$this->logger->info('worker started');
}Фабрикой и фасадом — вне контейнера
Для статических хелперов и кода, куда контейнер не достаёт:
use Flytachi\Winter\Logger\{Log, LoggerFactory};
LoggerFactory::getLogger(UserService::class)->info('user created', ['id' => 42]);
LoggerFactory::getLogger(SyncTask::class, 'job')->debug('processing'); // явный канал
Log::warning('retrying', ['attempt' => 3]); // фасад, канал по умолчаниюФасад Log покрывает уровни от debug до alert; emergency у него нет — если он
нужен, берите логгер через LoggerFactory.
Уровни
Уровни стандартные, из PSR-3. Порог задаётся переменной LOG_LEVEL: записи ниже
него отбрасываются.
$logger->debug('...'); // подробности для отладки
$logger->info('...'); // штатное событие
$logger->notice('...'); // необычное, но не проблема
$logger->warning('...'); // проблема, с которой справились
$logger->error('...'); // операция не удалась
$logger->critical('...'); // сломан компонент
$logger->alert('...'); // нужно вмешательство прямо сейчас
$logger->emergency('...'); // приложение неработоспособноФреймворк придерживается той же шкалы, когда пишет за вас: ожидаемая ошибка клиента
(404, 422) идёт как warning, сбой на нашей стороне — как error. Подробнее —
на странице Обработка ошибок.
Каналы
Канал — это направление записи со своими настройками: уровнем, форматом, местом назначения. Ядро поднимает два.
| Канал | Что пишет |
|---|---|
http |
Всё, что происходит внутри обработки запроса |
sys |
Всё остальное: запуск приложения, процессы, демоны, планировщик, консоль |
Канал выбирается автоматически по тому, где выполняется код: воркер, обслуживающий
запросы, пишет в http, всё прочее — в sys. Указывать его вручную обычно не
нужно.
Разделение нужно, чтобы поток запросов не забивал системные события: их можно развести по разным файлам и держать на разных уровнях.
LOG_LEVEL=info
LOG_HTTP_LEVEL=warning # от запросов — только проблемы
LOG_SYS_LEVEL=debug # системные события подробно
LOG_SYS_OUTPUT=file # и в отдельный файлСвои каналы
Если нужен отдельный поток — например, для аудита, — объявите класс-конфигуратор. Регистрировать его нигде не надо, сканер найдёт сам:
<?php
namespace Main;
use Flytachi\Winter\Kernel\App\Config\ChannelRegistry;
use Flytachi\Winter\Kernel\App\Config\LoggingConfigurer;
final class LoggingConfig implements LoggingConfigurer
{
public function configureChannels(ChannelRegistry $channels): void
{
$channels->add('audit')->add('job');
}
}Дальше канал запрашивается по имени:
LoggerFactory::getLogger(PaymentService::class, 'audit')
->info('refund issued', ['order' => $id, 'by' => $operator]);Свой канал читает переменные с собственным префиксом — LOG_AUDIT_LEVEL,
LOG_AUDIT_OUTPUT и так далее, — а чего в них нет, берёт из общих LOG_*.
Уровень для своих исключений
Фреймворк логирует необработанные исключения сам. По умолчанию это error, но
исключение может объявить свой уровень — для этого оно реализует
ExceptionLogLevel:
use FlytachiWinterBaseExceptionExceptionLogLevel;
use PsrLogLogLevel;
class OrderNotFound extends RuntimeException implements ExceptionLogLevel
{
public function getLogLevel(): string
{
return LogLevel::WARNING;
}
}Зачем это нужно: ошибка клиента и сбой сервера должны лежать в разных потоках.
Запрошенный несуществующий заказ — это warning, обычное поведение системы, по
которому никого не будят ночью. Отвалившаяся база — error.
Так устроены и встроенные исключения. ResponseException выбирает уровень по коду
ответа: 5xx — error, всё остальное — warning. EntityException, который
бросают findByIdOrThrow() и подобные, — всегда warning.
Запись при этом получает контекст автоматически:
[WARNING] -http- [4821] (Router): Order not found
{"code":404,"exception":"MainOrderNotFound","file":"/app/main/OrderService.php:42"}Подробнее про обработку — на странице Обработка ошибок.
Контекст запроса
Поля, заданные один раз, попадают в каждую последующую запись этого запроса. Так в лог добавляют идентификатор запроса и пользователя, не протаскивая их аргументами через все слои.
// в middleware, один раз на запрос
$ctx = LoggerFactory::contextStorage();
$ctx->set('request_id', bin2hex(random_bytes(8)));
$ctx->set('user_id', $this->auth->user()->id);Дальше любая запись — из контроллера, из сервиса, из репозитория — несёт оба поля.
[INFO ] -http- [4821] (MainOrderService): order placed
{"id":128,"request_id":"a3f9...","user_id":42}Поля не смешиваются между запросами
Под Swoole воркер обслуживает несколько запросов одновременно, поэтому контекст хранится отдельно для каждой корутины. Идентификатор одного пользователя не может попасть в запись другого — это проверено, а не подразумевается.
Маскирование секретов
Значения по чувствительным ключам заменяются на *** до записи — включая
вложенные массивы:
$logger->info('login attempt', [
'username' => 'alice',
'password' => 'hunter2', // → ***
'meta' => ['token' => 'eyJhb...'] // вложенное тоже → ***
]);{"username":"alice","password":"***","meta":{"token":"***"}}По умолчанию маскируются password, secret, token, authorization, cookie,
credit_card, cvv и подобные; сравнение идёт без учёта регистра. Полный список и
добавление своих ключей — в
доках пакета.
Маскирование работает по ключам, а не по значениям
Токен, положенный в лог под нейтральным именем — ['value' => $jwt] — замаскирован
не будет, как и секрет, вклеенный прямо в текст сообщения:
$logger->info("token: {$jwt}").
Кладите чувствительное в контекст под понятным ключом, а не в строку сообщения.
Настройка через .env
Шесть переменных. Все имеют значения по умолчанию, кроме LOG_LEVEL — без неё
логирование выключено.
| Переменная | Допустимые значения | По умолчанию |
|---|---|---|
LOG_LEVEL |
debug · info · notice · warning · error · critical · alert · emergency |
не задана — вывод выключен |
LOG_OUTPUT |
auto · stdout · stderr · file · syslog · null |
auto |
LOG_FORMAT |
line · json |
line |
LOG_COLOR |
auto · always · never |
auto |
LOG_FILE |
Путь к файлу | storage/logs/<канал>.log |
LOG_FILE_MAX |
Целое число — сколько дней хранить | 30 |
LOG_LEVEL — порог
Значение задаёт нижнюю границу: записи этого уровня и всех, что серьёзнее, выводятся; всё, что ниже, отбрасывается и ничего не стоит.
debug ─ info ─ notice ─ warning ─ error ─ critical ─ alert ─ emergency
↑ подробнее серьёзнее ↑
LOG_LEVEL=info выводится всё, кроме debug
LOG_LEVEL=warning только warning и выше — обычный выбор для прода
LOG_LEVEL=error только сбоиРегистр не важен: info, INFO и Info равнозначны. Принимаются и числовые
значения monolog (100 = debug, 200 = info, … 600 = emergency), но именами
понятнее.
Опечатка в значении роняет приложение
Значение, которого нет в списке, — это не «уровень по умолчанию», а исключение:
InvalidArgumentException: Level "warn" is not defined, use one of:
DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCYwarn — не синоним warning, verbose и trace не существуют. Сообщение
перечисляет допустимые значения, так что чинится сразу, но заметить это лучше не в
проде.
Пустое значение (LOG_LEVEL=) ошибкой не считается — оно равносильно незаданной
переменной, то есть выключает вывод.
LOG_OUTPUT — куда писать
| Значение | Что делает |
|---|---|
auto |
То же, что stdout — вывод захватит то, что запустило процесс |
stdout · stderr |
Стандартные потоки |
file |
Файл с посуточной ротацией |
syslog |
Системный журнал, тег winter |
null |
Ничего не писать — механизм работает вхолостую |
auto — правильный выбор в контейнере: записи попадают в docker logs и дальше в
систему сбора, а приложению не нужно знать ни про файлы, ни про их ротацию.
file без указанного LOG_FILE пишет в storage/logs/<канал>.log — то есть
storage/logs/http.log и storage/logs/sys.log.
null пригождается, чтобы заглушить один канал, не трогая остальные:
LOG_HTTP_OUTPUT=null уберёт поток запросов, оставив системные события.
LOG_COLOR — подсветка
| Значение | Когда красить |
|---|---|
auto |
Только если вывод идёт в терминал |
always |
Всегда — например, при просмотре через less -R |
never |
Никогда |
Цвет добавляется только к формату line. В json его не будет ни при каком
значении — иначе управляющие последовательности попали бы внутрь поля.
LOG_FORMAT — как выглядит запись
line читается человеком, json — машиной. В проде берут второй: поля контекста
становятся полями документа, и по ним можно искать и строить фильтры.
line:
[INFO ] -http- [4821] (MainOrderService): order placed {"id":128,"total":99}
json:
{"message":"order placed","context":{"id":128,"total":99},"level":200,
"level_name":"INFO","channel":"http","datetime":"2026-08-14T18:20:11+00:00"}Ротация
При LOG_OUTPUT=file файлы ротируются посуточно: к имени дописывается дата, а
LOG_FILE_MAX задаёт, сколько дней хранить — по умолчанию 30. Ротации по размеру
нет, поэтому один очень болтливый день даст один очень большой файл.
# разработка: всё в терминал, подробно и с цветом
LOG_LEVEL=debug
# прод: json в stdout, запросы тише системных событий
LOG_LEVEL=info
LOG_FORMAT=json
LOG_HTTP_LEVEL=warning
LOG_AUDIT_OUTPUT=fileНастройка отдельного канала
У каждой переменной есть версия для конкретного канала — с префиксом
LOG_{КАНАЛ}_. Значение ищется в три шага: сначала переменная канала, затем общая,
затем умолчание.
LOG_LEVEL=info # общий порог для всех каналов
LOG_HTTP_LEVEL=warning # но от запросов — только проблемы
LOG_SYS_OUTPUT=file # системные события ещё и в файлЗдесь канал http получит уровень warning и вывод stdout (из общего умолчания),
а sys — уровень info (из общей переменной) и вывод в файл.
Так же настраиваются свои каналы: объявленный audit читает LOG_AUDIT_LEVEL,
LOG_AUDIT_OUTPUT и остальные, а чего в них нет — берёт из общих.
Готовые наборы
# разработка: всё в терминал, подробно, с цветом
LOG_LEVEL=debug
# прод: машиночитаемо в stdout, запросы тише системных событий
LOG_LEVEL=info
LOG_FORMAT=json
LOG_HTTP_LEVEL=warning
# отдельный файл под аудит, остальное — как обычно
LOG_AUDIT_OUTPUT=file
LOG_AUDIT_FILE=/var/log/app/audit.log
LOG_AUDIT_FILE_MAX=90Дальше
- Winter Logger — полный reference — процессоры, форматы, каналы
- Обработка ошибок — какие уровни выбирает фреймворк
- Middleware — где обычно заполняют контекст запроса
- Процессы — готовый логгер в фоновых компонентах
- Конфигурация — остальные переменные окружения