Продвинутое

Логирование

Winter логирует через пакет winter-logger — слой поверх PSR-3. Логгер приходит в класс сам и уже подписан его именем, поля контекста добавляются к каждой записи запроса, а секреты маскируются до записи.

Пакет winter-loggerКаналы sys · httpСтандарт PSR-3

Что такое логирование и зачем

Логирование — запись того, что происходит в приложении, чтобы это можно было разобрать потом.

Проблема. Разрозненные error_log() и echo дают поток строк без уровней, без привязки к запросу и без единого места назначения. Разобрать по такому логу, что случилось с конкретным пользователем в конкретный момент, невозможно: строки нескольких одновременных запросов перемешаны, и понять, какая к какой относится, не по чему.

Хуже другое — в лог легко утекает лишнее. Достаточно записать в него тело запроса или заголовки, и там окажется пароль или токен.

Решение. Единый логгер с уровнями, каналами и полями контекста, который сам подписывает записи запросом и вычищает из них чувствительные значения.

Быстрый старт

Объявите свойство типа LoggerInterface — и пишите:

php
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,
      ]);
  }
}
text
[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: все вызовы проходят, ничего не записывается, ошибок нет. Код с логированием работает в обеих сборках одинаково — меняется только то, появляются ли строки на выходе.

bash
composer require monolog/monolog

Как только пакет оказывается в сборке, механизм включается сам — ни настраивать, ни регистрировать ничего не нужно.

Проверить, что именно у вас

LoggerFactory::getLogger('check')::class
// Flytachi\Winter\Logger\Logger  → пишет
// Psr\Log\NullLogger              → monolog не установлен

Переменная LOG_LEVEL

Второе условие — порог. Пока переменная не задана, канал настроен на вывод null: вызовы отрабатывают, но никуда не попадают.

.env
LOG_LEVEL=info

Этого достаточно — остальное имеет разумные значения по умолчанию: вывод в stdout, формат line, цвет по наличию терминала.

Итого минимум для рабочих логов: пакет monolog в сборке плюс одна строка в .env. Ни конфигураторов, ни регистрации каналов.

Молчаливое отсутствие логов — почти всегда одно из двух

Оба условия отказывают тихо: приложение работает, ошибок нет, а логов не видно. На новом окружении проверяйте сначала LOG_LEVEL, потом наличие monolog — этим объясняется подавляющее большинство случаев «логирование сломалось».

Как получить логгер

Три способа, от предпочтительного к запасным.

Внедрением — основной

Работает в любом классе, который строит контейнер: контроллеры, сервисы, middleware, задачи планировщика.

php
#[Autowired] private LoggerInterface $logger;

Он уже есть — в процессах и демонах

Стереотипы Process и Daemon дают логгер готовым, объявлять его не нужно:

php
public function run(): void
{
  $this->logger->info('worker started');
}

Фабрикой и фасадом — вне контейнера

Для статических хелперов и кода, куда контейнер не достаёт:

php
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: записи ниже него отбрасываются.

php
$logger->debug('...');      // подробности для отладки
$logger->info('...');       // штатное событие
$logger->notice('...');     // необычное, но не проблема
$logger->warning('...');    // проблема, с которой справились
$logger->error('...');      // операция не удалась
$logger->critical('...');   // сломан компонент
$logger->alert('...');      // нужно вмешательство прямо сейчас
$logger->emergency('...');  // приложение неработоспособно

Фреймворк придерживается той же шкалы, когда пишет за вас: ожидаемая ошибка клиента (404, 422) идёт как warning, сбой на нашей стороне — как error. Подробнее — на странице Обработка ошибок.

Каналы

Канал — это направление записи со своими настройками: уровнем, форматом, местом назначения. Ядро поднимает два.

Канал Что пишет
http Всё, что происходит внутри обработки запроса
sys Всё остальное: запуск приложения, процессы, демоны, планировщик, консоль

Канал выбирается автоматически по тому, где выполняется код: воркер, обслуживающий запросы, пишет в http, всё прочее — в sys. Указывать его вручную обычно не нужно.

Разделение нужно, чтобы поток запросов не забивал системные события: их можно развести по разным файлам и держать на разных уровнях.

.env
LOG_LEVEL=info

LOG_HTTP_LEVEL=warning        # от запросов — только проблемы
LOG_SYS_LEVEL=debug           # системные события подробно
LOG_SYS_OUTPUT=file           # и в отдельный файл

Свои каналы

Если нужен отдельный поток — например, для аудита, — объявите класс-конфигуратор. Регистрировать его нигде не надо, сканер найдёт сам:

main/LoggingConfig.php
<?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');
  }
}

Дальше канал запрашивается по имени:

php
LoggerFactory::getLogger(PaymentService::class, 'audit')
  ->info('refund issued', ['order' => $id, 'by' => $operator]);

Свой канал читает переменные с собственным префиксом — LOG_AUDIT_LEVEL, LOG_AUDIT_OUTPUT и так далее, — а чего в них нет, берёт из общих LOG_*.

Уровень для своих исключений

Фреймворк логирует необработанные исключения сам. По умолчанию это error, но исключение может объявить свой уровень — для этого оно реализует ExceptionLogLevel:

php
use FlytachiWinterBaseExceptionExceptionLogLevel;
use PsrLogLogLevel;

class OrderNotFound extends RuntimeException implements ExceptionLogLevel
{
  public function getLogLevel(): string
  {
      return LogLevel::WARNING;
  }
}

Зачем это нужно: ошибка клиента и сбой сервера должны лежать в разных потоках. Запрошенный несуществующий заказ — это warning, обычное поведение системы, по которому никого не будят ночью. Отвалившаяся база — error.

Так устроены и встроенные исключения. ResponseException выбирает уровень по коду ответа: 5xxerror, всё остальное — warning. EntityException, который бросают findByIdOrThrow() и подобные, — всегда warning.

Запись при этом получает контекст автоматически:

text
[WARNING] -http- [4821] (Router): Order not found
        {"code":404,"exception":"MainOrderNotFound","file":"/app/main/OrderService.php:42"}

Подробнее про обработку — на странице Обработка ошибок.

Контекст запроса

Поля, заданные один раз, попадают в каждую последующую запись этого запроса. Так в лог добавляют идентификатор запроса и пользователя, не протаскивая их аргументами через все слои.

php
// в middleware, один раз на запрос
$ctx = LoggerFactory::contextStorage();
$ctx->set('request_id', bin2hex(random_bytes(8)));
$ctx->set('user_id', $this->auth->user()->id);

Дальше любая запись — из контроллера, из сервиса, из репозитория — несёт оба поля.

text
[INFO ] -http- [4821] (MainOrderService): order placed
      {"id":128,"request_id":"a3f9...","user_id":42}

Поля не смешиваются между запросами

Под Swoole воркер обслуживает несколько запросов одновременно, поэтому контекст хранится отдельно для каждой корутины. Идентификатор одного пользователя не может попасть в запись другого — это проверено, а не подразумевается.

Маскирование секретов

Значения по чувствительным ключам заменяются на *** до записи — включая вложенные массивы:

php
$logger->info('login attempt', [
  'username' => 'alice',
  'password' => 'hunter2',              // → ***
  'meta'     => ['token' => 'eyJhb...'] // вложенное тоже → ***
]);
text
{"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 — порог

Значение задаёт нижнюю границу: записи этого уровня и всех, что серьёзнее, выводятся; всё, что ниже, отбрасывается и ничего не стоит.

text
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, EMERGENCY

warn — не синоним 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 — машиной. В проде берут второй: поля контекста становятся полями документа, и по ним можно искать и строить фильтры.

text
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. Ротации по размеру нет, поэтому один очень болтливый день даст один очень большой файл.

.env
# разработка: всё в терминал, подробно и с цветом
LOG_LEVEL=debug

# прод: json в stdout, запросы тише системных событий
LOG_LEVEL=info
LOG_FORMAT=json
LOG_HTTP_LEVEL=warning
LOG_AUDIT_OUTPUT=file

Настройка отдельного канала

У каждой переменной есть версия для конкретного канала — с префиксом LOG_{КАНАЛ}_. Значение ищется в три шага: сначала переменная канала, затем общая, затем умолчание.

.env
LOG_LEVEL=info            # общий порог для всех каналов
LOG_HTTP_LEVEL=warning    # но от запросов — только проблемы
LOG_SYS_OUTPUT=file       # системные события ещё и в файл

Здесь канал http получит уровень warning и вывод stdout (из общего умолчания), а sys — уровень info (из общей переменной) и вывод в файл.

Так же настраиваются свои каналы: объявленный audit читает LOG_AUDIT_LEVEL, LOG_AUDIT_OUTPUT и остальные, а чего в них нет — берёт из общих.

Готовые наборы

.env
# разработка: всё в терминал, подробно, с цветом
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

Дальше