Verified against Semitexa Ultimate 2026.10.20.0340
Logging
Inject LoggerInterface:
use Semitexa\Core\Attribute\InjectAsReadonly;
use Semitexa\Core\Log\LoggerInterface;
#[InjectAsReadonly]
protected LoggerInterface $logger;
$this->logger->warning('Payment provider slow', ['_channel' => 'billing', 'ms' => 2400, 'order' => 1042]);
Levels, lowest first: debug, info, notice, warning, error, critical. Code that has no container to inject from (a static helper, an early boot step) uses StaticLoggerBridge::warning('billing', 'Payment provider slow', [...]), which names the channel as its first argument and has error, warning, info and debug. Before the container exists, its error and warning go to PHP's error log as plain text, and its info and debug are dropped.
What a line looks like
One JSON object per line:
{"timestamp":"2026-10-21T01:12:09+00:00","level":"warning","message":"Payment provider slow","context":{"ms":2400,"order":1042},"channel":"billing","cid":17}
_channeland_request_idin the context become the top-levelchannelandrequest_id. Use a channel per area, so a reader can filter by it.cidis the coroutine id: it ties the lines of one request together for a moment, but ids are reused. With the developer tools (semitexa/dev) installed,processandblocklink the line to the Observatory's record of the request that wrote it.- Invalid UTF-8 in a message or context is replaced, not dropped. A context that cannot be encoded at all gives a line saying so, with the original message and level.
Each line is written when it is logged, by the coroutine that logged it. While one coroutine is writing, lines logged by others meanwhile wait in memory and go out with its next write, as one batch.
Settings
| Variable | Default | |
|---|---|---|
LOG_LEVEL |
info in dev, warning otherwise |
lines below it are not written |
LOG_SINK |
file |
file, stderr or both; any other value is said once per worker in the error log, and the file is used |
LOG_CHANNELS |
none | per-channel destinations, see Routing channels |
LOG_REMOTE |
none | udp://<IPv4 address>:<port>: a copy of every written line to a collector, see To another machine |
LOG_FILE |
var/log/app.log |
relative to the project root (a leading / is ignored); logs:app and the Observatory read it there |
LOG_MAX_BYTES |
33554432 (32 MiB) | when the file passes this it is renamed aside with a timestamp suffix; 0 lets it grow |
LOG_RETENTION_DAYS |
14 | rotated files older than this are removed when the next rotation happens; 0 keeps them |
Routing channels
LOG_CHANNELS sends a channel somewhere other than LOG_SINK: comma-separated channel=route pairs, where a route is file (LOG_FILE), stderr, both, off (dropped), or a file of the channel's own under var/log/ ending in .log.
LOG_CHANNELS=audit=var/log/audit.log,security=both,sql=off
- A channel it does not name, and a line with no channel, go where
LOG_SINKsays. Channel names match exactly, case included. - A channel's own file is rotated like
LOG_FILE(LOG_MAX_BYTES,LOG_RETENTION_DAYS). - A route that is none of these (a typo such as
of, a path outsidevar/log/, one with..,var/log/swoole.log, theSWOOLE_LOG_FILEthe server writes,var/log/error.logorLOG_FILEitself) is ignored with one warning in the error log per worker, and that channel followsLOG_SINK. - Each file and stderr get their lines in the order they were logged, whichever routes write them.
logs:appand the Observatory readLOG_FILEonly, not a channel's own file.- There is no syslog route. The dependable way to a remote collector is a log shipper reading stderr (
LOG_SINK=stderr) or the files; for a lighter, lossy copy, see To another machine.
To another machine: UDP
LOG_REMOTE=udp://10.0.0.5:5140
Every line the logger writes — to a file, to stderr, by LOG_SINK or by a channel's route — is also sent to that address as one UDP datagram holding the same JSON, with secret values in its context redacted (below). A channel routed off is not sent. Point it at a log collector that accepts JSON over UDP.
- UDP, because the send never waits: the datagram is handed to the kernel and the worker carries on. A syslog socket or a TCP connection to a slow collector would hold the worker, during a drain past its grace.
- The price is UDP's: a line the network or a busy collector drops is gone. Every datagram carries
"remote_seq", one up per line the worker was given, sent or not, so the collector sees a loss as a gap in it. The next line that gets through also carries"remote_dropped": N, the lines this worker knew it could not send (the socket refused them, was not open, or was backing off); what the network loses after the kernel took a datagram shows only as the gap. - An entry over 60 000 bytes goes as a stub with its time, level, channel, request id, the start of its message and
truncated. A datagram that large travels in many fragments, and many collectors cap UDP messages far lower: keep lines short. - An IPv4 address only (
udp://<address>:<port>): a host name would be looked up on the first send, and the coroutine runtime opens UDP sockets as IPv4. Anything else is ignored, with a note in the PHP error log. - An address that cannot be opened is reported once in the PHP error log and tried again a minute later.
- Plain UDP: every line crosses the network unencrypted, and anyone who can reach the collector can send it lines. Use it on a private network, or to a collector on the same host. In the copy that is sent, the value under a key that names a secret —
password,passwd,passphrase,pass,secret, a key ending intoken,authorization,cookie,api_key,credential,private_key,signature,dsn, at any depth of the context — reads[redacted], whatever it holds (a header list, a cookie map, a number).prompt_tokensandpassengerare not secrets and stay. Secrets inside text are redacted too, in the message and in any string of the context: aBearercredential, a URL'suser:password@, andname=valueorname: valuefor one of those names (Authorization: Basic …to the end of its line). An object in the context is read as it would be encoded, so a DTO's publicpasswordis redacted. The file and stderr keep the line as logged. A secret in free text with no such name beside it, a database error quoting its values included, is sent as written. - It is a copy: keep a file or stderr as well, for
logs:appand for what UDP loses.
In a container: stderr
Container runtimes collect what a process writes to stderr. With LOG_SINK=stderr every line goes there, one JSON line per entry, and no file is written: docker compose logs app, or your platform's log viewer, shows them, and a log shipper can parse them as JSON. LOG_SINK=both writes the file as well.
- Lines logged at the same moment are written in runs of whole lines of up to 4 KiB, and each run arrives whole. A single line longer than 4 KiB (a large context, a long stack trace) is written alone, and it can interleave with another worker's write.
- If the log collector stops reading, the write waits, and lines logged meanwhile wait in memory behind it. A collector that can fall behind should be set not to block (Docker:
log-opts: mode: non-blocking). - PHP errors and the framework's last-resort messages go to
var/log/error.logoutside dev, not to stderr. - In a console command, the lines appear in your terminal. Queue and scheduler lines are in the
workercontainer's logs. bin/semitexa logs:appand the Observatory (both fromsemitexa/dev) read the file. WithLOG_SINK=stderrthere is none:logs:appsays so, the Observatory shows no lines.
Reading the log
bin/semitexa logs:app # last 100 lines of the application log
bin/semitexa logs:app --level=ERROR --since=-1h
bin/semitexa logs:app --grep=billing --json
bin/semitexa logs:app --file=swoole # the server's own log
bin/semitexa logs:app --list # every log file and its size
--around=<timestamp> shows the lines around a moment, --context how many on each side (20); --lines is how many to show otherwise. The server's own messages (worker starts, crashes, reloads) go to SWOOLE_LOG_FILE, var/log/swoole.log, which Swoole rotates by date.
Watch out for
- A level below
LOG_LEVELcosts nothing but is lost. Production defaults towarning: aninfoline that explains a failure will not be there. SetLOG_LEVEL=infowhile you investigate. - Two users writing one file. Rotation renames the file and the next writer creates it. If the server runs as one user and your tests as another, the second may be locked out of the new file; give both write access to
var/log. - Secrets in context. The context is written as given. Do not log tokens, passwords or full request bodies.