Skip to content

Commit bf37950

Browse files
authored
Merge pull request #479 from os2display/feature/structured-logging-semconv
2 parents 4b1094f + 91a5e90 commit bf37950

17 files changed

Lines changed: 793 additions & 94 deletions

.env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,6 @@ SESSION_HANDLER_DSN=${REDIS_CACHE_DSN}
120120
HTTP_CLIENT_TIMEOUT=5
121121
# Maximum total duration in seconds for an HTTP request including transfer.
122122
HTTP_CLIENT_MAX_DURATION=30
123-
# Log level for outgoing HTTP requests (debug, info, notice, warning, error).
124-
HTTP_CLIENT_LOG_LEVEL=error
125123
###< Http Client ###
126124

127125
###> Logging ###
@@ -146,6 +144,8 @@ LOG_LEVEL_INTERACTIVE=
146144
# Threshold for Symfony's built-in cache channel (cache-adapter/Redis failures);
147145
# not an application channel. Empty or unset inherits LOG_LEVEL.
148146
LOG_LEVEL_CACHE=
147+
# Per-channel override (outbound HTTP client); empty or unset inherits LOG_LEVEL.
148+
LOG_LEVEL_OUTBOUND_HTTP=
149149
###< Logging ###
150150

151151
###> App ###

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ All notable changes to this project will be documented in this file.
1414
- Renamed the outbound-HTTP-client log channel `app_http``outbound_http` and silenced
1515
Symfony's redundant native `http_client` channel logging (a `NullLogger` decorates it), so
1616
`LoggingHttpClient` is the single source of outbound-HTTP logs (no duplicate request logging).
17+
- Adopted OpenTelemetry semantic-convention field names for log records: request/identity
18+
context (`http.route`, `url.path`, `client.address`, `user.id` XOR `screen.id`,
19+
`tenant.key`) plus the HTTP client's `http.request.method` / `url.full` /
20+
`http.response.status_code` / `http.client.request.duration`. Added GDPR-safe
21+
client-address truncation and secret-key redaction (`SensitiveDataProcessor`), and
22+
structured exception serialization (`type`, `message`, …) under the `exception` context
23+
key (`ExceptionContextProcessor`). See `docs/logging.md`.
24+
The HTTP client now logs completed requests at `info` and failures at `error` on the
25+
`outbound_http` channel, thresholded by the new `LOG_LEVEL_OUTBOUND_HTTP` like every
26+
other channel; the `HTTP_CLIENT_LOG_LEVEL` env var is removed (use `LOG_LEVEL_OUTBOUND_HTTP`).
1727

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

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ LOG_LEVEL_MEDIA=
578578
LOG_LEVEL_FEED=
579579
LOG_LEVEL_INTERACTIVE=
580580
LOG_LEVEL_CACHE=
581+
LOG_LEVEL_OUTBOUND_HTTP=
581582
###< Logging ###
582583
```
583584

@@ -591,9 +592,16 @@ LOG_LEVEL_CACHE=
591592
`critical`). `info` reproduces the previous output.
592593

593594
**Default**: `info`.
594-
- LOG_LEVEL_AUTH, LOG_LEVEL_SCREEN, LOG_LEVEL_MEDIA, LOG_LEVEL_FEED, LOG_LEVEL_INTERACTIVE, LOG_LEVEL_CACHE:
595-
Per-channel threshold overrides. Empty or unset inherits `LOG_LEVEL`. Set one to raise or lower a single channel
596-
(e.g. `LOG_LEVEL_FEED=warning`) without affecting the others. An invalid level fails fast at boot.
595+
- LOG_LEVEL_AUTH, LOG_LEVEL_SCREEN, LOG_LEVEL_MEDIA, LOG_LEVEL_FEED, LOG_LEVEL_INTERACTIVE, LOG_LEVEL_CACHE,
596+
LOG_LEVEL_OUTBOUND_HTTP: Per-channel threshold overrides. Empty or unset inherits `LOG_LEVEL`. Set one to raise
597+
or lower a single channel (e.g. `LOG_LEVEL_FEED=warning`) without affecting the others. An invalid level fails
598+
fast at boot. (`LOG_LEVEL_CACHE` gates Symfony's built-in cache-adapter channel — Redis backend failures — not an
599+
application channel.)
600+
601+
The `outbound_http` channel carries outbound HTTP client logs (`LoggingHttpClient`): completed requests at
602+
`info`, failures at `error` — controlled by `LOG_LEVEL_OUTBOUND_HTTP` like every other channel. Symfony's
603+
built-in `http_client` channel (native, request-only logging) is silenced with a `NullLogger`, so
604+
`LoggingHttpClient` is the single source.
597605

598606
### Admin configuration
599607

UPGRADE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ APP_REDIS_CACHE_DSN → REDIS_CACHE_DSN
9898
9999
APP_HTTP_CLIENT_TIMEOUT → HTTP_CLIENT_TIMEOUT
100100
APP_HTTP_CLIENT_MAX_DURATION → HTTP_CLIENT_MAX_DURATION
101-
APP_HTTP_CLIENT_LOG_LEVEL → HTTP_CLIENT_LOG_LEVEL
101+
APP_HTTP_CLIENT_LOG_LEVEL → LOG_LEVEL_OUTBOUND_HTTP
102102
103103
APP_INTERNAL_OIDC_METADATA_URL → INTERNAL_OIDC_METADATA_URL
104104
APP_INTERNAL_OIDC_CLIENT_ID → INTERNAL_OIDC_CLIENT_ID

config/packages/prod/monolog.yaml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ monolog:
44
type: fingers_crossed
55
action_level: error
66
handler: nested
7+
# Exclude every channel that has its own always-on handler below: the
8+
# fingers_crossed gate has no channels filter by default, so on an error
9+
# it would flush buffered per-channel records to `nested` in addition to
10+
# the dedicated handler that already wrote them — emitting each twice.
11+
# `main` therefore handles only the default `app` channel (and any
12+
# channel without a dedicated handler).
13+
channels:
14+
[
15+
'!outbound_http',
16+
'!auth',
17+
'!screen',
18+
'!media',
19+
'!feed',
20+
'!interactive',
21+
'!cache',
22+
]
723
excluded_http_codes: [404, 405]
824
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
925
nested:
@@ -14,7 +30,7 @@ monolog:
1430
outbound_http:
1531
type: stream
1632
path: '%env(resolve:LOG_PATH)%'
17-
level: info
33+
level: '%env(default:app.log_level:LOG_LEVEL_OUTBOUND_HTTP)%'
1834
channels: ['outbound_http']
1935
formatter: monolog.formatter.json
2036
# One always-on handler per domain channel. A Monolog handler carries a

config/services.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ services:
6060
decorates: http_client
6161
arguments:
6262
$client: '@.inner'
63-
$logger: '@monolog.logger.outbound_http'
64-
$logLevel: '%env(string:HTTP_CLIENT_LOG_LEVEL)%'
63+
$outboundHttpLogger: '@monolog.logger.outbound_http'
6564

6665
# Silence Symfony's native http_client logging at the source: it is
6766
# request-only and redundant with LoggingHttpClient (outbound_http channel),
@@ -80,6 +79,14 @@ services:
8079
App\Logger\Processor\TraceContextProcessor:
8180
tags: [{ name: monolog.processor }]
8281

82+
App\Logger\Processor\ExceptionContextProcessor:
83+
tags: [{ name: monolog.processor, priority: -90 }]
84+
85+
# Lowest priority: runs last so it scrubs everything the other processors
86+
# added (e.g. the raw client.address set by RequestContextProcessor).
87+
App\Logger\Processor\SensitiveDataProcessor:
88+
tags: [{ name: monolog.processor, priority: -100 }]
89+
8390
# Logs security outcomes on the `auth` channel (logging only).
8491
#
8592
# Channel loggers are bound explicitly rather than via MonologBundle's channel

docs/logging.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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. Name the constructor parameter for its channel and bind it explicitly
16+
in `config/services.yaml`:
17+
18+
```yaml
19+
App\Service\FeedService:
20+
arguments:
21+
$feedLogger: '@monolog.logger.feed'
22+
```
23+
24+
| Channel | Use for |
25+
|---|---|
26+
| `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`. |
27+
| `auth` | Authentication and authorization outcomes (login, logout, JWT/OIDC). |
28+
| `screen` | Screen-client binding, screen authentication, screen status. |
29+
| `media` | Media upload, thumbnailing, storage. |
30+
| `feed` | Feed fetching/parsing (`FeedService`, feed types). |
31+
| `interactive` | Interactive slides (booking, instant book). |
32+
| `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. |
33+
| `database` | Database connection failures (see below). |
34+
35+
## Level guidance
36+
37+
| Level | When |
38+
|---|---|
39+
| `debug` | Developer detail; verbose, off in production action buffering. |
40+
| `info` | Normal, noteworthy events (auth success, batch completed). |
41+
| `notice` | Normal but significant (config fallback applied). |
42+
| `warning` | Something recoverable went wrong (auth failure, flapping feed, malformed payload). |
43+
| `error` | An operation failed and a user/screen is affected. |
44+
| `critical` | A subsystem is down (database connection pressure/unreachable). |
45+
46+
## Message and context rules
47+
48+
1. **The message is a static string.** Never interpolate variables into it.
49+
50+
```php
51+
// ❌ flagged by logging.interpolatedLogMessage
52+
$logger->warning("Feed {$source->getId()} failed");
53+
54+
// ✅ static message, data in context
55+
$logger->warning('Feed fetch failed', ['feed_source_id' => (string) $source->getId()]);
56+
```
57+
58+
PSR-3 `{placeholder}` braces resolved from the context array are fine.
59+
60+
2. **Exceptions go under the `exception` context key — never any other key, never
61+
interpolated.**
62+
63+
```php
64+
// ❌ flagged by logging.exceptionContextKey
65+
$logger->error('Booking failed', ['error' => $e]);
66+
67+
// ✅
68+
$logger->error('Booking failed', ['exception' => $e]);
69+
```
70+
71+
`ExceptionContextProcessor` turns the `\Throwable` into a structured array
72+
(`type`, `message`, `code`, `file`, `line`, bounded `previous` chain). No raw
73+
multi-line stack-trace string is emitted at info level.
74+
75+
3. **Never swallow an exception silently.** A `catch` must log it (under `exception`),
76+
rethrow it (optionally wrapped), or be explicitly annotated when the silence is
77+
intentional:
78+
79+
```php
80+
try {
81+
$optional = $this->parse($payload);
82+
} catch (\JsonException $e) {
83+
// @phpstan-ignore logging.silentCatch (optional metadata; absence is expected)
84+
}
85+
```
86+
87+
This is enforced by `logging.silentCatch`.
88+
89+
## Field names (OpenTelemetry semantic conventions)
90+
91+
Field names follow [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/)
92+
as strictly as the framework allows, so the logs stay forward-compatible with an OTel
93+
collector should one be introduced:
94+
95+
| Field | Meaning |
96+
|---|---|
97+
| `request_id` | Per-request id (inbound `X-Request-Id` or minted). |
98+
| `http.request.method` | HTTP method. |
99+
| `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. |
100+
| `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. |
101+
| `client.address` | Client address — **truncated** for users/anonymous callers, kept **in full** for screen clients (see redaction). |
102+
| `user.id` | Back-office user identifier. |
103+
| `screen.id` | Screen id (for screen-token requests). |
104+
| `tenant.key` | Active tenant key. |
105+
| `trace_id` / `span_id` | W3C trace context, when a `traceparent` header is present. |
106+
107+
Strict OTel notes: `http.route` is the **path template** the server matched, not the
108+
Symfony route *name* and not the concrete path; it is omitted entirely (rather than set to
109+
an empty/placeholder value) when no route matched. `user.id` and `screen.id` are mutually
110+
exclusive. Fields that don't apply to a record are absent, never `null`.
111+
112+
### `extra` (ambient request) vs `context` (the event)
113+
114+
The processors above add the **inbound** request's attributes to every record's `extra`
115+
(`http.request.method`, `http.route`, `url.path`, `client.address`, …). A log *call* may
116+
put its own subject's attributes in `context` using the same OTel names — for example the
117+
outbound HTTP client (`outbound_http` channel) logs `http.request.method` / `url.full` /
118+
`http.response.status_code` / `http.client.request.duration` for the call it just made.
119+
So a record for an outbound call can carry `extra.http.request.method` (the inbound API
120+
request) **and** `context.http.request.method`
121+
(the outbound call). This is intentional and mirrors OTel's server-span vs client-span split:
122+
`extra` is the ambient request the worker is serving; `context` is the event being logged.
123+
The two stay in separate bags, so there is no key collision.
124+
125+
## Redaction guarantees
126+
127+
`SensitiveDataProcessor` runs last and is a backstop, not a license to log secrets:
128+
129+
- `client.address` is **truncated** for back-office users and anonymous callers — IPv4
130+
drops the last octet (`203.0.113.5` → `203.0.113.0`); IPv6 keeps the `/48` and zeroes the
131+
rest; an address that does not parse as an IP is replaced with `[redacted]`.
132+
**Screen-client (kiosk) requests are exempt and keep their full IP.** A kiosk is an
133+
unattended public display, not a personal device, so it falls outside GDPR, and the full
134+
address helps identify a specific screen. A record is treated as a screen client when it
135+
carries `screen.id` (set upstream by `RequestContextProcessor`).
136+
- Any key whose name contains `password`, `passwd`, `secret`, `authorization`, `api_key`,
137+
`apikey`, `token`, `credential` or `bearer` is replaced with `[redacted]`, at any depth
138+
in `context`/`extra`.
139+
140+
`SensitiveDataProcessor` matches on the context **key name only** — it never inspects
141+
*values*. A secret carried inside a value under an innocuous key (most commonly a URL with
142+
`?api_key=…` in its query string) is therefore **not** caught by the backstop and must be
143+
sanitised at the source. The outbound HTTP client does this: `LoggingHttpClient` redacts the
144+
query string of `url.full` wholesale (`https://host/path?[redacted]`) and drops any userinfo
145+
(`user:pass@`) before logging, so credentials in an outbound URL never reach the log.
146+
147+
Still: do not put credentials or token strings into log context in the first place.

src/HttpClient/LoggingHttpClient.php

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111

1212
class LoggingHttpClient implements HttpClientInterface
1313
{
14+
private const REDACTED = '[redacted]';
15+
1416
public function __construct(
1517
private HttpClientInterface $client,
16-
private readonly LoggerInterface $logger,
17-
private readonly string $logLevel = 'error',
18+
private readonly LoggerInterface $outboundHttpLogger,
1819
) {}
1920

2021
public function request(string $method, string $url, array $options = []): ResponseInterface
@@ -23,33 +24,88 @@ public function request(string $method, string $url, array $options = []): Respo
2324

2425
$response = $this->client->request($method, $url, $options);
2526

27+
// The actual request uses the full $url; only the logged value is
28+
// sanitised (see sanitizeUrl).
29+
$loggedUrl = $this->sanitizeUrl($url);
30+
2631
try {
2732
$statusCode = $response->getStatusCode();
2833
} catch (\Throwable $throwable) {
29-
$duration = round((microtime(true) - $startTime) * 1000);
30-
31-
$this->logger->error('{method} {url} failed after {duration}ms: {error}', [
32-
'method' => $method,
33-
'url' => $url,
34-
'duration' => $duration,
35-
'error' => $throwable->getMessage(),
34+
// OTel semantic conventions for an HTTP client call; the exception
35+
// goes under the `exception` key so ExceptionContextProcessor
36+
// serialises it (see docs/logging.md).
37+
$this->outboundHttpLogger->error('{http.request.method} {url.full} failed', [
38+
'http.request.method' => $method,
39+
'url.full' => $loggedUrl,
40+
'http.client.request.duration' => $this->durationSeconds($startTime),
41+
'exception' => $throwable,
3642
]);
3743

3844
throw $throwable;
3945
}
4046

41-
$duration = round((microtime(true) - $startTime) * 1000);
42-
43-
$this->logger->log($this->logLevel, '{method} {url} {status_code} {duration}ms', [
44-
'method' => $method,
45-
'url' => $url,
46-
'status_code' => $statusCode,
47-
'duration' => $duration,
47+
// Natural severity: a completed request is info; failures log at error
48+
// (above). Visibility is controlled by the outbound_http handler
49+
// threshold (LOG_LEVEL_OUTBOUND_HTTP), consistent with every channel.
50+
$this->outboundHttpLogger->info('{http.request.method} {url.full} {http.response.status_code} ({http.client.request.duration}s)', [
51+
'http.request.method' => $method,
52+
'url.full' => $loggedUrl,
53+
'http.response.status_code' => $statusCode,
54+
'http.client.request.duration' => $this->durationSeconds($startTime),
4855
]);
4956

5057
return $response;
5158
}
5259

60+
/**
61+
* Sanitises a URL for logging: the query string is replaced wholesale with
62+
* a redaction marker (`?[redacted]`), and the fragment and any userinfo
63+
* (`user:pass@`) are dropped, keeping scheme + host + port + path.
64+
*
65+
* The {@see \App\Logger\Processor\SensitiveDataProcessor} backstop only
66+
* redacts by context *key* name; a credential carried in a URL *value*
67+
* (`?api_key=…`, `?token=…`, or `https://user:pass@host/…`) would otherwise
68+
* pass through unredacted. URL values must therefore be sanitised here, at
69+
* the source. The query is where secrets live, so its contents are redacted
70+
* rather than dropped — the `?[redacted]` marker preserves the signal that
71+
* a query was present without leaking any of its values.
72+
*/
73+
private function sanitizeUrl(string $url): string
74+
{
75+
$parts = parse_url($url);
76+
if (false === $parts) {
77+
// Unparseable: redact from the first query delimiter onward rather
78+
// than risk emitting a credential-bearing tail.
79+
$base = explode('#', explode('?', $url, 2)[0], 2)[0];
80+
81+
return str_contains($url, '?') ? $base.'?'.self::REDACTED : $base;
82+
}
83+
84+
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
85+
$host = $parts['host'] ?? '';
86+
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
87+
$path = $parts['path'] ?? '';
88+
// Show that a query existed, but never its contents.
89+
$query = isset($parts['query']) ? '?'.self::REDACTED : '';
90+
91+
// No host (e.g. a relative URL resolved against the client's base_uri):
92+
// keep just the path, still with the query redacted.
93+
if ('' === $host) {
94+
return $path.$query;
95+
}
96+
97+
return $scheme.$host.$port.$path.$query;
98+
}
99+
100+
/**
101+
* Elapsed time since $startTime in seconds (OTel
102+
* `http.client.request.duration` unit), rounded to 0.1 ms.
103+
*/
104+
private function durationSeconds(float $startTime): float
105+
{
106+
return round(microtime(true) - $startTime, 4);
107+
}
108+
53109
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
54110
{
55111
return $this->client->stream($responses, $timeout);

0 commit comments

Comments
 (0)