Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,30 @@ HTTP_CLIENT_MAX_DURATION=30
HTTP_CLIENT_LOG_LEVEL=error
###< Http Client ###

###> Logging ###

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables should be documented in README.md under Configuration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Readme updated

# Log output destination. php://stderr suits container deployments (Docker
# captures it). Bare-metal nginx + php-fpm checkouts may set a file path, e.g.
# LOG_PATH=%kernel.logs_dir%/prod.log (the operator owns rotation/permissions).
# Image/container deployments must keep php://stderr.
LOG_PATH=php://stderr
# Global log level for the application domain channels
# (debug, info, notice, warning, error, critical). info reproduces prior output.
LOG_LEVEL=info
# Per-channel override; empty or unset inherits LOG_LEVEL.
LOG_LEVEL_AUTH=
# Per-channel override; empty or unset inherits LOG_LEVEL.
LOG_LEVEL_SCREEN=
# Per-channel override; empty or unset inherits LOG_LEVEL.
LOG_LEVEL_MEDIA=
# Per-channel override; empty or unset inherits LOG_LEVEL.
LOG_LEVEL_FEED=
# Per-channel override; empty or unset inherits LOG_LEVEL.
LOG_LEVEL_INTERACTIVE=
# Threshold for Symfony's built-in cache channel (cache-adapter/Redis failures);
# not an application channel. Empty or unset inherits LOG_LEVEL.
LOG_LEVEL_CACHE=
###< Logging ###

###> App ###
# Default date format for API responses (ISO 8601 with milliseconds).
DEFAULT_DATE_FORMAT='Y-m-d\TH:i:s.v\Z'
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.

- Enabled PHPStan's `reportIgnoresWithoutComments`: inline `@phpstan-ignore` annotations must
carry a comment explaining the suppression.
- Added structured, channel-split application logging (ADR 011): per-domain Monolog channels
(`auth`, `screen`, `media`, `feed`, `interactive`, `cache`) with per-channel prod handlers
thresholded by `LOG_LEVEL_<CHANNEL>` (falling back to a global `LOG_LEVEL`), a configurable
`LOG_PATH` output destination (default `php://stderr`), request/identity/trace-context
processors, request-id propagation via `X-Request-Id`, and an auth-event logging subscriber.
- Renamed the outbound-HTTP-client log channel `app_http` → `outbound_http` and silenced
Symfony's redundant native `http_client` channel logging (a `NullLogger` decorates it), so
`LoggingHttpClient` is the single source of outbound-HTTP logs (no duplicate request logging).

## [3.0.0-rc4] - 2026-06-04

Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,39 @@ MEDIA_MAX_UPLOAD_SIZE_MB=200
Changes are picked up on the next request once PHP-FPM workers see the new env value (in production, restart the
php-fpm container or reload the workers). The admin UI re-fetches `/config/admin` on the next page load.

### Logging

Structured JSON logging on per-domain channels (see [ADR 011](docs/adr/011-structured-logging.md) and
[docs/logging.md](docs/logging.md)). Each domain channel has a production stream handler whose threshold is
`LOG_LEVEL_<CHANNEL>`, falling back to the global `LOG_LEVEL` when the per-channel value is empty or unset.

```dotenv
###> Logging ###
LOG_PATH=php://stderr
LOG_LEVEL=info
LOG_LEVEL_AUTH=
LOG_LEVEL_SCREEN=
LOG_LEVEL_MEDIA=
LOG_LEVEL_FEED=
LOG_LEVEL_INTERACTIVE=
LOG_LEVEL_CACHE=
###< Logging ###
```

- LOG_PATH: Destination for the production log handlers. Defaults to `php://stderr`, which suits container
deployments (the runtime captures stderr). Bare-metal nginx + php-fpm deployments may point it at a file
(e.g. `%kernel.logs_dir%/prod.log`); the operator then owns log rotation and the php-fpm user's write permission
to the directory. Image/container deployments must keep `php://stderr`.

**Default**: `php://stderr`.
- LOG_LEVEL: Global log level for the application domain channels (`debug`, `info`, `notice`, `warning`, `error`,
`critical`). `info` reproduces the previous output.

**Default**: `info`.
- LOG_LEVEL_AUTH, LOG_LEVEL_SCREEN, LOG_LEVEL_MEDIA, LOG_LEVEL_FEED, LOG_LEVEL_INTERACTIVE, LOG_LEVEL_CACHE:
Per-channel threshold overrides. Empty or unset inherits `LOG_LEVEL`. Set one to raise or lower a single channel
(e.g. `LOG_LEVEL_FEED=warning`) without affecting the others. An invalid level fails fast at boot.

### Admin configuration

Will be exposed through the `/config/admin` route.
Expand Down
11 changes: 10 additions & 1 deletion config/packages/monolog.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
monolog:
channels: ['app_http']
channels:
[
'outbound_http',
'auth',
'screen',
'media',
'feed',
'interactive',
'cache',
]
53 changes: 49 additions & 4 deletions config/packages/prod/monolog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,59 @@ monolog:
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
path: '%env(resolve:LOG_PATH)%'
level: debug
formatter: monolog.formatter.json
app_http:
outbound_http:
type: stream
path: php://stderr
path: '%env(resolve:LOG_PATH)%'
level: info
channels: ['app_http']
channels: ['outbound_http']
formatter: monolog.formatter.json
# One always-on handler per domain channel. A Monolog handler carries a
# single level, so per-channel thresholds require per-channel handlers.
# Each level falls back to the global LOG_LEVEL (app.log_level) when its
# LOG_LEVEL_<CHANNEL> override is empty or unset.
auth:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_AUTH)%'
channels: ['auth']
formatter: monolog.formatter.json
screen:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_SCREEN)%'
channels: ['screen']
formatter: monolog.formatter.json
media:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_MEDIA)%'
channels: ['media']
formatter: monolog.formatter.json
feed:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_FEED)%'
channels: ['feed']
formatter: monolog.formatter.json
interactive:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_INTERACTIVE)%'
channels: ['interactive']
formatter: monolog.formatter.json
# Symfony's built-in cache channel — NOT an application channel. The cache
# adapters (the Redis pools) log backend failures here (failed save/fetch,
# connection drops) at `warning`; this dedicated handler surfaces them, since
# the `fingers_crossed` gate (action_level: error) would otherwise buffer and
# drop a standalone warning. No application code writes to this channel.
cache:
type: stream
path: '%env(resolve:LOG_PATH)%'
level: '%env(default:app.log_level:LOG_LEVEL_CACHE)%'
channels: ['cache']
formatter: monolog.formatter.json
console:
type: console
Expand Down
35 changes: 34 additions & 1 deletion config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Global fallback log level for the per-domain Monolog channels. Each
# LOG_LEVEL_<CHANNEL> override inherits this when empty/unset (ADR 011).
app.log_level: '%env(LOG_LEVEL)%'
# Dump the compiled service container into a single file with inlined
# factories instead of thousands of small files. Recommended for prod and
# required to get the most out of OPcache class preloading.
Expand Down Expand Up @@ -57,11 +60,41 @@ services:
decorates: http_client
arguments:
$client: '@.inner'
$logger: '@monolog.logger.app_http'
$logger: '@monolog.logger.outbound_http'
$logLevel: '%env(string:HTTP_CLIENT_LOG_LEVEL)%'

# Silence Symfony's native http_client logging at the source: it is
# request-only and redundant with LoggingHttpClient (outbound_http channel),
# which is the single, OTel-shaped source of outbound-HTTP logs. A NullLogger
# replaces the channel logger the framework injects into the native client.
app.null_http_client_logger:
class: Psr\Log\NullLogger
decorates: monolog.logger.http_client

#### App Scope below ###

# Structured logging: enrich every record with request/identity/trace context.
App\Logger\Processor\RequestContextProcessor:
tags: [{ name: monolog.processor }]

App\Logger\Processor\TraceContextProcessor:
tags: [{ name: monolog.processor }]

# Logs security outcomes on the `auth` channel (logging only).
#
# Channel loggers are bound explicitly rather than via MonologBundle's channel
# autowiring. The constructor parameter is named for its channel
# (`$authLogger`, `$feedLogger`, …) so the call site reads clearly, but the
# explicit `@monolog.logger.<channel>` binding is the authoritative wiring:
# it fails loudly at container build if the channel id is wrong or the
# parameter is renamed, whereas the autowiring convention would silently fall
# back to the default `app` channel — a misroute neither container
# compilation nor PHPStan can catch. The parameter name and the binding must
# agree; the container enforces it.
App\Logger\EventSubscriber\AuthLoggingSubscriber:
arguments:
$authLogger: '@monolog.logger.auth'

Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler'
Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler'

Expand Down
90 changes: 90 additions & 0 deletions docs/adr/011-structured-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ADR 011 - Structured application logging

Date: 02-06-2026

## Status

Accepted

## Context

Logging in `display-api-service` was a single Monolog channel (`app_http`) used only by
`LoggingHttpClient`, with `fingers_crossed` (action level `error`) writing JSON to
`php://stderr` in production. Log records carried no request, identity, or tenant context,
and ~14 catch sites swallowed exceptions with no log and no rethrow. As a result, several
documented production failures were invisible end-to-end: kiosk black-screens, missing
thumbnails (PR #374), empty interactive booking results, and OIDC/JWT/refresh outcomes.

Operators of single-server deployments frequently cannot run or monitor MariaDB directly
(no shell access to the database, no metrics scraper). The application log is therefore the
only place a database **connection** failure (too-many-connections, connection refused,
server gone away) can surface.

## Decision

1. **Channel splitting.** Declare per-domain application channels (`auth`, `screen`, `media`,
`feed`, `interactive`, and `database`) alongside the outbound-HTTP channel `outbound_http`
(`LoggingHttpClient`, renamed from `app_http`). Symfony's built-in `cache` channel is
additionally given a dedicated handler — not for application logging (no application code
writes to it), but so cache-adapter (Redis) backend failures surface instead of being
buffered and dropped by the `fingers_crossed` error gate. Symfony's native `http_client`
channel logging is silenced (see Consequences) so `outbound_http` is the single,
OTel-shaped source of outbound-HTTP logs.

2. **Context processors.** Every log record is enriched by Monolog processors with request
context (request id, route, method), identity (user or screen id, tenant key), W3C trace
context when present, GDPR-safe client address (truncated), and structured exception
serialization under a single `exception` context key. Field names follow OpenTelemetry
semantic conventions as strictly as the framework allows: `http.route` is the matched
route's **path template** (e.g. `/v2/screens/{id}`), emitted only when a route matched
and never the concrete id-bearing URL (which is recorded separately as `url.path`);
`client.address` is truncated; identity is `user.id` XOR `screen.id` plus `tenant.key`.

3. **No silent failures.** Catch blocks must log the exception, rethrow it (optionally
wrapped), or be explicitly annotated as intentionally silent. This is enforced in CI by
project-local PHPStan rules (`logging.silentCatch`, `logging.interpolatedLogMessage`,
`logging.exceptionContextKey`).

4. **Database connection-error logging.** A DBAL driver middleware logs connection
establishment failures on the `database` channel, classified by the raw driver error
code, so the failure is visible regardless of whether application code swallows the
exception and regardless of which SAPI (web, CLI, Messenger) opened the connection.

5. **Output destination is configurable, defaulting to `php://stderr`.** Handlers write to
a `LOG_PATH` env var (default `php://stderr`). The default suits the Docker image
deployment, where the runtime captures stderr. Operators who run the repo directly under
nginx + php-fpm (no container) can set `LOG_PATH` to a file, because php-fpm does not
capture worker stderr cleanly. Per-channel thresholds are set with `LOG_LEVEL_<CHANNEL>`
env vars that fall back to a global `LOG_LEVEL`. Shipping logs onward to an aggregator
(OTel Collector / Loki / Grafana) remains a separate concern in `os2display-docker-server`
and is not decided here. Records stay JSON regardless of destination.

6. **OpenTelemetry-first.** All logging follows OpenTelemetry semantic conventions and
naming as closely as the framework allows — attribute names, value shapes, and severity
levels. When Symfony/Monolog cannot express a convention exactly, the closest compliant
form is chosen over a bespoke field (e.g. `http.route` carries the route path template,
not the Symfony route name; attributes that don't apply are omitted rather than set to a
placeholder). New log fields must reuse an existing OTel attribute where one fits before
inventing a name. This keeps the logs forward-compatible with an OTel collector, should
one be introduced.

## Consequences

- Contributors must attach context via the PSR-3 context array (not string interpolation)
and must never swallow exceptions silently; the PHPStan gate fails the build otherwise.
The conventions are documented in `docs/logging.md`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is docs/logging.md ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in #476

- Operators gain a failure signal for database connection problems without database access,
at the cost of it being reactive (per-failure events, not "approaching the limit" trends).
- The connection-error middleware covers connection **establishment** only; mid-query drops
(`2006`/`2013`) are out of its current scope and would require also wrapping the
connection's execution path.
- Adopting OpenTelemetry semantic conventions for field names keeps logs forward-compatible
with an OTel collector, should one be introduced later.
- Symfony's native `http_client` logging is silenced at the source — a `NullLogger` decorates
the `monolog.logger.http_client` service the framework injects into the native client (it is
request-only and redundant). `LoggingHttpClient` (`outbound_http` channel) is therefore the
single, OTel-shaped source of outbound-HTTP logs — no duplicate request logging.
- `LOG_PATH` must stay `php://stderr` in the image deployment — pointing it at a
container-internal file breaks `task logs` and the planned filelog collector. Bare-metal
operators choosing a file own its rotation (e.g. logrotate) and the php-fpm user's write
permission to the log directory.
Loading
Loading