Skip to content

Commit 4c6a7c9

Browse files
mescalanteaclaude
andauthored
[PAR-735] Fix memory exhaustion in LoggerService when logging complex objects (#16)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2f6e3cb commit 4c6a7c9

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

src/Service/Infrastructure/LoggerService.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function logMessage(LogData $data): void
4545
if (!empty($context)) {
4646
$contextData = array();
4747
foreach ($context as $item) {
48-
$contextData[$item->getName()] = print_r($item->getValue(), true);
48+
$contextData[$item->getName()] = $this->formatContextValue($item->getValue());
4949
}
5050

5151
$logMessage .= PHP_EOL . 'Context data: ' . print_r($contextData, true);
@@ -65,4 +65,27 @@ public function logMessage(LogData $data): void
6565
Log::info($logMessage);
6666
}
6767
}
68+
69+
/**
70+
* Formats a context value for safe logging without unbounded memory allocation.
71+
*
72+
* @param mixed $value
73+
*
74+
* @return string
75+
*/
76+
private function formatContextValue(mixed $value): string
77+
{
78+
if ($value instanceof \Throwable) {
79+
return get_class($value) . ': ' . $value->getMessage()
80+
. ' in ' . $value->getFile() . ':' . $value->getLine();
81+
}
82+
83+
if (is_object($value)) {
84+
$encoded = json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR, 5);
85+
86+
return $encoded !== false ? $encoded : get_class($value) . ' (not serializable)';
87+
}
88+
89+
return print_r($value, true);
90+
}
6891
}

tests/Unit/LoggerServiceTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace SeQura\Middleware\Tests\Unit;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use SeQura\Middleware\Service\Infrastructure\LoggerService;
7+
8+
/**
9+
* Class LoggerServiceTest
10+
*
11+
* @package SeQura\Middleware\Tests\Unit
12+
*/
13+
class LoggerServiceTest extends TestCase
14+
{
15+
private \ReflectionMethod $formatContextValue;
16+
private LoggerService $loggerService;
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
$this->loggerService = LoggerService::getInstance();
23+
24+
$this->formatContextValue = new \ReflectionMethod(LoggerService::class, 'formatContextValue');
25+
$this->formatContextValue->setAccessible(true);
26+
}
27+
28+
public function testThrowableIsFormattedWithoutPrintR(): void
29+
{
30+
$exception = new \RuntimeException('Something went wrong');
31+
32+
$result = $this->formatContextValue->invoke($this->loggerService, $exception);
33+
34+
$this->assertStringContainsString('RuntimeException: Something went wrong', $result);
35+
$this->assertStringContainsString(__FILE__, $result);
36+
// Must NOT contain full stack trace output from print_r
37+
$this->assertStringNotContainsString('#0 ', $result);
38+
$this->assertStringNotContainsString('Array', $result);
39+
}
40+
41+
public function testNestedExceptionOnlyShowsOuterException(): void
42+
{
43+
$previous = new \InvalidArgumentException('Root cause');
44+
$exception = new \RuntimeException('Wrapper', 0, $previous);
45+
46+
$result = $this->formatContextValue->invoke($this->loggerService, $exception);
47+
48+
$this->assertStringContainsString('RuntimeException: Wrapper', $result);
49+
// Should NOT recurse into the previous exception
50+
$this->assertStringNotContainsString('Root cause', $result);
51+
$this->assertStringNotContainsString('#0 ', $result);
52+
}
53+
54+
public function testObjectUsesJsonEncode(): void
55+
{
56+
$object = new \stdClass();
57+
$object->key = 'value';
58+
$object->number = 42;
59+
60+
$result = $this->formatContextValue->invoke($this->loggerService, $object);
61+
62+
$this->assertStringContainsString('"key":"value"', $result);
63+
$this->assertStringContainsString('"number":42', $result);
64+
}
65+
66+
public function testNonSerializableObjectFallsBackToClassName(): void
67+
{
68+
$object = new class {
69+
public float $value = NAN;
70+
};
71+
72+
$result = $this->formatContextValue->invoke($this->loggerService, $object);
73+
74+
// JSON_PARTIAL_OUTPUT_ON_ERROR will produce output or fall back to class name
75+
$this->assertNotEmpty($result);
76+
// Should not crash or produce unbounded output
77+
$this->assertLessThan(1000, strlen($result));
78+
}
79+
80+
public function testScalarStringIsReturnedAsIs(): void
81+
{
82+
$result = $this->formatContextValue->invoke($this->loggerService, 'simple string');
83+
84+
$this->assertEquals('simple string', $result);
85+
}
86+
87+
public function testIntegerIsReturnedViaPrintR(): void
88+
{
89+
$result = $this->formatContextValue->invoke($this->loggerService, 42);
90+
91+
$this->assertEquals('42', $result);
92+
}
93+
94+
public function testArrayIsReturnedViaPrintR(): void
95+
{
96+
$result = $this->formatContextValue->invoke($this->loggerService, ['a', 'b', 'c']);
97+
98+
$this->assertStringContainsString('a', $result);
99+
$this->assertStringContainsString('b', $result);
100+
$this->assertStringContainsString('c', $result);
101+
}
102+
103+
public function testNullIsHandled(): void
104+
{
105+
$result = $this->formatContextValue->invoke($this->loggerService, null);
106+
107+
$this->assertIsString($result);
108+
}
109+
}

0 commit comments

Comments
 (0)