Advanced

Logging

Winter logs through the winter-logger package — a layer over PSR-3. The logger arrives in a class by itself, already signed with that class’s name, context fields are added to every record of a request, and secrets are masked before anything is written.

Package winter-loggerChannels sys · httpStandard PSR-3

What logging is and why

Logging is recording what happens in an application so that it can be worked out afterwards.

The problem. Scattered error_log() and echo calls give a stream of lines with no levels, no tie to a request and no single destination. Working out from such a log what happened to a particular user at a particular moment is impossible: the lines of several concurrent requests are interleaved, and there is nothing to tell which belongs to which.

Worse is the other side — it is easy for too much to leak into a log. Write the request body or the headers into it and a password or a token ends up there.

The solution. One logger with levels, channels and context fields, which signs records with their request itself and strips sensitive values out of them.

Quick start

Declare a property of type LoggerInterface — and write:

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}

The name in brackets appeared by itself — the logger arrives already named after the class it was injected into. There is no need to write getLogger(self::class).

When logs are switched on

The arrangement is this: you can always write to the log, but whether anything is written depends on two conditions. A $this->logger->info(...) call is safe in any case and never causes an error — even when logging is not configured at all.

There are two conditions, and they are independent:

Condition If unmet
monolog/monolog is in the build The logger is replaced with a stub — records are silently discarded
LOG_LEVEL is set The machinery works, but the output goes nowhere

The monolog/monolog package

The kernel pulls in winter-logger — a layer over PSR-3 — but not monolog itself, which that layer runs on. That is deliberate: an application that does not need logs should not have to carry a dependency it will not use.

If the package is absent, LoggerFactory hands back a Psr\Log\NullLogger: every call goes through, nothing is written, no errors occur. Code with logging works the same in either build — the only difference is whether lines appear on the output.

bash
composer require monolog/monolog

As soon as the package is in the build, the machinery switches on by itself — nothing to configure and nothing to register.

Checking which one you have

LoggerFactory::getLogger('check')::class
// Flytachi\Winter\Logger\Logger  → writes
// Psr\Log\NullLogger              → monolog is not installed

The LOG_LEVEL variable

The second condition is the threshold. Until the variable is set, the channel’s output is null: the calls run but land nowhere.

.env
LOG_LEVEL=info

That is enough — everything else has sensible defaults: output to stdout, the line format, colour when a terminal is present.

So the minimum for working logs is: the monolog package in the build plus one line in .env. No configurers and no channel registration.

Silently missing logs are nearly always one of two things

Both conditions fail quietly: the application works, no errors appear, and no logs are visible. On a new environment, check LOG_LEVEL first and monolog’s presence second — that explains the overwhelming majority of “logging is broken” cases.

Getting hold of a logger

Three ways, from the preferred one to the fallbacks.

By injection — the main way

It works in any class the container builds: controllers, services, middleware, scheduler tasks.

php
#[Autowired] private LoggerInterface $logger;

It is already there — in processes and daemons

The Process and Daemon stereotypes provide the logger ready-made; there is nothing to declare:

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

By factory and facade — outside the container

For static helpers and code the container does not reach:

php
use Flytachi\Winter\Logger\{Log, LoggerFactory};

LoggerFactory::getLogger(UserService::class)->info('user created', ['id' => 42]);
LoggerFactory::getLogger(SyncTask::class, 'job')->debug('processing');  // explicit channel

Log::warning('retrying', ['attempt' => 3]);    // the facade, default channel

The Log facade covers the levels from debug to alert; it has no emergency — if you need that, take a logger through LoggerFactory.

Levels

The levels are the standard PSR-3 ones. The threshold is set by the LOG_LEVEL variable: records below it are discarded.

php
$logger->debug('...');      // details for debugging
$logger->info('...');       // a normal event
$logger->notice('...');     // unusual, but not a problem
$logger->warning('...');    // a problem that was dealt with
$logger->error('...');      // an operation failed
$logger->critical('...');   // a component is broken
$logger->alert('...');      // intervention needed right now
$logger->emergency('...');  // the application is unusable

The framework keeps to the same scale when it writes on your behalf: an expected client error (404, 422) goes out as a warning, a failure on our side as an error. In detail on the Error handling page.

Channels

A channel is a destination with settings of its own: a level, a format, a place to write. The kernel brings two up.

Channel What it writes
http Everything happening inside request handling
sys Everything else: application startup, processes, daemons, the scheduler, the console

The channel is picked automatically by where the code runs: a worker serving requests writes to http, everything else to sys. Naming it by hand is usually unnecessary.

The split exists so that the request stream does not drown out system events: they can be sent to different files and kept at different levels.

.env
LOG_LEVEL=info

LOG_HTTP_LEVEL=warning        # from requests — problems only
LOG_SYS_LEVEL=debug           # system events in detail
LOG_SYS_OUTPUT=file           # and into a separate file

Channels of your own

If you need a separate stream — for auditing, say — declare a configurer class. There is nothing to register anywhere; the scanner finds it:

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');
  }
}

From there the channel is requested by name:

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

A channel of your own reads variables with its own prefix — LOG_AUDIT_LEVEL, LOG_AUDIT_OUTPUT and so on — and takes whatever is not there from the common LOG_* ones.

A level for your own exceptions

The framework logs unhandled exceptions itself. That is error by default, but an exception can declare its own level — for that it implements ExceptionLogLevel:

php
use FlytachiWinterBaseExceptionExceptionLogLevel;
use PsrLogLogLevel;

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

Why this matters: a client error and a server failure belong in different streams. A request for an order that does not exist is a warning, ordinary system behaviour nobody should be woken up for. A database that fell over is an error.

The built-in exceptions work the same way. ResponseException picks its level by the response code: 5xx is error, everything else is warning. EntityException, thrown by findByIdOrThrow() and its kin, is always warning.

The record gets its context automatically:

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

More on the handling itself on the Error handling page.

Request context

Fields set once land in every subsequent record of that request. That is how a request identifier and a user are added to the log without dragging them through every layer as arguments.

php
// in a middleware, once per request
$ctx = LoggerFactory::contextStorage();
$ctx->set('request_id', bin2hex(random_bytes(8)));
$ctx->set('user_id', $this->auth->user()->id);

From then on every record — from a controller, from a service, from a repository — carries both fields.

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

Fields do not mix between requests

Under Swoole a worker serves several requests at once, so the context is stored separately for each coroutine. One user’s identifier cannot end up in another’s record — that is tested, not assumed.

Masking secrets

Values under sensitive keys are replaced with *** before anything is written — nested arrays included:

php
$logger->info('login attempt', [
  'username' => 'alice',
  'password' => 'hunter2',              // → ***
  'meta'     => ['token' => 'eyJhb...'] // nested too → ***
]);
text
{"username":"alice","password":"***","meta":{"token":"***"}}

By default password, secret, token, authorization, cookie, credit_card, cvv and the like are masked; the comparison is case-insensitive. The full list and how to add keys of your own are in the package docs.

Masking works on keys, not on values

A token put into the log under a neutral name — ['value' => $jwt] — will not be masked, and neither will a secret glued into the message text: $logger->info("token: {$jwt}").

Put sensitive things into the context under a meaningful key, not into the message string.

Configuring through .env

Six variables. All have defaults except LOG_LEVEL — without it logging is off.

Variable Allowed values Default
LOG_LEVEL debug · info · notice · warning · error · critical · alert · emergency unset — output disabled
LOG_OUTPUT auto · stdout · stderr · file · syslog · null auto
LOG_FORMAT line · json line
LOG_COLOR auto · always · never auto
LOG_FILE A path to a file storage/logs/<channel>.log
LOG_FILE_MAX An integer — how many days to keep 30

LOG_LEVEL — the threshold

The value sets the lower bound: records at that level and everything more serious are emitted; anything below is discarded and costs nothing.

text
debug ─ info ─ notice ─ warning ─ error ─ critical ─ alert ─ emergency
↑ more detail                                       more serious ↑

LOG_LEVEL=info       everything except debug
LOG_LEVEL=warning    warning and above only — the usual production choice
LOG_LEVEL=error      failures only

Case does not matter: info, INFO and Info are equivalent. Monolog’s numeric values are accepted too (100 = debug, 200 = info, … 600 = emergency), but the names read better.

A typo in the value takes the application down

A value that is not on the list is not “the default level” but an exception:

InvalidArgumentException: Level "warn" is not defined, use one of:
DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY

warn is not a synonym for warning, and verbose and trace do not exist. The message lists the allowed values, so it is fixed at once — but it is better noticed somewhere other than production.

An empty value (LOG_LEVEL=) is not an error — it is equivalent to an unset variable, that is, it disables output.

LOG_OUTPUT — where to write

Value What it does
auto The same as stdout — whatever started the process captures the output
stdout · stderr The standard streams
file A file with daily rotation
syslog The system journal, tagged winter
null Write nothing — the machinery runs idle

auto is the right choice in a container: records land in docker logs and from there in your collection system, and the application need know nothing about files or their rotation.

file without a LOG_FILE writes to storage/logs/<channel>.log — that is, storage/logs/http.log and storage/logs/sys.log.

null is useful for silencing one channel without touching the others: LOG_HTTP_OUTPUT=null removes the request stream and leaves the system events.

LOG_COLOR — highlighting

Value When to colour
auto Only when the output goes to a terminal
always Always — for viewing through less -R, for instance
never Never

Colour is added only to the line format. There will be none in json under any value — otherwise the escape sequences would end up inside a field.

LOG_FORMAT — what a record looks like

line is read by a human, json by a machine. Production takes the second: the context fields become document fields you can search and filter on.

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"}

Rotation

With LOG_OUTPUT=file the files rotate daily: the date is appended to the name, and LOG_FILE_MAX sets how many days to keep — 30 by default. There is no rotation by size, so one very talkative day gives one very large file.

Configuring one channel

Every variable has a per-channel version, prefixed LOG_{CHANNEL}_. The value is looked up in three steps: the channel’s variable first, then the common one, then the default.

.env
LOG_LEVEL=info            # the common threshold for every channel
LOG_HTTP_LEVEL=warning    # but from requests — problems only
LOG_SYS_OUTPUT=file       # system events into a file as well

Here the http channel gets level warning and stdout output (from the common default), while sys gets level info (from the common variable) and file output.

Channels of your own are configured the same way: a declared audit reads LOG_AUDIT_LEVEL, LOG_AUDIT_OUTPUT and the rest, and takes whatever is missing from the common ones.

Ready-made sets

.env
# development: everything to the terminal, in detail, with colour
LOG_LEVEL=debug

# production: machine-readable to stdout, requests quieter than system events
LOG_LEVEL=info
LOG_FORMAT=json
LOG_HTTP_LEVEL=warning

# a separate file for auditing, everything else as usual
LOG_AUDIT_OUTPUT=file
LOG_AUDIT_FILE=/var/log/app/audit.log
LOG_AUDIT_FILE_MAX=90

Next