feat: error-codes in flash messages to reference log entries
This commit is contained in:
@@ -112,6 +112,11 @@ class LogEntry
|
|||||||
return $this->getExtra()['request_id'] ?? '-';
|
return $this->getExtra()['request_id'] ?? '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getErrorCode(): string
|
||||||
|
{
|
||||||
|
return $this->getExtra()['error_code'] ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
public function getUri(): string
|
public function getUri(): string
|
||||||
{
|
{
|
||||||
return $this->getExtra()['uri'] ?? '';
|
return $this->getExtra()['uri'] ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logger;
|
||||||
|
|
||||||
|
use App\Service\ErrorCodeService;
|
||||||
|
use Monolog\Attribute\AsMonologProcessor;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\LogRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds error reference codes to log records with ERROR level or higher.
|
||||||
|
*
|
||||||
|
* When an error is logged, this processor generates a user-friendly error code
|
||||||
|
* and adds it to the log record's extra data. The code can then be displayed
|
||||||
|
* to users and used by support staff to locate the corresponding log entries.
|
||||||
|
*/
|
||||||
|
#[AsMonologProcessor]
|
||||||
|
class ErrorCodeProcessor
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ErrorCodeService $errorCodeService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(LogRecord $record): LogRecord
|
||||||
|
{
|
||||||
|
if ($record->level->value < Level::Error->value) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorCode = $this->errorCodeService->getErrorCode();
|
||||||
|
if (null === $errorCode) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->errorCodeService->markErrorOccurred();
|
||||||
|
$record->extra['error_code'] = $errorCode;
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and tracks error reference codes for user-facing error messages.
|
||||||
|
*
|
||||||
|
* Produces deterministic codes based on the current request ID, allowing users
|
||||||
|
* to report error codes to support staff who can then search logs by the code.
|
||||||
|
* The code format is E-XXXXXXXX (8 uppercase alphanumeric characters in base36).
|
||||||
|
*/
|
||||||
|
class ErrorCodeService
|
||||||
|
{
|
||||||
|
private ?string $errorCode = null;
|
||||||
|
private bool $hasError = false;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the error code for the current request.
|
||||||
|
*
|
||||||
|
* Generates a deterministic code from the request ID on first call and
|
||||||
|
* caches it for subsequent calls. Sequence suffixes like _1, _2 are
|
||||||
|
* stripped before hashing to ensure all requests in a sequence share
|
||||||
|
* the same error code.
|
||||||
|
*
|
||||||
|
* @return string|null The error code or null if no request context exists
|
||||||
|
*/
|
||||||
|
public function getErrorCode(): ?string
|
||||||
|
{
|
||||||
|
if (null !== $this->errorCode) {
|
||||||
|
return $this->errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getMainRequest();
|
||||||
|
if (null === $request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestId = $request->attributes->get('request_id');
|
||||||
|
if (null === $requestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->errorCode = $this->generateCode((string) $requestId);
|
||||||
|
|
||||||
|
return $this->errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks that an error has occurred during this request.
|
||||||
|
*
|
||||||
|
* Called by the ErrorCodeProcessor when a log entry with ERROR level
|
||||||
|
* or higher is created.
|
||||||
|
*/
|
||||||
|
public function markErrorOccurred(): void
|
||||||
|
{
|
||||||
|
$this->hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an error has been logged during this request.
|
||||||
|
*
|
||||||
|
* @return bool True if an error has been logged, false otherwise
|
||||||
|
*/
|
||||||
|
public function hasError(): bool
|
||||||
|
{
|
||||||
|
return $this->hasError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an error code from a request ID.
|
||||||
|
*
|
||||||
|
* Strips sequence suffixes, hashes the normalized ID, and converts
|
||||||
|
* to a base36 representation prefixed with E-.
|
||||||
|
*
|
||||||
|
* @param string $requestId The request ID to generate a code from
|
||||||
|
*
|
||||||
|
* @return string The generated error code in format E-XXXXXXXX
|
||||||
|
*/
|
||||||
|
private function generateCode(string $requestId): string
|
||||||
|
{
|
||||||
|
$normalizedId = preg_replace('/_\d+$/', '', $requestId);
|
||||||
|
$hash = md5((string) $normalizedId);
|
||||||
|
$hexPart = substr($hash, 0, 12);
|
||||||
|
$decimal = hexdec($hexPart);
|
||||||
|
$base36 = strtoupper(base_convert((string) $decimal, 10, 36));
|
||||||
|
$padded = str_pad($base36, 8, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
return 'E-'.substr($padded, 0, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Twig;
|
||||||
|
|
||||||
|
use App\Service\ErrorCodeService;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class ErrorCodeExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ErrorCodeService $errorCodeService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('error_code', [$this, 'getErrorCode']),
|
||||||
|
new TwigFunction('has_error_code', [$this, 'hasErrorCode']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getErrorCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->errorCodeService->getErrorCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasErrorCode(): bool
|
||||||
|
{
|
||||||
|
return $this->errorCodeService->hasError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if level == 'error' and has_error_code() %}
|
||||||
|
<div class="pt-8 text-white text-sm font-mono">
|
||||||
|
Fehlercode (bitte angeben): {{ error_code() }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Logger;
|
||||||
|
|
||||||
|
use App\Logger\ErrorCodeProcessor;
|
||||||
|
use App\Service\ErrorCodeService;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\LogRecord;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ErrorCodeProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
private ErrorCodeService $errorCodeService;
|
||||||
|
private ErrorCodeProcessor $processor;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->errorCodeService = $this->createMock(ErrorCodeService::class);
|
||||||
|
$this->processor = new ErrorCodeProcessor($this->errorCodeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresDebugLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Debug,
|
||||||
|
message: 'Debug message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('getErrorCode');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('error_code', $result->extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresInfoLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Info,
|
||||||
|
message: 'Info message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('getErrorCode');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('error_code', $result->extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresWarningLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Warning,
|
||||||
|
message: 'Warning message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('getErrorCode');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('error_code', $result->extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddsErrorCodeForErrorLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Error,
|
||||||
|
message: 'Error message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getErrorCode')
|
||||||
|
->willReturn('E-ABC12345');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error_code', $result->extra);
|
||||||
|
$this->assertSame('E-ABC12345', $result->extra['error_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddsErrorCodeForCriticalLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Critical,
|
||||||
|
message: 'Critical message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getErrorCode')
|
||||||
|
->willReturn('E-XYZ78901');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error_code', $result->extra);
|
||||||
|
$this->assertSame('E-XYZ78901', $result->extra['error_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddsErrorCodeForEmergencyLevel(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Emergency,
|
||||||
|
message: 'Emergency message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getErrorCode')
|
||||||
|
->willReturn('E-EMR99999');
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error_code', $result->extra);
|
||||||
|
$this->assertSame('E-EMR99999', $result->extra['error_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotAddCodeWhenServiceReturnsNull(): void
|
||||||
|
{
|
||||||
|
$record = new LogRecord(
|
||||||
|
datetime: new \DateTimeImmutable(),
|
||||||
|
channel: 'test',
|
||||||
|
level: Level::Error,
|
||||||
|
message: 'Error message',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getErrorCode')
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
$this->errorCodeService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('markErrorOccurred');
|
||||||
|
|
||||||
|
$result = ($this->processor)($record);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('error_code', $result->extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\ErrorCodeService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
class ErrorCodeServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private RequestStack $requestStack;
|
||||||
|
private ErrorCodeService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->requestStack = new RequestStack();
|
||||||
|
$this->service = new ErrorCodeService($this->requestStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNullWithoutRequest(): void
|
||||||
|
{
|
||||||
|
$result = $this->service->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNullWithoutRequestId(): void
|
||||||
|
{
|
||||||
|
$request = new Request();
|
||||||
|
$this->requestStack->push($request);
|
||||||
|
|
||||||
|
$result = $this->service->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneratesCodeWithRequestId(): void
|
||||||
|
{
|
||||||
|
$request = new Request();
|
||||||
|
$request->attributes->set('request_id', 'abc123');
|
||||||
|
$this->requestStack->push($request);
|
||||||
|
|
||||||
|
$result = $this->service->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertMatchesRegularExpression('/^E-[A-Z0-9]{8}$/', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneratesConsistentCodeForSameRequestId(): void
|
||||||
|
{
|
||||||
|
$request = new Request();
|
||||||
|
$request->attributes->set('request_id', 'abc123');
|
||||||
|
$this->requestStack->push($request);
|
||||||
|
|
||||||
|
$result1 = $this->service->getErrorCode();
|
||||||
|
$result2 = $this->service->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertSame($result1, $result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneratesDifferentCodesForDifferentRequestIds(): void
|
||||||
|
{
|
||||||
|
$request1 = new Request();
|
||||||
|
$request1->attributes->set('request_id', 'abc123');
|
||||||
|
$this->requestStack->push($request1);
|
||||||
|
|
||||||
|
$service1 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result1 = $service1->getErrorCode();
|
||||||
|
|
||||||
|
$this->requestStack->pop();
|
||||||
|
|
||||||
|
$request2 = new Request();
|
||||||
|
$request2->attributes->set('request_id', 'xyz789');
|
||||||
|
$this->requestStack->push($request2);
|
||||||
|
|
||||||
|
$service2 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result2 = $service2->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertNotSame($result1, $result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSequenceSuffixesProduceSameCode(): void
|
||||||
|
{
|
||||||
|
$request1 = new Request();
|
||||||
|
$request1->attributes->set('request_id', 'abc123_1');
|
||||||
|
$this->requestStack->push($request1);
|
||||||
|
|
||||||
|
$service1 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result1 = $service1->getErrorCode();
|
||||||
|
|
||||||
|
$this->requestStack->pop();
|
||||||
|
|
||||||
|
$request2 = new Request();
|
||||||
|
$request2->attributes->set('request_id', 'abc123_2');
|
||||||
|
$this->requestStack->push($request2);
|
||||||
|
|
||||||
|
$service2 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result2 = $service2->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertSame($result1, $result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSequenceSuffixMatchesBaseId(): void
|
||||||
|
{
|
||||||
|
$request1 = new Request();
|
||||||
|
$request1->attributes->set('request_id', 'abc123');
|
||||||
|
$this->requestStack->push($request1);
|
||||||
|
|
||||||
|
$service1 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result1 = $service1->getErrorCode();
|
||||||
|
|
||||||
|
$this->requestStack->pop();
|
||||||
|
|
||||||
|
$request2 = new Request();
|
||||||
|
$request2->attributes->set('request_id', 'abc123_5');
|
||||||
|
$this->requestStack->push($request2);
|
||||||
|
|
||||||
|
$service2 = new ErrorCodeService($this->requestStack);
|
||||||
|
$result2 = $service2->getErrorCode();
|
||||||
|
|
||||||
|
$this->assertSame($result1, $result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasErrorReturnsFalseByDefault(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->service->hasError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkErrorOccurredSetsFlag(): void
|
||||||
|
{
|
||||||
|
$this->service->markErrorOccurred();
|
||||||
|
|
||||||
|
$this->assertTrue($this->service->hasError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasErrorRemainsTrueAfterMultipleCalls(): void
|
||||||
|
{
|
||||||
|
$this->service->markErrorOccurred();
|
||||||
|
$this->service->markErrorOccurred();
|
||||||
|
|
||||||
|
$this->assertTrue($this->service->hasError());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user