From 8cb3bb0002512990ce3460cf64d6f21094201a0a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 2 Jun 2026 23:57:57 +0200 Subject: [PATCH 1/5] feat(logging): log DB connection-establishment errors on a database channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `database` Monolog channel and a DBAL driver middleware that logs connection failures so operators without database access get a signal (ADR 011). Logging-only — no reconnect/retry. - ConnectionErrorDriver wraps connect() and reads the raw driver error code (a middleware sits below DBAL's exception conversion, which does not classify 1040/1203/2003 as ConnectionException). Connection-pressure / unreachability codes log at critical, other connect failures at error. The original exception is rethrown unchanged; only the host is logged, never the password or DSN. - Register the middleware with the explicit doctrine.middleware tag (the bundle collects tagged services; implementing the interface is not enough) and bind the database channel. - Add a prod per-channel handler for `database` (LOG_LEVEL_DATABASE with the LOG_LEVEL fallback) and document the event=db.connection_error shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env | 3 + CHANGELOG.md | 4 + README.md | 11 +- config/packages/monolog.yaml | 1 + config/packages/prod/monolog.yaml | 6 + config/services.yaml | 9 ++ docs/logging.md | 24 +++ .../Middleware/ConnectionErrorDriver.php | 70 +++++++++ .../Middleware/ConnectionErrorMiddleware.php | 26 ++++ .../Middleware/ConnectionErrorDriverTest.php | 145 ++++++++++++++++++ .../ConnectionErrorMiddlewareTest.php | 50 ++++++ 11 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/Doctrine/Middleware/ConnectionErrorDriver.php create mode 100644 src/Doctrine/Middleware/ConnectionErrorMiddleware.php create mode 100644 tests/Doctrine/Middleware/ConnectionErrorDriverTest.php create mode 100644 tests/Doctrine/Middleware/ConnectionErrorMiddlewareTest.php diff --git a/.env b/.env index 031852552..bf9f13b95 100644 --- a/.env +++ b/.env @@ -146,6 +146,9 @@ LOG_LEVEL_INTERACTIVE= LOG_LEVEL_CACHE= # Per-channel override (outbound HTTP client); empty or unset inherits LOG_LEVEL. LOG_LEVEL_OUTBOUND_HTTP= +# Per-channel override; empty or unset inherits LOG_LEVEL. Connection errors +# are logged at error/critical, so they emit regardless of this threshold. +LOG_LEVEL_DATABASE= ###< Logging ### ###> App ### diff --git a/CHANGELOG.md b/CHANGELOG.md index 521a5880e..50f7c9d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ All notable changes to this project will be documented in this file. (`logging.silentCatch`, `logging.interpolatedLogMessage`, `logging.exceptionContextKey`) and migrated the previously silent catch sites to log the exception, surface it, or be explicitly annotated as intentional. +- Added a `database` log channel and a DBAL driver middleware that logs MariaDB + connection-establishment failures (classified by raw driver error code; connection + pressure/unreachability at `critical`, other failures at `error`) so operators without database + access get a failure signal. Logging-only — no reconnect/retry. - Removed the deprecated feed types `SparkleIOFeedType`, `EventDatabaseApiFeedType` and `KobaFeedType`. Made the unknown-feed-type handling consistent: **reads degrade, writes are rejected.** Feed sources (and feeds) that reference a removed type keep loading — item and collection reads return them with no diff --git a/README.md b/README.md index dd91aba7f..393233d9b 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,7 @@ LOG_LEVEL_FEED= LOG_LEVEL_INTERACTIVE= LOG_LEVEL_CACHE= LOG_LEVEL_OUTBOUND_HTTP= +LOG_LEVEL_DATABASE= ###< Logging ### ``` @@ -593,10 +594,12 @@ LOG_LEVEL_OUTBOUND_HTTP= **Default**: `info`. - LOG_LEVEL_AUTH, LOG_LEVEL_SCREEN, LOG_LEVEL_MEDIA, LOG_LEVEL_FEED, LOG_LEVEL_INTERACTIVE, LOG_LEVEL_CACHE, - LOG_LEVEL_OUTBOUND_HTTP: 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. (`LOG_LEVEL_CACHE` gates Symfony's built-in cache-adapter channel — Redis backend failures — not an - application channel.) + LOG_LEVEL_OUTBOUND_HTTP, LOG_LEVEL_DATABASE: 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. (`LOG_LEVEL_CACHE` gates Symfony's built-in cache-adapter channel — + Redis backend failures — not an application channel.) Note `database` connection errors are logged at + `error`/`critical`, so they emit regardless of `LOG_LEVEL_DATABASE` (which only gates lower-severity + database-channel records). The `outbound_http` channel carries outbound HTTP client logs (`LoggingHttpClient`): completed requests at `info`, failures at `error` — controlled by `LOG_LEVEL_OUTBOUND_HTTP` like every other channel. Symfony's diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 25a0a3dad..0ae1f1d6b 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -8,4 +8,5 @@ monolog: 'feed', 'interactive', 'cache', + 'database', ] diff --git a/config/packages/prod/monolog.yaml b/config/packages/prod/monolog.yaml index d1794380f..1b6acff52 100644 --- a/config/packages/prod/monolog.yaml +++ b/config/packages/prod/monolog.yaml @@ -62,6 +62,12 @@ monolog: level: '%env(default:app.log_level:LOG_LEVEL_CACHE)%' channels: ['cache'] formatter: monolog.formatter.json + database: + type: stream + path: '%env(resolve:LOG_PATH)%' + level: '%env(default:app.log_level:LOG_LEVEL_DATABASE)%' + channels: ['database'] + formatter: monolog.formatter.json console: type: console process_psr_3_messages: false diff --git a/config/services.yaml b/config/services.yaml index 8f747921a..db69dbbee 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -108,6 +108,15 @@ services: arguments: $logger: '@monolog.logger.feed' + # Logs DB connection-establishment failures on the `database` channel. + # The explicit doctrine.middleware tag is required: doctrine-bundle collects + # tagged services in MiddlewaresPass; implementing the interface is not enough. + App\Doctrine\Middleware\ConnectionErrorMiddleware: + arguments: + $logger: '@monolog.logger.database' + tags: + - { name: doctrine.middleware } + 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' diff --git a/docs/logging.md b/docs/logging.md index 56ec00cff..098f837f4 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -138,3 +138,27 @@ query string of `url.full` wholesale (`https://host/path?[redacted]`) and drops (`user:pass@`) before logging, so credentials in an outbound URL never reach the log. Still: do not put credentials or token strings into log context in the first place. + +## Database connection errors + +A DBAL driver middleware (`App\Doctrine\Middleware\ConnectionErrorMiddleware`) logs +connection-**establishment** failures on the `database` channel so operators without +database access get a failure signal (ADR 011). It reads the raw driver error code (a +middleware sits below DBAL's exception conversion, which does not classify +`1040`/`1203`/`2003` as `ConnectionException`), fires for every SAPI (web, CLI, Messenger), +and rethrows the original exception unchanged — it is logging-only, with no reconnect. + +The record uses the static message `Database connection failed` with: + +| Key | Meaning | +|---|---| +| `event` | Always `db.connection_error`. | +| `db.error_code` | Numeric MySQL/MariaDB error number (e.g. `1040`, `1045`, `2003`). | +| `db.sqlstate` | The SQLSTATE, when available. | +| `db.host` | Connection host only — never the password or full DSN. | +| `exception` | The structured `\Throwable` (via `ExceptionContextProcessor`). | + +Connection **pressure / unreachability** codes (`1040`, `1203`, `1226`, `2002`, `2003`, +`2005`) are logged at `critical`; other connect failures (e.g. a `1045` credential error) +at `error`. Both levels emit regardless of `LOG_LEVEL_DATABASE`, which only gates +lower-severity `database`-channel records. Mid-query drops (`2006`/`2013`) are out of scope. diff --git a/src/Doctrine/Middleware/ConnectionErrorDriver.php b/src/Doctrine/Middleware/ConnectionErrorDriver.php new file mode 100644 index 000000000..e69a3c705 --- /dev/null +++ b/src/Doctrine/Middleware/ConnectionErrorDriver.php @@ -0,0 +1,70 @@ + + */ + private const CONNECTION_PRESSURE = [ + 1040, // ER_CON_COUNT_ERROR — too many connections + 1203, // ER_TOO_MANY_USER_CONNECTIONS + 1226, // ER_USER_LIMIT_REACHED + 2002, // CR_CONNECTION_ERROR — can't connect via socket + 2003, // CR_CONN_HOST_ERROR — can't connect via TCP (refused / host down) + 2005, // CR_UNKNOWN_HOST + ]; + + public function __construct( + Driver $driver, + private readonly LoggerInterface $logger, + ) { + parent::__construct($driver); + } + + public function connect(array $params): DriverConnection + { + try { + return parent::connect($params); + } catch (DriverException $e) { + try { + $code = $e->getCode(); + $level = in_array($code, self::CONNECTION_PRESSURE, true) ? 'critical' : 'error'; + + $this->logger->log($level, 'Database connection failed', [ + 'event' => 'db.connection_error', + 'db.error_code' => $code, + 'db.sqlstate' => $e->getSQLState(), + 'db.host' => $params['host'] ?? null, // host only — never password/DSN + 'exception' => $e, + ]); + } catch (\Throwable) { // @phpstan-ignore logging.silentCatch (a failing log write — e.g. unwritable LOG_PATH during the same outage — must never mask the real connection error rethrown below) + // Swallow logging failures so the original connection exception + // always propagates unchanged (the class's documented contract). + } + + throw $e; + } + } +} diff --git a/src/Doctrine/Middleware/ConnectionErrorMiddleware.php b/src/Doctrine/Middleware/ConnectionErrorMiddleware.php new file mode 100644 index 000000000..863822f5f --- /dev/null +++ b/src/Doctrine/Middleware/ConnectionErrorMiddleware.php @@ -0,0 +1,26 @@ +logger); + } +} diff --git a/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php new file mode 100644 index 000000000..7d60ee11b --- /dev/null +++ b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php @@ -0,0 +1,145 @@ +driverThrowing(2003, 'HY000'); // CR_CONN_HOST_ERROR + + $this->expectException(DriverException::class); + + try { + (new ConnectionErrorDriver($driver, new Logger('database', [$handler]))) + ->connect(['host' => 'db.internal', 'password' => 's3cr3t', 'user' => 'app']); + } finally { + $records = $handler->getRecords(); + $this->assertCount(1, $records); + $record = $records[0]; + + $this->assertSame(Level::Critical, $record->level); + $this->assertSame('database', $record->channel); + $this->assertSame('Database connection failed', $record->message); + $this->assertSame('db.connection_error', $record->context['event']); + $this->assertSame(2003, $record->context['db.error_code']); + $this->assertSame('HY000', $record->context['db.sqlstate']); + $this->assertSame('db.internal', $record->context['db.host']); + + // The middleware logs the host only — never the password or DSN. + $serialised = json_encode($record->toArray(), JSON_THROW_ON_ERROR | JSON_PARTIAL_OUTPUT_ON_ERROR); + $this->assertStringNotContainsString('s3cr3t', $serialised); + $this->assertArrayNotHasKey('password', $record->context); + } + } + + public function testCredentialErrorIsLoggedAsError(): void + { + $handler = new TestHandler(); + $driver = $this->driverThrowing(1045, '28000'); // ER_ACCESS_DENIED_ERROR + + try { + (new ConnectionErrorDriver($driver, new Logger('database', [$handler]))) + ->connect(['host' => 'db.internal']); + $this->fail('Expected the driver exception to be rethrown.'); + } catch (DriverException) { + $record = $handler->getRecords()[0]; + $this->assertSame(Level::Error, $record->level); + $this->assertSame(1045, $record->context['db.error_code']); + } + } + + public function testSuccessfulConnectionIsNotLoggedAndPassesThrough(): void + { + $handler = new TestHandler(); + $connection = $this->createMock(DriverConnection::class); + $driver = $this->createMock(Driver::class); + $driver->method('connect')->willReturn($connection); + + $result = (new ConnectionErrorDriver($driver, new Logger('database', [$handler]))) + ->connect(['host' => 'db.internal']); + + // The wrapped connection passes through untouched and nothing is logged. + $this->assertSame($connection, $result); + $this->assertSame([], $handler->getRecords()); + } + + public function testOriginalExceptionInstanceIsRethrown(): void + { + $handler = new TestHandler(); + $exception = $this->driverException(2003, 'HY000'); + $driver = $this->driverThrowingException($exception); + + try { + (new ConnectionErrorDriver($driver, new Logger('database', [$handler]))) + ->connect(['host' => 'db.internal']); + $this->fail('Expected the driver exception to be rethrown.'); + } catch (DriverException $caught) { + // The exact original instance must propagate — not a wrapper that + // would drop the driver code / previous cause DBAL relies on. + $this->assertSame($exception, $caught); + } + } + + public function testThrowingLoggerDoesNotMaskTheConnectionError(): void + { + $exception = $this->driverException(2003, 'HY000'); + $driver = $this->driverThrowingException($exception); + + // A logger whose write fails — e.g. an unwritable LOG_PATH during the + // very outage this middleware reports on. + $logger = $this->createMock(LoggerInterface::class); + $logger->method('log')->willThrowException(new \RuntimeException('log sink unavailable')); + + try { + (new ConnectionErrorDriver($driver, $logger))->connect(['host' => 'db.internal']); + $this->fail('Expected the driver exception to be rethrown.'); + } catch (\Throwable $caught) { + // The real DB connection error wins; the logging failure is swallowed. + $this->assertSame($exception, $caught); + } + } + + private function driverThrowing(int $code, string $sqlState): Driver + { + return $this->driverThrowingException($this->driverException($code, $sqlState)); + } + + private function driverException(int $code, string $sqlState): DriverException + { + return new class($code, $sqlState) extends \RuntimeException implements DriverException { + public function __construct( + int $code, + private readonly string $sqlState, + ) { + parent::__construct('driver failure', $code); + } + + public function getSQLState(): ?string + { + return $this->sqlState; + } + }; + } + + private function driverThrowingException(DriverException $exception): Driver + { + $driver = $this->createMock(Driver::class); + $driver->method('connect')->willThrowException($exception); + + return $driver; + } +} diff --git a/tests/Doctrine/Middleware/ConnectionErrorMiddlewareTest.php b/tests/Doctrine/Middleware/ConnectionErrorMiddlewareTest.php new file mode 100644 index 000000000..9c1574e42 --- /dev/null +++ b/tests/Doctrine/Middleware/ConnectionErrorMiddlewareTest.php @@ -0,0 +1,50 @@ +wrap($this->createMock(Driver::class)); + + $this->assertInstanceOf(ConnectionErrorDriver::class, $wrapped); + } + + public function testWrappedDriverLogsConnectionFailuresOnTheInjectedLogger(): void + { + $handler = new TestHandler(); + $middleware = new ConnectionErrorMiddleware(new Logger('database', [$handler])); + + $driver = $this->createMock(Driver::class); + $driver->method('connect')->willThrowException( + new class extends \RuntimeException implements DriverException { + public function getSQLState(): ?string + { + return 'HY000'; + } + } + ); + + try { + $middleware->wrap($driver)->connect(['host' => 'db.internal']); + $this->fail('Expected the driver exception to propagate.'); + } catch (DriverException) { + // wrap() must thread the injected logger into the driver it creates. + $this->assertTrue($handler->hasRecords(Level::Error)); + } + } +} From 232e1e5bad6757656e2e0324294dc47d013412f9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 8 Jun 2026 22:58:53 +0200 Subject: [PATCH 2/5] refactor(logging): name the database channel logger parameter for its channel Rename ConnectionErrorMiddleware's injected logger $logger -> $databaseLogger so the constructor self-documents its channel; binding key updated to match. ConnectionErrorDriver keeps its positional $logger (not a channel-bound service). No behaviour change. Co-Authored-By: Claude Opus 4.8 --- config/services.yaml | 2 +- src/Doctrine/Middleware/ConnectionErrorMiddleware.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index b45351cd2..f2476e42d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -128,7 +128,7 @@ services: # tagged services in MiddlewaresPass; implementing the interface is not enough. App\Doctrine\Middleware\ConnectionErrorMiddleware: arguments: - $logger: '@monolog.logger.database' + $databaseLogger: '@monolog.logger.database' tags: - { name: doctrine.middleware } diff --git a/src/Doctrine/Middleware/ConnectionErrorMiddleware.php b/src/Doctrine/Middleware/ConnectionErrorMiddleware.php index 863822f5f..f12f0429e 100644 --- a/src/Doctrine/Middleware/ConnectionErrorMiddleware.php +++ b/src/Doctrine/Middleware/ConnectionErrorMiddleware.php @@ -16,11 +16,11 @@ final readonly class ConnectionErrorMiddleware implements Middleware { public function __construct( - private LoggerInterface $logger, + private LoggerInterface $databaseLogger, ) {} public function wrap(Driver $driver): Driver { - return new ConnectionErrorDriver($driver, $this->logger); + return new ConnectionErrorDriver($driver, $this->databaseLogger); } } From f9cd19f08ddb56225747309f4623fa09bdf1d77a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 9 Jun 2026 09:50:59 +0200 Subject: [PATCH 3/5] feat(logging): add OTel error.type to db connection errors; rename to db.response.status_code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database connection-error record now carries an OTel-compliant categorisation: error.type holds a stable low-cardinality token (too_many_connections, access_denied, …) mapped from the driver code, falling back to the stringified code when unmapped. The verbose driver text remains in exception.message. Also rename the bespoke db.error_code to the OTel database semantic-convention attribute db.response.status_code (emitted as a string). Doc table and ConnectionErrorDriver test updated accordingly. Co-Authored-By: Claude Opus 4.8 --- docs/logging.md | 3 ++- .../Middleware/ConnectionErrorDriver.php | 25 ++++++++++++++++++- .../Middleware/ConnectionErrorDriverTest.php | 6 +++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/logging.md b/docs/logging.md index fbe8b64d4..9e07ee98b 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -159,7 +159,8 @@ The record uses the static message `Database connection failed` with: | Key | Meaning | |---|---| | `event` | Always `db.connection_error`. | -| `db.error_code` | Numeric MySQL/MariaDB error number (e.g. `1040`, `1045`, `2003`). | +| `db.response.status_code` | MySQL/MariaDB error number as a string (e.g. `1040`, `1045`, `2003`); OTel db semconv. | +| `error.type` | Low-cardinality OTel categorisation of the failure (e.g. `too_many_connections`, `access_denied`); falls back to the stringified code when unmapped. The verbose driver message stays in `exception.message`. | | `db.sqlstate` | The SQLSTATE, when available. | | `db.host` | Connection host only — never the password or full DSN. | | `exception` | The structured `\Throwable` (via `ExceptionContextProcessor`). | diff --git a/src/Doctrine/Middleware/ConnectionErrorDriver.php b/src/Doctrine/Middleware/ConnectionErrorDriver.php index e69a3c705..664858aeb 100644 --- a/src/Doctrine/Middleware/ConnectionErrorDriver.php +++ b/src/Doctrine/Middleware/ConnectionErrorDriver.php @@ -36,6 +36,26 @@ final class ConnectionErrorDriver extends AbstractDriverMiddleware 2005, // CR_UNKNOWN_HOST ]; + /** + * MySQL/MariaDB error numbers mapped to a stable, low-cardinality OTel + * `error.type` token (the human-readable categorisation; the verbose driver + * message stays in `exception.message`). Codes not listed fall back to the + * stringified code per OTel guidance, so `error.type` is always present. + * + * @var array + */ + private const ERROR_TYPE = [ + 1040 => 'too_many_connections', + 1203 => 'too_many_user_connections', + 1226 => 'user_resource_limit', + 2002 => 'connection_refused', + 2003 => 'connection_refused', + 2005 => 'unknown_host', + 1045 => 'access_denied', + 1044 => 'access_denied', + 1049 => 'unknown_database', + ]; + public function __construct( Driver $driver, private readonly LoggerInterface $logger, @@ -54,7 +74,10 @@ public function connect(array $params): DriverConnection $this->logger->log($level, 'Database connection failed', [ 'event' => 'db.connection_error', - 'db.error_code' => $code, + // OTel db semconv: the DB-specific status/error code, as a string. + 'db.response.status_code' => (string) $code, + // OTel: low-cardinality categorisation of the failure. + 'error.type' => self::ERROR_TYPE[$code] ?? (string) $code, 'db.sqlstate' => $e->getSQLState(), 'db.host' => $params['host'] ?? null, // host only — never password/DSN 'exception' => $e, diff --git a/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php index 7d60ee11b..f17b973cf 100644 --- a/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php +++ b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php @@ -35,7 +35,8 @@ public function testConnectionPressureCodeIsLoggedAsCriticalWithoutSecrets(): vo $this->assertSame('database', $record->channel); $this->assertSame('Database connection failed', $record->message); $this->assertSame('db.connection_error', $record->context['event']); - $this->assertSame(2003, $record->context['db.error_code']); + $this->assertSame('2003', $record->context['db.response.status_code']); + $this->assertSame('connection_refused', $record->context['error.type']); $this->assertSame('HY000', $record->context['db.sqlstate']); $this->assertSame('db.internal', $record->context['db.host']); @@ -58,7 +59,8 @@ public function testCredentialErrorIsLoggedAsError(): void } catch (DriverException) { $record = $handler->getRecords()[0]; $this->assertSame(Level::Error, $record->level); - $this->assertSame(1045, $record->context['db.error_code']); + $this->assertSame('1045', $record->context['db.response.status_code']); + $this->assertSame('access_denied', $record->context['error.type']); } } From cc31323a449fae7961967f23b99931ada3db41ff Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 9 Jun 2026 10:59:38 +0200 Subject: [PATCH 4/5] fix(logging): exclude database channel from fingers_crossed main handler The database channel has its own always-on handler; add it to the main handler's channel blacklist so a critical connection error isn't emitted twice (see the per-channel duplicate-emission fix). Co-Authored-By: Claude Opus 4.8 --- config/packages/prod/monolog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/packages/prod/monolog.yaml b/config/packages/prod/monolog.yaml index 8ce90baee..008b31fa7 100644 --- a/config/packages/prod/monolog.yaml +++ b/config/packages/prod/monolog.yaml @@ -19,6 +19,7 @@ monolog: '!feed', '!interactive', '!cache', + '!database', ] excluded_http_codes: [404, 405] buffer_size: 50 # How many messages should be saved? Prevent memory leaks From cad19f27abd561cf4063bbe358d10a9b90ef7590 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 9 Jun 2026 10:59:39 +0200 Subject: [PATCH 5/5] refactor(logging): use OTel server.address/server.port for db connection host There is no db.host OTel attribute; connection host/port are the general server.address / server.port attributes. Rename db.host -> server.address (host only, never password/DSN) and emit server.port conditionally (omit-when-absent, per OTel). Doc table and driver test updated. Co-Authored-By: Claude Opus 4.8 --- docs/logging.md | 3 ++- src/Doctrine/Middleware/ConnectionErrorDriver.php | 13 ++++++++++--- .../Middleware/ConnectionErrorDriverTest.php | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/logging.md b/docs/logging.md index 6a678dd65..2543a204a 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -162,7 +162,8 @@ The record uses the static message `Database connection failed` with: | `db.response.status_code` | MySQL/MariaDB error number as a string (e.g. `1040`, `1045`, `2003`); OTel db semconv. | | `error.type` | Low-cardinality OTel categorisation of the failure (e.g. `too_many_connections`, `access_denied`); falls back to the stringified code when unmapped. The verbose driver message stays in `exception.message`. | | `db.sqlstate` | The SQLSTATE, when available. | -| `db.host` | Connection host only — never the password or full DSN. | +| `server.address` | Connection host only (OTel `server.*`) — never the password or full DSN. | +| `server.port` | Connection port (OTel `server.*`); emitted only when the connection params carry one. | | `exception` | The structured `\Throwable` (via `ExceptionContextProcessor`). | Connection **pressure / unreachability** codes (`1040`, `1203`, `1226`, `2002`, `2003`, diff --git a/src/Doctrine/Middleware/ConnectionErrorDriver.php b/src/Doctrine/Middleware/ConnectionErrorDriver.php index 664858aeb..0b6f0c677 100644 --- a/src/Doctrine/Middleware/ConnectionErrorDriver.php +++ b/src/Doctrine/Middleware/ConnectionErrorDriver.php @@ -72,16 +72,23 @@ public function connect(array $params): DriverConnection $code = $e->getCode(); $level = in_array($code, self::CONNECTION_PRESSURE, true) ? 'critical' : 'error'; - $this->logger->log($level, 'Database connection failed', [ + $context = [ 'event' => 'db.connection_error', // OTel db semconv: the DB-specific status/error code, as a string. 'db.response.status_code' => (string) $code, // OTel: low-cardinality categorisation of the failure. 'error.type' => self::ERROR_TYPE[$code] ?? (string) $code, 'db.sqlstate' => $e->getSQLState(), - 'db.host' => $params['host'] ?? null, // host only — never password/DSN + // OTel server.* — host only, never the password or full DSN. + 'server.address' => $params['host'] ?? null, 'exception' => $e, - ]); + ]; + // OTel pairs server.address with server.port; omit when absent. + if (isset($params['port'])) { + $context['server.port'] = $params['port']; + } + + $this->logger->log($level, 'Database connection failed', $context); } catch (\Throwable) { // @phpstan-ignore logging.silentCatch (a failing log write — e.g. unwritable LOG_PATH during the same outage — must never mask the real connection error rethrown below) // Swallow logging failures so the original connection exception // always propagates unchanged (the class's documented contract). diff --git a/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php index f17b973cf..2a0178416 100644 --- a/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php +++ b/tests/Doctrine/Middleware/ConnectionErrorDriverTest.php @@ -38,7 +38,9 @@ public function testConnectionPressureCodeIsLoggedAsCriticalWithoutSecrets(): vo $this->assertSame('2003', $record->context['db.response.status_code']); $this->assertSame('connection_refused', $record->context['error.type']); $this->assertSame('HY000', $record->context['db.sqlstate']); - $this->assertSame('db.internal', $record->context['db.host']); + $this->assertSame('db.internal', $record->context['server.address']); + // The fixture passes no port, so server.port is omitted (OTel omit-when-absent). + $this->assertArrayNotHasKey('server.port', $record->context); // The middleware logs the host only — never the password or DSN. $serialised = json_encode($record->toArray(), JSON_THROW_ON_ERROR | JSON_PARTIAL_OUTPUT_ON_ERROR);