Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8cb3bb0
feat(logging): log DB connection-establishment errors on a database c…
turegjorup Jun 2, 2026
a8db82f
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
b80dacc
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
1618cd6
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
342435c
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
fcaf287
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
613467b
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 8, 2026
232e1e5
refactor(logging): name the database channel logger parameter for its…
turegjorup Jun 8, 2026
f9cd19f
feat(logging): add OTel error.type to db connection errors; rename to…
turegjorup Jun 9, 2026
87bf74c
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
cc31323
fix(logging): exclude database channel from fingers_crossed main handler
turegjorup Jun 9, 2026
cad19f2
refactor(logging): use OTel server.address/server.port for db connect…
turegjorup Jun 9, 2026
91213c9
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
3327271
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
d9530ea
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
c7b10c2
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
685ee07
Merge remote-tracking branch 'origin/feature/logging-phpstan-gate' in…
turegjorup Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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.

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

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ LOG_LEVEL_FEED=
LOG_LEVEL_INTERACTIVE=
LOG_LEVEL_CACHE=
LOG_LEVEL_OUTBOUND_HTTP=
LOG_LEVEL_DATABASE=
###< Logging ###
```

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/packages/monolog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ monolog:
'feed',
'interactive',
'cache',
'database',
]
7 changes: 7 additions & 0 deletions config/packages/prod/monolog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,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
Expand Down
9 changes: 9 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ services:
arguments:
$feedLogger: '@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:
$databaseLogger: '@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'

Expand Down
26 changes: 26 additions & 0 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,29 @@ 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.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. |
| `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`,
`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.
100 changes: 100 additions & 0 deletions src/Doctrine/Middleware/ConnectionErrorDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace App\Doctrine\Middleware;

use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Exception as DriverException;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Psr\Log\LoggerInterface;

/**
* Logs MariaDB/MySQL connection-establishment failures on the `database`
* channel, reading the raw driver error code (a middleware sits below DBAL's
* exception conversion, which does not classify 1040/1203/2003/2013 as
* ConnectionException). Fires for every SAPI and regardless of whether
* application code later swallows the exception. The original exception is
* rethrown unchanged.
*/
final class ConnectionErrorDriver extends AbstractDriverMiddleware
{
/**
* MySQL/MariaDB error numbers that indicate connection PRESSURE or
* unreachability (as opposed to e.g. a bad credential typo). Logged at
* `critical`; other connect failures at `error`.
*
* @var list<int>
*/
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
];

/**
* 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<int, string>
*/
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,
) {
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';

$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(),
// 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).
}

throw $e;
}
}
}
26 changes: 26 additions & 0 deletions src/Doctrine/Middleware/ConnectionErrorMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Doctrine\Middleware;

use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Middleware;
use Psr\Log\LoggerInterface;

/**
* Wraps the DBAL driver so connection-establishment failures are logged on the
* `database` channel before they propagate (ADR 011). Logging only — there is
* no reconnect/retry.
*/
final readonly class ConnectionErrorMiddleware implements Middleware
{
public function __construct(
private LoggerInterface $databaseLogger,
) {}

public function wrap(Driver $driver): Driver
{
return new ConnectionErrorDriver($driver, $this->databaseLogger);
}
}
149 changes: 149 additions & 0 deletions tests/Doctrine/Middleware/ConnectionErrorDriverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

declare(strict_types=1);

namespace App\Tests\Doctrine\Middleware;

use App\Doctrine\Middleware\ConnectionErrorDriver;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Exception as DriverException;
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

class ConnectionErrorDriverTest extends TestCase
{
public function testConnectionPressureCodeIsLoggedAsCriticalWithoutSecrets(): void
{
$handler = new TestHandler();
$driver = $this->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.response.status_code']);
$this->assertSame('connection_refused', $record->context['error.type']);
$this->assertSame('HY000', $record->context['db.sqlstate']);
$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);
$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.response.status_code']);
$this->assertSame('access_denied', $record->context['error.type']);
}
}

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;
}
}
Loading
Loading