From afe5aef9f7141432411c390f3c844a19111a6941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 23 Jan 2026 11:06:24 +0100 Subject: [PATCH] feat: error-codes in flash messages to reference log entries --- src/Entity/LogEntry.php | 5 + src/Logger/ErrorCodeProcessor.php | 43 ++++++ src/Service/ErrorCodeService.php | 99 +++++++++++++ src/Twig/ErrorCodeExtension.php | 35 +++++ templates/_partials/_alert.html.twig | 5 + tests/Logger/ErrorCodeProcessorTest.php | 184 ++++++++++++++++++++++++ tests/Service/ErrorCodeServiceTest.php | 146 +++++++++++++++++++ 7 files changed, 517 insertions(+) create mode 100644 src/Logger/ErrorCodeProcessor.php create mode 100644 src/Service/ErrorCodeService.php create mode 100644 src/Twig/ErrorCodeExtension.php create mode 100644 tests/Logger/ErrorCodeProcessorTest.php create mode 100644 tests/Service/ErrorCodeServiceTest.php diff --git a/src/Entity/LogEntry.php b/src/Entity/LogEntry.php index a766fcc..4601eae 100644 --- a/src/Entity/LogEntry.php +++ b/src/Entity/LogEntry.php @@ -112,6 +112,11 @@ class LogEntry return $this->getExtra()['request_id'] ?? '-'; } + public function getErrorCode(): string + { + return $this->getExtra()['error_code'] ?? '-'; + } + public function getUri(): string { return $this->getExtra()['uri'] ?? ''; diff --git a/src/Logger/ErrorCodeProcessor.php b/src/Logger/ErrorCodeProcessor.php new file mode 100644 index 0000000..794ae13 --- /dev/null +++ b/src/Logger/ErrorCodeProcessor.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/src/Service/ErrorCodeService.php b/src/Service/ErrorCodeService.php new file mode 100644 index 0000000..203863a --- /dev/null +++ b/src/Service/ErrorCodeService.php @@ -0,0 +1,99 @@ +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); + } +} diff --git a/src/Twig/ErrorCodeExtension.php b/src/Twig/ErrorCodeExtension.php new file mode 100644 index 0000000..e321748 --- /dev/null +++ b/src/Twig/ErrorCodeExtension.php @@ -0,0 +1,35 @@ +errorCodeService->getErrorCode(); + } + + public function hasErrorCode(): bool + { + return $this->errorCodeService->hasError(); + } +} diff --git a/templates/_partials/_alert.html.twig b/templates/_partials/_alert.html.twig index 725dd31..428a910 100644 --- a/templates/_partials/_alert.html.twig +++ b/templates/_partials/_alert.html.twig @@ -35,6 +35,11 @@ {% endif %} + {% if level == 'error' and has_error_code() %} +
+ Fehlercode (bitte angeben): {{ error_code() }} +
+ {% endif %} diff --git a/tests/Logger/ErrorCodeProcessorTest.php b/tests/Logger/ErrorCodeProcessorTest.php new file mode 100644 index 0000000..0e32898 --- /dev/null +++ b/tests/Logger/ErrorCodeProcessorTest.php @@ -0,0 +1,184 @@ +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); + } +} diff --git a/tests/Service/ErrorCodeServiceTest.php b/tests/Service/ErrorCodeServiceTest.php new file mode 100644 index 0000000..551bc72 --- /dev/null +++ b/tests/Service/ErrorCodeServiceTest.php @@ -0,0 +1,146 @@ +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()); + } +}