Skip to content

Commit ddb56cc

Browse files
authored
Normalize exceptions in SyslogJsonFormatter log context (#2060)
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. #2055
1 parent 653a13a commit ddb56cc

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

src/OpenConext/EngineBlockBundle/Monolog/Formatter/SyslogJsonFormatter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ protected function normalizeRecord(LogRecord $record): array
3131
'channel' => $record->channel,
3232
'level' => $record->level->getName(),
3333
'message' => $record->message,
34-
'context' => $record->context,
35-
'extra' => $record->extra,
34+
'context' => $this->normalize($record->context),
35+
'extra' => $this->normalize($record->extra),
3636
];
3737
}
3838
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2026 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenConext\EngineBlockBundle\Monolog\Formatter;
22+
23+
use DateTimeImmutable;
24+
use EngineBlock_Exception;
25+
use Monolog\Level;
26+
use Monolog\LogRecord;
27+
use PHPUnit\Framework\Attributes\Test;
28+
use PHPUnit\Framework\TestCase;
29+
use RuntimeException;
30+
31+
class SyslogJsonFormatterTest extends TestCase
32+
{
33+
private SyslogJsonFormatter $formatter;
34+
35+
protected function setUp(): void
36+
{
37+
$this->formatter = new SyslogJsonFormatter();
38+
}
39+
40+
#[Test]
41+
public function it_normalizes_an_exception_in_the_context_to_its_class_message_code_and_file(): void
42+
{
43+
$exception = new EngineBlock_Exception('Something went wrong');
44+
45+
$record = $this->recordWithContext(['exception' => $exception]);
46+
47+
$decoded = json_decode($this->formatter->format($record), true);
48+
49+
self::assertSame(EngineBlock_Exception::class, $decoded['context']['exception']['class']);
50+
self::assertSame('Something went wrong', $decoded['context']['exception']['message']);
51+
self::assertArrayHasKey('code', $decoded['context']['exception']);
52+
self::assertArrayHasKey('file', $decoded['context']['exception']);
53+
self::assertArrayNotHasKey('sessionId', $decoded['context']['exception']);
54+
self::assertArrayNotHasKey('trace', $decoded['context']['exception']);
55+
}
56+
57+
#[Test]
58+
public function it_normalizes_previous_exceptions_to_their_string_representation(): void
59+
{
60+
$exception = new RuntimeException('Outer', 0, new RuntimeException('Inner'));
61+
62+
$record = $this->recordWithContext(['previous_exceptions' => [(string) $exception->getPrevious()]]);
63+
64+
$decoded = json_decode($this->formatter->format($record), true);
65+
66+
self::assertStringContainsString('Inner', $decoded['context']['previous_exceptions'][0]);
67+
}
68+
69+
#[Test]
70+
public function it_leaves_scalar_and_array_context_values_unchanged(): void
71+
{
72+
$record = $this->recordWithContext(['route' => 'api_connections', 'route_parameters' => ['_format' => 'json']]);
73+
74+
$decoded = json_decode($this->formatter->format($record), true);
75+
76+
self::assertSame('api_connections', $decoded['context']['route']);
77+
self::assertSame(['_format' => 'json'], $decoded['context']['route_parameters']);
78+
}
79+
80+
private function recordWithContext(array $context): LogRecord
81+
{
82+
return new LogRecord(
83+
new DateTimeImmutable(),
84+
'app',
85+
Level::Error,
86+
'An error was caught',
87+
$context,
88+
);
89+
}
90+
}

0 commit comments

Comments
 (0)