From d45b873a46c324cc251ffa1cc3bc9465bde5bfcd Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Fri, 17 Jul 2026 11:32:21 +0200 Subject: [PATCH] Normalize exceptions in SyslogJsonFormatter log context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why is this change needed? Prior to this change, SyslogJsonFormatter::normalizeRecord() bypassed Monolog's normalization pipeline, passing $record->context/extra straight to JSON encoding without ever calling $this->normalize(...). Every logged exception is stored as a raw object under context['exception']. PHP's json_encode() on a plain object only serializes public properties, so instead of class/message/code/file, EngineBlock_Exception-based exceptions logged as {"sessionId":null,"userId":null,"spEntityId":null,"idpEntityId":null, "description":null} — its five unused public properties — making production exception logs useless for debugging. How does it address the issue? This change runs context and extra through $this->normalize(...) (inherited from JsonFormatter) before assembling the record array, so Throwable instances are converted via normalizeException() into their proper class/message/code/file representation, matching pre-7.2 behavior. https://github.com/OpenConext/OpenConext-engineblock/issues/2055 --- .../Monolog/Formatter/SyslogJsonFormatter.php | 4 +- .../Formatter/SyslogJsonFormatterTest.php | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/unit/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatterTest.php diff --git a/src/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatter.php b/src/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatter.php index 8fb22ccd1..f26f9e2f3 100644 --- a/src/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatter.php +++ b/src/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatter.php @@ -31,8 +31,8 @@ protected function normalizeRecord(LogRecord $record): array 'channel' => $record->channel, 'level' => $record->level->getName(), 'message' => $record->message, - 'context' => $record->context, - 'extra' => $record->extra, + 'context' => $this->normalize($record->context), + 'extra' => $this->normalize($record->extra), ]; } } diff --git a/tests/unit/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatterTest.php b/tests/unit/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatterTest.php new file mode 100644 index 000000000..69b4b0212 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatterTest.php @@ -0,0 +1,90 @@ +formatter = new SyslogJsonFormatter(); + } + + #[Test] + public function it_normalizes_an_exception_in_the_context_to_its_class_message_code_and_file(): void + { + $exception = new EngineBlock_Exception('Something went wrong'); + + $record = $this->recordWithContext(['exception' => $exception]); + + $decoded = json_decode($this->formatter->format($record), true); + + self::assertSame(EngineBlock_Exception::class, $decoded['context']['exception']['class']); + self::assertSame('Something went wrong', $decoded['context']['exception']['message']); + self::assertArrayHasKey('code', $decoded['context']['exception']); + self::assertArrayHasKey('file', $decoded['context']['exception']); + self::assertArrayNotHasKey('sessionId', $decoded['context']['exception']); + self::assertArrayNotHasKey('trace', $decoded['context']['exception']); + } + + #[Test] + public function it_normalizes_previous_exceptions_to_their_string_representation(): void + { + $exception = new RuntimeException('Outer', 0, new RuntimeException('Inner')); + + $record = $this->recordWithContext(['previous_exceptions' => [(string) $exception->getPrevious()]]); + + $decoded = json_decode($this->formatter->format($record), true); + + self::assertStringContainsString('Inner', $decoded['context']['previous_exceptions'][0]); + } + + #[Test] + public function it_leaves_scalar_and_array_context_values_unchanged(): void + { + $record = $this->recordWithContext(['route' => 'api_connections', 'route_parameters' => ['_format' => 'json']]); + + $decoded = json_decode($this->formatter->format($record), true); + + self::assertSame('api_connections', $decoded['context']['route']); + self::assertSame(['_format' => 'json'], $decoded['context']['route_parameters']); + } + + private function recordWithContext(array $context): LogRecord + { + return new LogRecord( + new DateTimeImmutable(), + 'app', + Level::Error, + 'An error was caught', + $context, + ); + } +}