Skip to content

Commit c442096

Browse files
authored
Merge pull request #478 from os2display/feature/db-connection-error-logging
feat(logging): [4/4] DB connection-error logging middleware (database channel)
2 parents 8abbea8 + 685ee07 commit c442096

11 files changed

Lines changed: 382 additions & 4 deletions

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ LOG_LEVEL_INTERACTIVE=
146146
LOG_LEVEL_CACHE=
147147
# Per-channel override (outbound HTTP client); empty or unset inherits LOG_LEVEL.
148148
LOG_LEVEL_OUTBOUND_HTTP=
149+
# Per-channel override; empty or unset inherits LOG_LEVEL. Connection errors
150+
# are logged at error/critical, so they emit regardless of this threshold.
151+
LOG_LEVEL_DATABASE=
149152
###< Logging ###
150153

151154
###> App ###

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ All notable changes to this project will be documented in this file.
2828
(`logging.silentCatch`, `logging.interpolatedLogMessage`, `logging.exceptionContextKey`) and
2929
migrated the previously silent catch sites to log the exception, surface it, or be explicitly
3030
annotated as intentional.
31+
- Added a `database` log channel and a DBAL driver middleware that logs MariaDB
32+
connection-establishment failures (classified by raw driver error code; connection
33+
pressure/unreachability at `critical`, other failures at `error`) so operators without database
34+
access get a failure signal. Logging-only — no reconnect/retry.
3135

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

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ LOG_LEVEL_FEED=
579579
LOG_LEVEL_INTERACTIVE=
580580
LOG_LEVEL_CACHE=
581581
LOG_LEVEL_OUTBOUND_HTTP=
582+
LOG_LEVEL_DATABASE=
582583
###< Logging ###
583584
```
584585

@@ -593,10 +594,12 @@ LOG_LEVEL_OUTBOUND_HTTP=
593594

594595
**Default**: `info`.
595596
- 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.)
597+
LOG_LEVEL_OUTBOUND_HTTP, LOG_LEVEL_DATABASE: Per-channel threshold overrides. Empty or unset inherits
598+
`LOG_LEVEL`. Set one to raise or lower a single channel (e.g. `LOG_LEVEL_FEED=warning`) without affecting the
599+
others. An invalid level fails fast at boot. (`LOG_LEVEL_CACHE` gates Symfony's built-in cache-adapter channel —
600+
Redis backend failures — not an application channel.) Note `database` connection errors are logged at
601+
`error`/`critical`, so they emit regardless of `LOG_LEVEL_DATABASE` (which only gates lower-severity
602+
database-channel records).
600603

601604
The `outbound_http` channel carries outbound HTTP client logs (`LoggingHttpClient`): completed requests at
602605
`info`, failures at `error` — controlled by `LOG_LEVEL_OUTBOUND_HTTP` like every other channel. Symfony's

config/packages/monolog.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ monolog:
88
'feed',
99
'interactive',
1010
'cache',
11+
'database',
1112
]

config/packages/prod/monolog.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ monolog:
1919
'!feed',
2020
'!interactive',
2121
'!cache',
22+
'!database',
2223
]
2324
excluded_http_codes: [404, 405]
2425
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
@@ -78,6 +79,12 @@ monolog:
7879
level: '%env(default:app.log_level:LOG_LEVEL_CACHE)%'
7980
channels: ['cache']
8081
formatter: monolog.formatter.json
82+
database:
83+
type: stream
84+
path: '%env(resolve:LOG_PATH)%'
85+
level: '%env(default:app.log_level:LOG_LEVEL_DATABASE)%'
86+
channels: ['database']
87+
formatter: monolog.formatter.json
8188
console:
8289
type: console
8390
process_psr_3_messages: false

config/services.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ services:
123123
arguments:
124124
$feedLogger: '@monolog.logger.feed'
125125

126+
# Logs DB connection-establishment failures on the `database` channel.
127+
# The explicit doctrine.middleware tag is required: doctrine-bundle collects
128+
# tagged services in MiddlewaresPass; implementing the interface is not enough.
129+
App\Doctrine\Middleware\ConnectionErrorMiddleware:
130+
arguments:
131+
$databaseLogger: '@monolog.logger.database'
132+
tags:
133+
- { name: doctrine.middleware }
134+
126135
Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler'
127136
Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler'
128137

docs/logging.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,29 @@ query string of `url.full` wholesale (`https://host/path?[redacted]`) and drops
145145
(`user:pass@`) before logging, so credentials in an outbound URL never reach the log.
146146

147147
Still: do not put credentials or token strings into log context in the first place.
148+
149+
## Database connection errors
150+
151+
A DBAL driver middleware (`App\Doctrine\Middleware\ConnectionErrorMiddleware`) logs
152+
connection-**establishment** failures on the `database` channel so operators without
153+
database access get a failure signal (ADR 011). It reads the raw driver error code (a
154+
middleware sits below DBAL's exception conversion, which does not classify
155+
`1040`/`1203`/`2003` as `ConnectionException`), fires for every SAPI (web, CLI, Messenger),
156+
and rethrows the original exception unchanged — it is logging-only, with no reconnect.
157+
158+
The record uses the static message `Database connection failed` with:
159+
160+
| Key | Meaning |
161+
|---|---|
162+
| `event` | Always `db.connection_error`. |
163+
| `db.response.status_code` | MySQL/MariaDB error number as a string (e.g. `1040`, `1045`, `2003`); OTel db semconv. |
164+
| `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`. |
165+
| `db.sqlstate` | The SQLSTATE, when available. |
166+
| `server.address` | Connection host only (OTel `server.*`) — never the password or full DSN. |
167+
| `server.port` | Connection port (OTel `server.*`); emitted only when the connection params carry one. |
168+
| `exception` | The structured `\Throwable` (via `ExceptionContextProcessor`). |
169+
170+
Connection **pressure / unreachability** codes (`1040`, `1203`, `1226`, `2002`, `2003`,
171+
`2005`) are logged at `critical`; other connect failures (e.g. a `1045` credential error)
172+
at `error`. Both levels emit regardless of `LOG_LEVEL_DATABASE`, which only gates
173+
lower-severity `database`-channel records. Mid-query drops (`2006`/`2013`) are out of scope.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Doctrine\Middleware;
6+
7+
use Doctrine\DBAL\Driver;
8+
use Doctrine\DBAL\Driver\Connection as DriverConnection;
9+
use Doctrine\DBAL\Driver\Exception as DriverException;
10+
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
11+
use Psr\Log\LoggerInterface;
12+
13+
/**
14+
* Logs MariaDB/MySQL connection-establishment failures on the `database`
15+
* channel, reading the raw driver error code (a middleware sits below DBAL's
16+
* exception conversion, which does not classify 1040/1203/2003/2013 as
17+
* ConnectionException). Fires for every SAPI and regardless of whether
18+
* application code later swallows the exception. The original exception is
19+
* rethrown unchanged.
20+
*/
21+
final class ConnectionErrorDriver extends AbstractDriverMiddleware
22+
{
23+
/**
24+
* MySQL/MariaDB error numbers that indicate connection PRESSURE or
25+
* unreachability (as opposed to e.g. a bad credential typo). Logged at
26+
* `critical`; other connect failures at `error`.
27+
*
28+
* @var list<int>
29+
*/
30+
private const CONNECTION_PRESSURE = [
31+
1040, // ER_CON_COUNT_ERROR — too many connections
32+
1203, // ER_TOO_MANY_USER_CONNECTIONS
33+
1226, // ER_USER_LIMIT_REACHED
34+
2002, // CR_CONNECTION_ERROR — can't connect via socket
35+
2003, // CR_CONN_HOST_ERROR — can't connect via TCP (refused / host down)
36+
2005, // CR_UNKNOWN_HOST
37+
];
38+
39+
/**
40+
* MySQL/MariaDB error numbers mapped to a stable, low-cardinality OTel
41+
* `error.type` token (the human-readable categorisation; the verbose driver
42+
* message stays in `exception.message`). Codes not listed fall back to the
43+
* stringified code per OTel guidance, so `error.type` is always present.
44+
*
45+
* @var array<int, string>
46+
*/
47+
private const ERROR_TYPE = [
48+
1040 => 'too_many_connections',
49+
1203 => 'too_many_user_connections',
50+
1226 => 'user_resource_limit',
51+
2002 => 'connection_refused',
52+
2003 => 'connection_refused',
53+
2005 => 'unknown_host',
54+
1045 => 'access_denied',
55+
1044 => 'access_denied',
56+
1049 => 'unknown_database',
57+
];
58+
59+
public function __construct(
60+
Driver $driver,
61+
private readonly LoggerInterface $logger,
62+
) {
63+
parent::__construct($driver);
64+
}
65+
66+
public function connect(array $params): DriverConnection
67+
{
68+
try {
69+
return parent::connect($params);
70+
} catch (DriverException $e) {
71+
try {
72+
$code = $e->getCode();
73+
$level = in_array($code, self::CONNECTION_PRESSURE, true) ? 'critical' : 'error';
74+
75+
$context = [
76+
'event' => 'db.connection_error',
77+
// OTel db semconv: the DB-specific status/error code, as a string.
78+
'db.response.status_code' => (string) $code,
79+
// OTel: low-cardinality categorisation of the failure.
80+
'error.type' => self::ERROR_TYPE[$code] ?? (string) $code,
81+
'db.sqlstate' => $e->getSQLState(),
82+
// OTel server.* — host only, never the password or full DSN.
83+
'server.address' => $params['host'] ?? null,
84+
'exception' => $e,
85+
];
86+
// OTel pairs server.address with server.port; omit when absent.
87+
if (isset($params['port'])) {
88+
$context['server.port'] = $params['port'];
89+
}
90+
91+
$this->logger->log($level, 'Database connection failed', $context);
92+
} 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)
93+
// Swallow logging failures so the original connection exception
94+
// always propagates unchanged (the class's documented contract).
95+
}
96+
97+
throw $e;
98+
}
99+
}
100+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Doctrine\Middleware;
6+
7+
use Doctrine\DBAL\Driver;
8+
use Doctrine\DBAL\Driver\Middleware;
9+
use Psr\Log\LoggerInterface;
10+
11+
/**
12+
* Wraps the DBAL driver so connection-establishment failures are logged on the
13+
* `database` channel before they propagate (ADR 011). Logging only — there is
14+
* no reconnect/retry.
15+
*/
16+
final readonly class ConnectionErrorMiddleware implements Middleware
17+
{
18+
public function __construct(
19+
private LoggerInterface $databaseLogger,
20+
) {}
21+
22+
public function wrap(Driver $driver): Driver
23+
{
24+
return new ConnectionErrorDriver($driver, $this->databaseLogger);
25+
}
26+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Doctrine\Middleware;
6+
7+
use App\Doctrine\Middleware\ConnectionErrorDriver;
8+
use Doctrine\DBAL\Driver;
9+
use Doctrine\DBAL\Driver\Connection as DriverConnection;
10+
use Doctrine\DBAL\Driver\Exception as DriverException;
11+
use Monolog\Handler\TestHandler;
12+
use Monolog\Level;
13+
use Monolog\Logger;
14+
use PHPUnit\Framework\TestCase;
15+
use Psr\Log\LoggerInterface;
16+
17+
class ConnectionErrorDriverTest extends TestCase
18+
{
19+
public function testConnectionPressureCodeIsLoggedAsCriticalWithoutSecrets(): void
20+
{
21+
$handler = new TestHandler();
22+
$driver = $this->driverThrowing(2003, 'HY000'); // CR_CONN_HOST_ERROR
23+
24+
$this->expectException(DriverException::class);
25+
26+
try {
27+
(new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
28+
->connect(['host' => 'db.internal', 'password' => 's3cr3t', 'user' => 'app']);
29+
} finally {
30+
$records = $handler->getRecords();
31+
$this->assertCount(1, $records);
32+
$record = $records[0];
33+
34+
$this->assertSame(Level::Critical, $record->level);
35+
$this->assertSame('database', $record->channel);
36+
$this->assertSame('Database connection failed', $record->message);
37+
$this->assertSame('db.connection_error', $record->context['event']);
38+
$this->assertSame('2003', $record->context['db.response.status_code']);
39+
$this->assertSame('connection_refused', $record->context['error.type']);
40+
$this->assertSame('HY000', $record->context['db.sqlstate']);
41+
$this->assertSame('db.internal', $record->context['server.address']);
42+
// The fixture passes no port, so server.port is omitted (OTel omit-when-absent).
43+
$this->assertArrayNotHasKey('server.port', $record->context);
44+
45+
// The middleware logs the host only — never the password or DSN.
46+
$serialised = json_encode($record->toArray(), JSON_THROW_ON_ERROR | JSON_PARTIAL_OUTPUT_ON_ERROR);
47+
$this->assertStringNotContainsString('s3cr3t', $serialised);
48+
$this->assertArrayNotHasKey('password', $record->context);
49+
}
50+
}
51+
52+
public function testCredentialErrorIsLoggedAsError(): void
53+
{
54+
$handler = new TestHandler();
55+
$driver = $this->driverThrowing(1045, '28000'); // ER_ACCESS_DENIED_ERROR
56+
57+
try {
58+
(new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
59+
->connect(['host' => 'db.internal']);
60+
$this->fail('Expected the driver exception to be rethrown.');
61+
} catch (DriverException) {
62+
$record = $handler->getRecords()[0];
63+
$this->assertSame(Level::Error, $record->level);
64+
$this->assertSame('1045', $record->context['db.response.status_code']);
65+
$this->assertSame('access_denied', $record->context['error.type']);
66+
}
67+
}
68+
69+
public function testSuccessfulConnectionIsNotLoggedAndPassesThrough(): void
70+
{
71+
$handler = new TestHandler();
72+
$connection = $this->createMock(DriverConnection::class);
73+
$driver = $this->createMock(Driver::class);
74+
$driver->method('connect')->willReturn($connection);
75+
76+
$result = (new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
77+
->connect(['host' => 'db.internal']);
78+
79+
// The wrapped connection passes through untouched and nothing is logged.
80+
$this->assertSame($connection, $result);
81+
$this->assertSame([], $handler->getRecords());
82+
}
83+
84+
public function testOriginalExceptionInstanceIsRethrown(): void
85+
{
86+
$handler = new TestHandler();
87+
$exception = $this->driverException(2003, 'HY000');
88+
$driver = $this->driverThrowingException($exception);
89+
90+
try {
91+
(new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
92+
->connect(['host' => 'db.internal']);
93+
$this->fail('Expected the driver exception to be rethrown.');
94+
} catch (DriverException $caught) {
95+
// The exact original instance must propagate — not a wrapper that
96+
// would drop the driver code / previous cause DBAL relies on.
97+
$this->assertSame($exception, $caught);
98+
}
99+
}
100+
101+
public function testThrowingLoggerDoesNotMaskTheConnectionError(): void
102+
{
103+
$exception = $this->driverException(2003, 'HY000');
104+
$driver = $this->driverThrowingException($exception);
105+
106+
// A logger whose write fails — e.g. an unwritable LOG_PATH during the
107+
// very outage this middleware reports on.
108+
$logger = $this->createMock(LoggerInterface::class);
109+
$logger->method('log')->willThrowException(new \RuntimeException('log sink unavailable'));
110+
111+
try {
112+
(new ConnectionErrorDriver($driver, $logger))->connect(['host' => 'db.internal']);
113+
$this->fail('Expected the driver exception to be rethrown.');
114+
} catch (\Throwable $caught) {
115+
// The real DB connection error wins; the logging failure is swallowed.
116+
$this->assertSame($exception, $caught);
117+
}
118+
}
119+
120+
private function driverThrowing(int $code, string $sqlState): Driver
121+
{
122+
return $this->driverThrowingException($this->driverException($code, $sqlState));
123+
}
124+
125+
private function driverException(int $code, string $sqlState): DriverException
126+
{
127+
return new class($code, $sqlState) extends \RuntimeException implements DriverException {
128+
public function __construct(
129+
int $code,
130+
private readonly string $sqlState,
131+
) {
132+
parent::__construct('driver failure', $code);
133+
}
134+
135+
public function getSQLState(): ?string
136+
{
137+
return $this->sqlState;
138+
}
139+
};
140+
}
141+
142+
private function driverThrowingException(DriverException $exception): Driver
143+
{
144+
$driver = $this->createMock(Driver::class);
145+
$driver->method('connect')->willThrowException($exception);
146+
147+
return $driver;
148+
}
149+
}

0 commit comments

Comments
 (0)