|
| 1 | +# Logging |
| 2 | + |
| 3 | +This service emits **structured JSON logs** to `php://stderr`. Every record is enriched |
| 4 | +with request, identity and trace context, and serialised so it can be ingested by a log |
| 5 | +aggregator later without changes here. The decisions behind this are recorded in |
| 6 | +[ADR 011](adr/011-structured-logging.md). |
| 7 | + |
| 8 | +The conventions below are **enforced in CI** by project-local PHPStan rules |
| 9 | +(`logging.silentCatch`, `logging.interpolatedLogMessage`, `logging.exceptionContextKey`). |
| 10 | +A build fails if you break them. |
| 11 | + |
| 12 | +## Channels |
| 13 | + |
| 14 | +Inject a channel-specific logger instead of the generic one, so records can be routed and |
| 15 | +filtered per domain. Bind the channel in `config/services.yaml`: |
| 16 | + |
| 17 | +```yaml |
| 18 | +App\Service\FeedService: |
| 19 | + arguments: |
| 20 | + $logger: '@monolog.logger.feed' |
| 21 | +``` |
| 22 | +
|
| 23 | +| Channel | Use for | |
| 24 | +|---|---| |
| 25 | +| `outbound_http` | Outbound HTTP client calls (`LoggingHttpClient`). Named to stay clear of Symfony's built-in `http_client` channel, whose native (request-only) logging is silenced with a `NullLogger`. | |
| 26 | +| `auth` | Authentication and authorization outcomes (login, logout, JWT/OIDC). | |
| 27 | +| `screen` | Screen-client binding, screen authentication, screen status. | |
| 28 | +| `media` | Media upload, thumbnailing, storage. | |
| 29 | +| `feed` | Feed fetching/parsing (`FeedService`, feed types). | |
| 30 | +| `interactive` | Interactive slides (booking, instant book). | |
| 31 | +| `cache` | **Not an application channel** — Symfony's built-in cache-adapter channel. Given a dedicated handler so cache-adapter (Redis) backend failures surface instead of being dropped by the `fingers_crossed` gate. No application code writes to it. | |
| 32 | +| `database` | Database connection failures (see below). | |
| 33 | + |
| 34 | +## Level guidance |
| 35 | + |
| 36 | +| Level | When | |
| 37 | +|---|---| |
| 38 | +| `debug` | Developer detail; verbose, off in production action buffering. | |
| 39 | +| `info` | Normal, noteworthy events (auth success, batch completed). | |
| 40 | +| `notice` | Normal but significant (config fallback applied). | |
| 41 | +| `warning` | Something recoverable went wrong (auth failure, flapping feed, malformed payload). | |
| 42 | +| `error` | An operation failed and a user/screen is affected. | |
| 43 | +| `critical` | A subsystem is down (database connection pressure/unreachable). | |
| 44 | + |
| 45 | +## Message and context rules |
| 46 | + |
| 47 | +1. **The message is a static string.** Never interpolate variables into it. |
| 48 | + |
| 49 | + ```php |
| 50 | + // ❌ flagged by logging.interpolatedLogMessage |
| 51 | + $logger->warning("Feed {$source->getId()} failed"); |
| 52 | +
|
| 53 | + // ✅ static message, data in context |
| 54 | + $logger->warning('Feed fetch failed', ['feed_source_id' => (string) $source->getId()]); |
| 55 | + ``` |
| 56 | + |
| 57 | + PSR-3 `{placeholder}` braces resolved from the context array are fine. |
| 58 | + |
| 59 | +2. **Exceptions go under the `exception` context key — never any other key, never |
| 60 | + interpolated.** |
| 61 | + |
| 62 | + ```php |
| 63 | + // ❌ flagged by logging.exceptionContextKey |
| 64 | + $logger->error('Booking failed', ['error' => $e]); |
| 65 | +
|
| 66 | + // ✅ |
| 67 | + $logger->error('Booking failed', ['exception' => $e]); |
| 68 | + ``` |
| 69 | + |
| 70 | + `ExceptionContextProcessor` turns the `\Throwable` into a structured array |
| 71 | + (`class`, `message`, `code`, `file`, `line`, bounded `previous` chain). No raw |
| 72 | + multi-line stack-trace string is emitted at info level. |
| 73 | + |
| 74 | +3. **Never swallow an exception silently.** A `catch` must log it (under `exception`), |
| 75 | + rethrow it (optionally wrapped), or be explicitly annotated when the silence is |
| 76 | + intentional: |
| 77 | + |
| 78 | + ```php |
| 79 | + try { |
| 80 | + $optional = $this->parse($payload); |
| 81 | + } catch (\JsonException $e) { |
| 82 | + // @phpstan-ignore logging.silentCatch (optional metadata; absence is expected) |
| 83 | + } |
| 84 | + ``` |
| 85 | + |
| 86 | + This is enforced by `logging.silentCatch`. |
| 87 | + |
| 88 | +## Field names (OpenTelemetry semantic conventions) |
| 89 | + |
| 90 | +Field names follow [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) |
| 91 | +as strictly as the framework allows, so the logs stay forward-compatible with an OTel |
| 92 | +collector should one be introduced: |
| 93 | + |
| 94 | +| Field | Meaning | |
| 95 | +|---|---| |
| 96 | +| `request_id` | Per-request id (inbound `X-Request-Id` or minted). | |
| 97 | +| `http.request.method` | HTTP method. | |
| 98 | +| `http.route` | Matched route's **path template**, e.g. `/v2/screens/{id}` — id-free and low-cardinality, with the API Platform `.{_format}` suffix stripped. **Set only when a route matched** (per OTel guidance), and **never** the concrete id-bearing URL. | |
| 99 | +| `url.path` | The concrete request path, **including** ids (e.g. `/v2/screens/01HXYZ…`). This is the field that carries the real path; `http.route` is its templated, id-free counterpart. | |
| 100 | +| `client.address` | **Truncated** client address (see redaction). | |
| 101 | +| `enduser.id` | Back-office user identifier. | |
| 102 | +| `screen.id` | Screen id (for screen-token requests). | |
| 103 | +| `tenant.key` | Active tenant key. | |
| 104 | +| `trace_id` / `span_id` | W3C trace context, when a `traceparent` header is present. | |
| 105 | + |
| 106 | +Strict OTel notes: `http.route` is the **path template** the server matched, not the |
| 107 | +Symfony route *name* and not the concrete path; it is omitted entirely (rather than set to |
| 108 | +an empty/placeholder value) when no route matched. `enduser.id` and `screen.id` are mutually |
| 109 | +exclusive. Fields that don't apply to a record are absent, never `null`. |
| 110 | + |
| 111 | +### `extra` (ambient request) vs `context` (the event) |
| 112 | + |
| 113 | +The processors above add the **inbound** request's attributes to every record's `extra` |
| 114 | +(`http.request.method`, `http.route`, `url.path`, `client.address`, …). A log *call* may |
| 115 | +put its own subject's attributes in `context` using the same OTel names — for example the |
| 116 | +outbound HTTP client (`outbound_http` channel) logs `http.request.method` / `url.full` / |
| 117 | +`http.response.status_code` for the call it just made. So a record for an outbound call can |
| 118 | +carry `extra.http.request.method` (the inbound API request) **and** `context.http.request.method` |
| 119 | +(the outbound call). This is intentional and mirrors OTel's server-span vs client-span split: |
| 120 | +`extra` is the ambient request the worker is serving; `context` is the event being logged. |
| 121 | +The two stay in separate bags, so there is no key collision. |
| 122 | + |
| 123 | +## Redaction guarantees |
| 124 | + |
| 125 | +`SensitiveDataProcessor` runs last and is a backstop, not a license to log secrets: |
| 126 | + |
| 127 | +- `client.address` is **truncated** — IPv4 drops the last octet (`203.0.113.5` → |
| 128 | + `203.0.113.0`); IPv6 keeps the `/48` and zeroes the rest. No full client IP is emitted. |
| 129 | +- Any key whose name contains `password`, `passwd`, `secret`, `authorization`, `api_key`, |
| 130 | + `apikey`, `token`, `credential` or `bearer` is replaced with `[redacted]`, at any depth |
| 131 | + in `context`/`extra`. |
| 132 | + |
| 133 | +Still: do not put credentials or token strings into log context in the first place. |
0 commit comments