Skip to content

Commit 79c07ce

Browse files
turegjorupclaude
andcommitted
feat(logging): log DB connection-establishment errors on a database channel
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) <noreply@anthropic.com>
1 parent 57d45c1 commit 79c07ce

9 files changed

Lines changed: 231 additions & 1 deletion

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ LOG_LEVEL_FEED=
145145
LOG_LEVEL_INTERACTIVE=
146146
# Per-channel override; empty or unset inherits LOG_LEVEL.
147147
LOG_LEVEL_CACHE=
148+
# Per-channel override; empty or unset inherits LOG_LEVEL. Connection errors
149+
# are logged at error/critical, so they emit regardless of this threshold.
150+
LOG_LEVEL_DATABASE=
148151
###< Logging ###
149152

150153
###> App ###

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ All notable changes to this project will be documented in this file.
2626
(`logging.silentCatch`, `logging.interpolatedLogMessage`, `logging.exceptionContextKey`) and
2727
migrated the previously silent catch sites to log the exception, surface it, or be explicitly
2828
annotated as intentional.
29+
- Added a `database` log channel and a DBAL driver middleware that logs MariaDB
30+
connection-establishment failures (classified by raw driver error code; connection
31+
pressure/unreachability at `critical`, other failures at `error`) so operators without database
32+
access get a failure signal. Logging-only — no reconnect/retry.
2933
- Removed the deprecated feed types `SparkleIOFeedType`, `EventDatabaseApiFeedType` and `KobaFeedType`.
3034
Made the unknown-feed-type handling consistent: **reads degrade, writes are rejected.** Feed sources
3135
(and feeds) that reference a removed type keep loading — item and collection reads return them with no

config/packages/monolog.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
monolog:
22
channels:
3-
['app_http', 'auth', 'screen', 'media', 'feed', 'interactive', 'cache']
3+
[
4+
'app_http',
5+
'auth',
6+
'screen',
7+
'media',
8+
'feed',
9+
'interactive',
10+
'cache',
11+
'database',
12+
]

config/packages/prod/monolog.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ monolog:
5757
level: '%env(default:app.log_level:LOG_LEVEL_CACHE)%'
5858
channels: ['cache']
5959
formatter: monolog.formatter.json
60+
database:
61+
type: stream
62+
path: '%env(resolve:LOG_PATH)%'
63+
level: '%env(default:app.log_level:LOG_LEVEL_DATABASE)%'
64+
channels: ['database']
65+
formatter: monolog.formatter.json
6066
console:
6167
type: console
6268
process_psr_3_messages: false

config/services.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ services:
101101
arguments:
102102
$logger: '@monolog.logger.feed'
103103

104+
# Logs DB connection-establishment failures on the `database` channel.
105+
# The explicit doctrine.middleware tag is required: doctrine-bundle collects
106+
# tagged services in MiddlewaresPass; implementing the interface is not enough.
107+
App\Doctrine\Middleware\ConnectionErrorMiddleware:
108+
arguments:
109+
$logger: '@monolog.logger.database'
110+
tags:
111+
- { name: doctrine.middleware }
112+
104113
Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler'
105114
Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler'
106115

docs/logging.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,27 @@ exclusive. Fields that don't apply to a record are absent, never `null`.
118118
in `context`/`extra`.
119119

120120
Still: do not put credentials or token strings into log context in the first place.
121+
122+
## Database connection errors
123+
124+
A DBAL driver middleware (`App\Doctrine\Middleware\ConnectionErrorMiddleware`) logs
125+
connection-**establishment** failures on the `database` channel so operators without
126+
database access get a failure signal (ADR 011). It reads the raw driver error code (a
127+
middleware sits below DBAL's exception conversion, which does not classify
128+
`1040`/`1203`/`2003` as `ConnectionException`), fires for every SAPI (web, CLI, Messenger),
129+
and rethrows the original exception unchanged — it is logging-only, with no reconnect.
130+
131+
The record uses the static message `Database connection failed` with:
132+
133+
| Key | Meaning |
134+
|---|---|
135+
| `event` | Always `db.connection_error`. |
136+
| `db.error_code` | Numeric MySQL/MariaDB error number (e.g. `1040`, `1045`, `2003`). |
137+
| `db.sqlstate` | The SQLSTATE, when available. |
138+
| `db.host` | Connection host only — never the password or full DSN. |
139+
| `exception` | The structured `\Throwable` (via `ExceptionContextProcessor`). |
140+
141+
Connection **pressure / unreachability** codes (`1040`, `1203`, `1226`, `2002`, `2003`,
142+
`2005`) are logged at `critical`; other connect failures (e.g. a `1045` credential error)
143+
at `error`. Both levels emit regardless of `LOG_LEVEL_DATABASE`, which only gates
144+
lower-severity `database`-channel records. Mid-query drops (`2006`/`2013`) are out of scope.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
public function __construct(
40+
Driver $driver,
41+
private readonly LoggerInterface $logger,
42+
) {
43+
parent::__construct($driver);
44+
}
45+
46+
public function connect(array $params): DriverConnection
47+
{
48+
try {
49+
return parent::connect($params);
50+
} catch (DriverException $e) {
51+
$code = $e->getCode();
52+
$level = in_array($code, self::CONNECTION_PRESSURE, true) ? 'critical' : 'error';
53+
54+
$this->logger->log($level, 'Database connection failed', [
55+
'event' => 'db.connection_error',
56+
'db.error_code' => $code,
57+
'db.sqlstate' => $e->getSQLState(),
58+
'db.host' => $params['host'] ?? null, // host only — never password/DSN
59+
'exception' => $e,
60+
]);
61+
62+
throw $e;
63+
}
64+
}
65+
}
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 $logger,
20+
) {}
21+
22+
public function wrap(Driver $driver): Driver
23+
{
24+
return new ConnectionErrorDriver($driver, $this->logger);
25+
}
26+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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\Exception as DriverException;
10+
use Monolog\Handler\TestHandler;
11+
use Monolog\Level;
12+
use Monolog\Logger;
13+
use PHPUnit\Framework\TestCase;
14+
15+
class ConnectionErrorDriverTest extends TestCase
16+
{
17+
public function testConnectionPressureCodeIsLoggedAsCriticalWithoutSecrets(): void
18+
{
19+
$handler = new TestHandler();
20+
$driver = $this->driverThrowing(2003, 'HY000'); // CR_CONN_HOST_ERROR
21+
22+
$this->expectException(DriverException::class);
23+
24+
try {
25+
(new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
26+
->connect(['host' => 'db.internal', 'password' => 's3cr3t', 'user' => 'app']);
27+
} finally {
28+
$records = $handler->getRecords();
29+
$this->assertCount(1, $records);
30+
$record = $records[0];
31+
32+
$this->assertSame(Level::Critical, $record->level);
33+
$this->assertSame('database', $record->channel);
34+
$this->assertSame('Database connection failed', $record->message);
35+
$this->assertSame('db.connection_error', $record->context['event']);
36+
$this->assertSame(2003, $record->context['db.error_code']);
37+
$this->assertSame('HY000', $record->context['db.sqlstate']);
38+
$this->assertSame('db.internal', $record->context['db.host']);
39+
40+
// The middleware logs the host only — never the password or DSN.
41+
$serialised = json_encode($record->toArray(), JSON_THROW_ON_ERROR | JSON_PARTIAL_OUTPUT_ON_ERROR);
42+
$this->assertStringNotContainsString('s3cr3t', $serialised);
43+
$this->assertArrayNotHasKey('password', $record->context);
44+
}
45+
}
46+
47+
public function testCredentialErrorIsLoggedAsError(): void
48+
{
49+
$handler = new TestHandler();
50+
$driver = $this->driverThrowing(1045, '28000'); // ER_ACCESS_DENIED_ERROR
51+
52+
try {
53+
(new ConnectionErrorDriver($driver, new Logger('database', [$handler])))
54+
->connect(['host' => 'db.internal']);
55+
$this->fail('Expected the driver exception to be rethrown.');
56+
} catch (DriverException) {
57+
$record = $handler->getRecords()[0];
58+
$this->assertSame(Level::Error, $record->level);
59+
$this->assertSame(1045, $record->context['db.error_code']);
60+
}
61+
}
62+
63+
private function driverThrowing(int $code, string $sqlState): Driver
64+
{
65+
$exception = new class($code, $sqlState) extends \RuntimeException implements DriverException {
66+
public function __construct(
67+
int $code,
68+
private readonly string $sqlState,
69+
) {
70+
parent::__construct('driver failure', $code);
71+
}
72+
73+
public function getSQLState(): ?string
74+
{
75+
return $this->sqlState;
76+
}
77+
};
78+
79+
$driver = $this->createMock(Driver::class);
80+
$driver->method('connect')->willThrowException($exception);
81+
82+
return $driver;
83+
}
84+
}

0 commit comments

Comments
 (0)