Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "phpnomad/logger",
"description": "",
"version": "1.2.1",
"version": "1.2.2",
"type": "library",
"homepage": "https://github.com/phpnomad/core",
"readme": "README.md",
Expand Down
74 changes: 71 additions & 3 deletions lib/Traits/CanLogException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,84 @@
use Exception;
use PHPNomad\Logger\Enums\LoggerLevel;
use PHPNomad\Logger\Interfaces\LoggerStrategy;
use Throwable;

trait CanLogException
{
/**
* Hard cap on how many levels of the exception chain are folded into the
* logged message, guarding against pathologically deep chains.
*/
protected int $maxLoggedExceptionDepth = 10;

public function logException(Exception $e, string $message = '', array $context = [], $level = null)
{
if(!$level){
if (!$level) {
$level = LoggerLevel::Critical;
}

$context['exception'] = $e;
$this->$level(implode(' - ', [$message, $e->getMessage()]), $context);
$this->$level(implode(' - ', $this->buildExceptionMessageParts($e, $message)), $context);
}

/**
* Flattens the prefix message and the exception chain into the message
* parts that get logged.
*
* Datastore and other infrastructure exceptions deliberately surface a
* stable, client-safe message and carry the real cause (e.g. the SQL
* driver error) as a chained previous exception. Logging only the outer
* message would discard that cause, so the chain is walked here.
*
* A chain message is skipped when it is already contained in a part we
* have collected — some wrappers embed the cause's message in their own
* text *and* chain the cause, which would otherwise log it twice. The
* walk is also bounded by {@see $maxLoggedExceptionDepth} and a cycle
* guard so the message can never grow without limit.
*
* @return list<string>
*/
protected function buildExceptionMessageParts(Throwable $e, string $message): array
{
$parts = [];

if ($message !== '') {
$parts[] = $message;
}

$seen = [];
$current = $e;
$depth = 0;

while ($current instanceof Throwable && $depth < $this->maxLoggedExceptionDepth && !in_array($current, $seen, true)) {
$seen[] = $current;
$depth++;

$currentMessage = $current->getMessage();
if ($currentMessage !== '' && !$this->messageAlreadyCollected($currentMessage, $parts)) {
$parts[] = $currentMessage;
}

$current = $current->getPrevious();
}

return $parts;
}

/**
* Whether a chain message is already represented in the collected parts,
* so wrappers that embed their cause's message aren't logged twice.
*
* @param list<string> $parts
*/
protected function messageAlreadyCollected(string $message, array $parts): bool
{
foreach ($parts as $part) {
if (strpos($part, $message) !== false) {
return true;
}
}

return false;
}
}
}
157 changes: 157 additions & 0 deletions tests/Unit/CanLogExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

namespace PHPNomad\Logger\Tests\Unit;

use Exception;
use PHPNomad\Logger\Enums\LoggerLevel;
use PHPNomad\Logger\Tests\TestCase;
use PHPNomad\Logger\Traits\CanLogException;

class CanLogExceptionTest extends TestCase
{
/**
* Builds a logger that records the level, message, and context of each call.
*/
protected function makeLogger(): object
{
return new class () {
use CanLogException;

/** @var array{level:string,message:string,context:array}|null */
public ?array $logged = null;

public function __call(string $name, array $arguments): void
{
$this->logged = [
'level' => $name,
'message' => $arguments[0] ?? '',
'context' => $arguments[1] ?? [],
];
}
};
}

public function testLogsAtCriticalByDefault(): void
{
$logger = $this->makeLogger();

$logger->logException(new Exception('boom'));

$this->assertSame(LoggerLevel::Critical, $logger->logged['level']);
}

public function testRespectsExplicitLevel(): void
{
$logger = $this->makeLogger();

$logger->logException(new Exception('boom'), '', [], LoggerLevel::Warning);

$this->assertSame(LoggerLevel::Warning, $logger->logged['level']);
}

public function testKeepsExceptionInContext(): void
{
$logger = $this->makeLogger();
$e = new Exception('boom');

$logger->logException($e);

$this->assertSame($e, $logger->logged['context']['exception']);
}

public function testPreservesProvidedContext(): void
{
$logger = $this->makeLogger();

$logger->logException(new Exception('boom'), '', ['orgId' => 7]);

$this->assertSame(7, $logger->logged['context']['orgId']);
}

public function testIncludesPrefixMessageAndExceptionMessage(): void
{
$logger = $this->makeLogger();

$logger->logException(new Exception('boom'), 'while saving');

$this->assertStringContainsString('while saving', $logger->logged['message']);
$this->assertStringContainsString('boom', $logger->logged['message']);
}

public function testWalksChainedCauseIntoMessage(): void
{
$logger = $this->makeLogger();

// Mirrors the datastore backend: a stable, client-safe outer message
// with the real driver error chained as the previous exception.
$cause = new Exception('SQLSTATE[42S22]: Column not found: 1054 Unknown column "foo"');
$outer = new Exception('Failed to execute query.', 0, $cause);

$logger->logException($outer);

$message = $logger->logged['message'];
$this->assertStringContainsString('Failed to execute query.', $message);
$this->assertStringContainsString('Unknown column "foo"', $message);
}

public function testWalksMultiLevelChain(): void
{
$logger = $this->makeLogger();

$root = new Exception('root cause');
$mid = new Exception('mid layer', 0, $root);
$outer = new Exception('outer', 0, $mid);

$logger->logException($outer);

$message = $logger->logged['message'];
$this->assertStringContainsString('outer', $message);
$this->assertStringContainsString('mid layer', $message);
$this->assertStringContainsString('root cause', $message);
}

public function testEmptyPrefixDoesNotProduceLeadingSeparator(): void
{
$logger = $this->makeLogger();

$logger->logException(new Exception('boom'));

$this->assertSame('boom', $logger->logged['message']);
}

public function testDoesNotRepeatCauseAlreadyEmbeddedInWrapperMessage(): void
{
$logger = $this->makeLogger();

// Mirrors QueryStrategy: the wrapper embeds the driver message in its
// own text AND chains the driver exception as the cause.
$driverMessage = 'SQLSTATE[42S22]: Unknown column "foo"';
$cause = new Exception($driverMessage);
$outer = new Exception('Invalid query: ' . $driverMessage, 0, $cause);

$logger->logException($outer);

$this->assertSame(
1,
substr_count($logger->logged['message'], $driverMessage),
'The chained cause should not be logged twice when the wrapper already embeds it.'
);
}

public function testBoundsPathologicallyDeepChains(): void
{
$logger = $this->makeLogger();

$e = new Exception('level-0');
for ($i = 1; $i <= 50; $i++) {
$e = new Exception('level-' . $i, 0, $e);
}

$logger->logException($e);

// Default cap is 10 levels: the outermost is kept, deeper roots dropped.
$this->assertStringContainsString('level-50', $logger->logged['message']);
$this->assertStringNotContainsString('level-0', $logger->logged['message']);
$this->assertSame(10, substr_count($logger->logged['message'], 'level-'));
}
}
Loading