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
Expand Up @@ -43,7 +43,7 @@
"scripts": {
"cs:diff": "php-cs-fixer fix --dry-run -v --diff --show-progress dots",
"cs:fix": "php-cs-fixer fix -v",
"test": "phpunit",
"test": "XDEBUG_MODE=coverage phpunit",
"psalm": "psalm"
},
"autoload": {
Expand Down
54 changes: 54 additions & 0 deletions src/Internal/ContextProcessor/BuiltInTypeProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Processor for built-in PHP types (null, scalar, array).
*
* Handles null and scalar values by passing them through as-is since they are already
* suitable for structured logging. Arrays are processed recursively to handle nested structures.
*
* This consolidates the functionality of the former NullProcessor, ScalarProcessor, and ArrayProcessor
* for better performance and simpler architecture.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<null|scalar|array<array-key, mixed>, null|scalar|array<array-key, mixed>>
*/
class BuiltInTypeProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
return $value === null || \is_scalar($value) || \is_array($value);
}

/**
* @param null|scalar|array<array-key, mixed> $value
* @param callable(mixed): mixed $recursiveProcessor
* @return null|scalar|array<array-key, mixed>
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
// Handle arrays recursively
if (\is_array($value)) {
/** @var array<array-key, mixed> $processed */
$processed = [];

/**
* @var array-key $key
* @var mixed $item
*/
foreach ($value as $key => $item) {
/** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
$processed[$key] = $recursiveProcessor($item);
}

return $processed;
}

// Null and scalar values are already suitable for logging
return $value;
}
}
33 changes: 33 additions & 0 deletions src/Internal/ContextProcessor/ContextProcessorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Interface for context data processors.
*
* Each processor handles a specific type of data and converts it to a
* format suitable for structured logging.
*
* @internal This interface is internal to the PSR Logger implementation and should not be used directly.
*
* @template TValue The input value type
* @template TProcessed The processed output type
*/
interface ContextProcessorInterface
{
/**
* Check if this processor can handle the given value.
*/
public function canProcess(mixed $value): bool;

/**
* Process the value and return a serializable representation.
*
* @param TValue $value The value to process
* @param callable(mixed): mixed $recursiveProcessor Function to process nested values recursively
* @return TProcessed Processed value suitable for logging
*/
public function process(mixed $value, callable $recursiveProcessor): mixed;
}
102 changes: 102 additions & 0 deletions src/Internal/ContextProcessor/ContextProcessorManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Manager for context data processors.
*
* Coordinates multiple processors to handle different data types for structured logging.
* Processors are executed in registration order, with the first matching processor handling the value.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*/
class ContextProcessorManager
{
/** @var array<ContextProcessorInterface> */
private array $processors = [];

public function __construct()
{
$this->registerDefaultProcessors();
}

/**
* Register a processor.
* Processors are checked in the order they are added.
*/
public function addProcessor(ContextProcessorInterface $processor): void
{
$this->processors[] = $processor;
}

/**
* Process context data recursively.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $context
* @return array<string, mixed>
*/
public function processContext(array $context): array
{
if (empty($context)) {
return [];
}

/** @var array<string, mixed> $processed */
$processed = [];

/**
* @var TKey $key
* @var TValue $value
*/
foreach ($context as $key => $value) {
$stringKey = (string) $key;
/** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
$processed[$stringKey] = $this->processValue($value);
}

return $processed;
}
Comment on lines +34 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add recursion guards (max depth + object cycle detection) to prevent runaway processing

Self-referential arrays/objects in context can cause infinite recursion and memory exhaustion. Add a shallow guard and track visited objects per processing call. Keep the external API unchanged by capturing guard state in the recursive callback.

Illustrative diff:

-    public function processContext(array $context): array
+    public function processContext(array $context): array
     {
         if (empty($context)) {
             return [];
         }
 
         /** @var array<string, mixed> $processed */
         $processed = [];
+        $visited = new \SplObjectStorage();
+        $maxDepth = 20;
+
+        $processValue = function (mixed $value, int $depth = 0) use (&$processValue, $visited, $maxDepth): mixed {
+            if ($depth > $maxDepth) {
+                return '__max_depth_exceeded__';
+            }
+
+            if (\is_object($value)) {
+                if ($visited->contains($value)) {
+                    return '__circular_ref:' . \get_class($value) . '#' . \spl_object_id($value) . '__';
+                }
+                $visited->attach($value);
+            }
+
+            foreach ($this->processors as $processor) {
+                if ($processor->canProcess($value)) {
+                    // Delegate with depth increment; keep single-arg callable contract
+                    return $processor->process($value, static fn(mixed $v): mixed => $processValue($v, $depth + 1));
+                }
+            }
+
+            return \gettype($value);
+        };
 
         /**
          * @var TKey $key
          * @var TValue $value
          */
         foreach ($context as $key => $value) {
             $stringKey = (string) $key;
-            /** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
-            $processed[$stringKey] = $this->processValue($value);
+            /** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
+            $processed[$stringKey] = $processValue($value, 0);
         }
 
         return $processed;
     }

Note: This change confines guard state to each processContext call, so no cross-call retention.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Process context data recursively.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $context
* @return array<string, mixed>
*/
public function processContext(array $context): array
{
if (empty($context)) {
return [];
}
/** @var array<string, mixed> $processed */
$processed = [];
/**
* @var TKey $key
* @var TValue $value
*/
foreach ($context as $key => $value) {
$stringKey = (string) $key;
/** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
$processed[$stringKey] = $this->processValue($value);
}
return $processed;
}
/**
* Process context data recursively.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $context
* @return array<string, mixed>
*/
public function processContext(array $context): array
{
if (empty($context)) {
return [];
}
/** @var array<string, mixed> $processed */
$processed = [];
$visited = new \SplObjectStorage();
$maxDepth = 20;
$processValue = function (mixed $value, int $depth = 0) use (&$processValue, $visited, $maxDepth): mixed {
if ($depth > $maxDepth) {
return '__max_depth_exceeded__';
}
if (\is_object($value)) {
if ($visited->contains($value)) {
return '__circular_ref:' . \get_class($value) . '#' . \spl_object_id($value) . '__';
}
$visited->attach($value);
}
foreach ($this->processors as $processor) {
if ($processor->canProcess($value)) {
// Delegate with depth increment; keep single-arg callable contract
return $processor->process(
$value,
static fn (mixed $v): mixed => $processValue($v, $depth + 1)
);
}
}
return \gettype($value);
};
/**
* @var TKey $key
* @var TValue $value
*/
foreach ($context as $key => $value) {
$stringKey = (string) $key;
/** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
$processed[$stringKey] = $processValue($value, 0);
}
return $processed;
}


/**
* Process a single value using the appropriate processor.
*/
public function processValue(mixed $value): mixed
{
foreach ($this->processors as $processor) {
if ($processor->canProcess($value)) {
return $processor->process($value, [$this, 'processValue']);
}
}

// This should never happen due to FallbackProcessor, but just in case
return \gettype($value);
}

/**
* Register the default set of processors in the correct order.
* Order matters: more specific processors should be registered first.
*/
private function registerDefaultProcessors(): void
{
// Built-in PHP types (null, scalar, array) - most common and efficient
$this->addProcessor(new BuiltInTypeProcessor());

// Specific object types (before generic object processor)
$this->addProcessor(new DateTimeProcessor());
$this->addProcessor(new ThrowableProcessor());
$this->addProcessor(new StringableProcessor());

// Resources
$this->addProcessor(new ResourceProcessor());

// Generic object processor (before fallback)
$this->addProcessor(new ObjectProcessor());

// Fallback processor (last resort)
$this->addProcessor(new FallbackProcessor());
}
}
33 changes: 33 additions & 0 deletions src/Internal/ContextProcessor/DateTimeProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Processor for DateTime objects.
*
* Converts DateTime and DateTimeImmutable objects to ISO 8601 format
* for consistent structured logging.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<\DateTimeInterface, string>
*/
class DateTimeProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
return $value instanceof \DateTimeInterface;
}

/**
* @param \DateTimeInterface $value
* @param callable(mixed): mixed $recursiveProcessor
* @return string
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
return $value->format(\DateTimeInterface::ATOM);
}
}
33 changes: 33 additions & 0 deletions src/Internal/ContextProcessor/FallbackProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Fallback processor for unknown types.
*
* Returns the type name for any value that couldn't be processed
* by more specific processors.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<mixed, string>
*/
class FallbackProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
// This processor can handle anything as a last resort
return true;
}

/**
* @param callable(mixed): mixed $recursiveProcessor
* @return string
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
return \gettype($value);
}
}
51 changes: 51 additions & 0 deletions src/Internal/ContextProcessor/ObjectProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Processor for generic objects.
*
* Attempts to convert objects to array representation using public properties,
* or falls back to class name if no public properties are available.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<object, array<string, mixed>|string>
*/
class ObjectProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
return \is_object($value);
}

/**
* @param object $value
* @param callable(mixed): mixed $recursiveProcessor
* @return array<string, mixed>|string
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
// Try to convert to array (for objects with public properties)
$objectVars = \get_object_vars($value);

if (!empty($objectVars)) {
/** @var array<string, mixed> $processed */
$processed = [];
/**
* @var string $property
* @var mixed $propertyValue
*/
foreach ($objectVars as $property => $propertyValue) {
/** @psalm-suppress MixedAssignment - Intentionally processing mixed types */
$processed[$property] = $recursiveProcessor($propertyValue);
}
return $processed;
}
Comment on lines +29 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify cycle handling and depth limits in the recursive path

Processing object graphs via $recursiveProcessor can loop on self-references or explode on deeply nested structures without guards. Ensure the manager tracks visited nodes and enforces a sane max depth/size.

Run:

Expected:

  • Specific processors (DateTime, Throwable, Stringable) registered before ObjectProcessor.
  • Presence of visited-set (e.g., SplObjectStorage) and/or max-depth guards.

🏁 Script executed:

#!/bin/bash
# Inspect manager for processor order, cycle detection, and limits
rg -nP 'class\s+ContextProcessorManager\b' -C3
rg -nP '(register|default).*processor' -n -C3 src/Internal/ContextProcessor/ContextProcessorManager.php
rg -nP '(visited|seen|SplObjectStorage|maxDepth|maxDepth|depth|cycle)' -n -C3 src/Internal/ContextProcessor/ContextProcessorManager.php

Length of output: 1187


🏁 Script executed:

#!/bin/bash
# Display the registerDefaultProcessors method including the registration calls
awk 'NR>=70 && NR<=140' src/Internal/ContextProcessor/ContextProcessorManager.php

Length of output: 1234


Add cycle detection and recursion depth limit to ContextProcessorManager

ContextProcessorManager currently delegates recursion to ObjectProcessor without tracking already-visited objects or capping recursion depth. This opens the door to infinite loops on circular references and stack overflows on deeply nested data.

• In src/Internal/ContextProcessor/ContextProcessorManager.php, processValue() should maintain a visited-object set (e.g. via SplObjectStorage) to skip re-processing the same object instance.
• Introduce a configurable maxDepth property (with a sane default) and carry a depth counter through each recursive call, aborting (or falling back) when the limit is exceeded.
• Update the call signature—either by extending processValue(mixed $value, SplObjectStorage $visited = null, int $depth = 0) or by encapsulating state in the manager—to initialize and propagate both the visited set and depth.

Addressing these will prevent unbounded recursion and ensure safe handling of self-referencing or deeply nested object graphs.

🤖 Prompt for AI Agents
In src/Internal/ContextProcessor/ObjectProcessor.php around lines 29 to 46,
ObjectProcessor delegates recursion without any cycle detection or depth checks;
update the ContextProcessorManager (not just this file) to maintain an
SplObjectStorage visited set and a configurable maxDepth (with a sane default),
and propagate an integer depth counter on recursive calls; change the
recursive/processValue call signature or have the manager provide a closure that
captures visited and depth so that before processing an object you: 1) if depth
> maxDepth return a safe fallback (null or a marker) or throw a controlled
exception; 2) if the object is already in visited skip re-processing (return a
reference marker or previously-processed value); otherwise add the object to
visited, increment depth, call the existing processor (or recursive closure) and
remove the object from visited after processing; ensure the manager initializes
visited and depth on the top-level call and that ObjectProcessor uses the
propagated state instead of unguarded recursion.


// Fallback to class name if no public properties
return \get_class($value);
}
}
32 changes: 32 additions & 0 deletions src/Internal/ContextProcessor/ResourceProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Processor for resource types.
*
* Converts resources to string representation indicating the resource type.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<resource, string>
*/
class ResourceProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
return \is_resource($value);
}

/**
* @param resource $value
* @param callable(mixed): mixed $recursiveProcessor
* @return string
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
return \get_resource_type($value) . ' resource';
}
}
32 changes: 32 additions & 0 deletions src/Internal/ContextProcessor/StringableProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace RoadRunner\PsrLogger\Internal\ContextProcessor;

/**
* Processor for objects implementing the Stringable interface.
*
* Converts Stringable objects to their string representation.
*
* @internal This class is internal to the PSR Logger implementation and should not be used directly.
*
* @implements ContextProcessorInterface<\Stringable, string>
*/
class StringableProcessor implements ContextProcessorInterface
{
public function canProcess(mixed $value): bool
{
return $value instanceof \Stringable;
}

/**
* @param \Stringable $value
* @param callable(mixed): mixed $recursiveProcessor
* @return string
*/
public function process(mixed $value, callable $recursiveProcessor): mixed
{
return (string) $value;
}
}
Loading
Loading