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.
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:
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}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.
composer require monolog/monologAs 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 installedThe 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.
LOG_LEVEL=infoThat 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.
#[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:
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:
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 channelThe 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.
$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 unusableThe 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.
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 fileChannels 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:
<?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:
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:
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:
[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.
// 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.
[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:
$logger->info('login attempt', [
'username' => 'alice',
'password' => 'hunter2', // → ***
'meta' => ['token' => 'eyJhb...'] // nested too → ***
]);{"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.
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 onlyCase 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, EMERGENCYwarn 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.
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.
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 wellHere 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
# 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=90Next
- Winter Logger — the full reference — processors, formats, channels
- Error handling — which levels the framework picks
- Middleware — where the request context is usually filled in
- Processes — the ready-made logger in background components
- Configuration — the remaining environment variables