diff --git a/composer.json b/composer.json index b5d4e20..ea64d2d 100644 --- a/composer.json +++ b/composer.json @@ -37,13 +37,17 @@ "buggregator/trap": "^1.13", "phpunit/phpunit": "^10.5.45", "spiral/code-style": "^2.2", + "symfony/polyfill-php83": "*", "ta-tikoma/phpunit-architecture-test": "^0.8.5", "vimeo/psalm": "^6.10" }, "scripts": { "cs:diff": "php-cs-fixer fix --dry-run -v --diff --show-progress dots", "cs:fix": "php-cs-fixer fix -v", - "test": "phpunit", + "test": [ + "@putenv XDEBUG_MODE=coverage", + "phpunit" + ], "psalm": "psalm" }, "autoload": { diff --git a/src/Context/DefaultProcessor.php b/src/Context/DefaultProcessor.php new file mode 100644 index 0000000..391e46e --- /dev/null +++ b/src/Context/DefaultProcessor.php @@ -0,0 +1,79 @@ + */ + private array $processors = []; + + private function __construct() {} + + /** + * Create empty instance. + */ + public static function create(): self + { + return new self(); + } + + /** + * Create with default set of {@see ObjectProcessor}. + */ + public static function createDefault(): self + { + $self = new self(); + $self->processors = [ + new DateTimeProcessor(), + new StringableProcessor(), + new ThrowableProcessor(), + new FallbackProcessor(), + ]; + return $self; + } + + /** + * Copy the current object and add Object Processors before existing ones. + */ + public function withObjectProcessors(ObjectProcessor ...$processors): self + { + $clone = clone $this; + $clone->processors = \array_merge(\array_values($processors), $clone->processors); + return $clone; + } + + public function __invoke(mixed $value): mixed + { + if (\is_resource($value)) { + return \get_resource_type($value) . ' resource'; + } + + if (\is_array($value)) { + foreach ($value as &$v) { + $v = $this($v); + } + } + + if (\is_object($value)) { + foreach ($this->processors as $processor) { + if ($processor->canProcess($value)) { + return $processor->process($value, $this); + } + } + } + + return $value; + } +} diff --git a/src/Context/ObjectProcessor.php b/src/Context/ObjectProcessor.php new file mode 100644 index 0000000..d283124 --- /dev/null +++ b/src/Context/ObjectProcessor.php @@ -0,0 +1,25 @@ + + * @api + */ +final class DateTimeProcessor implements ObjectProcessor +{ + #[\Override] + public function canProcess(object $value): bool + { + return $value instanceof \DateTimeInterface; + } + + #[\Override] + public function process(object $value, callable $processor): mixed + { + return $value->format(\DateTimeInterface::ATOM); + } +} diff --git a/src/Context/ObjectProcessor/FallbackProcessor.php b/src/Context/ObjectProcessor/FallbackProcessor.php new file mode 100644 index 0000000..ccfc845 --- /dev/null +++ b/src/Context/ObjectProcessor/FallbackProcessor.php @@ -0,0 +1,37 @@ + + * @api + */ +final class FallbackProcessor implements ObjectProcessor +{ + #[\Override] + public function canProcess(object $value): bool + { + return true; + } + + #[\Override] + public function process(object $value, callable $processor): array + { + $result = ['@class' => $value::class] + \get_object_vars($value); + foreach ($result as $k => &$v) { + if ($v === $value) { + unset($result[$k]); + } + + $v = $processor($v); + } + + return $result; + } +} diff --git a/src/Context/ObjectProcessor/StringableProcessor.php b/src/Context/ObjectProcessor/StringableProcessor.php new file mode 100644 index 0000000..5a498d7 --- /dev/null +++ b/src/Context/ObjectProcessor/StringableProcessor.php @@ -0,0 +1,28 @@ + + * @api + */ +final class StringableProcessor implements ObjectProcessor +{ + #[\Override] + public function canProcess(object $value): bool + { + return $value instanceof \Stringable; + } + + #[\Override] + public function process(object $value, callable $processor): mixed + { + return (string) $value; + } +} diff --git a/src/Context/ObjectProcessor/ThrowableProcessor.php b/src/Context/ObjectProcessor/ThrowableProcessor.php new file mode 100644 index 0000000..593eb06 --- /dev/null +++ b/src/Context/ObjectProcessor/ThrowableProcessor.php @@ -0,0 +1,36 @@ + + * @api + */ +final class ThrowableProcessor implements ObjectProcessor +{ + #[\Override] + public function canProcess(object $value): bool + { + return $value instanceof \Throwable; + } + + #[\Override] + public function process(object $value, callable $processor): array + { + return [ + 'class' => \get_class($value), + 'message' => $value->getMessage(), + 'code' => $value->getCode(), + 'file' => $value->getFile(), + 'line' => $value->getLine(), + 'trace' => $value->getTraceAsString(), + ]; + } +} diff --git a/src/RpcLogger.php b/src/RpcLogger.php index d278ce8..dec3d21 100644 --- a/src/RpcLogger.php +++ b/src/RpcLogger.php @@ -10,16 +10,22 @@ use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use RoadRunner\Logger\Logger as AppLogger; use RoadRunner\Logger\LogLevel; +use RoadRunner\PsrLogger\Context\DefaultProcessor; +/** + * @api + */ class RpcLogger implements LoggerInterface { use LoggerTrait; private readonly AppLogger $logger; + private readonly \Closure $objectProcessor; - public function __construct(AppLogger $logger) + public function __construct(AppLogger $logger, ?callable $processor = null) { $this->logger = $logger; + $this->objectProcessor = ($processor ?? DefaultProcessor::createDefault())(...); } /** @@ -29,26 +35,29 @@ public function __construct(AppLogger $logger) * * @link https://www.php-fig.org/psr/psr-3/#5-psrlogloglevel */ + #[\Override] public function log($level, \Stringable|string $message, array $context = []): void { $normalizedLevel = \strtolower(match (true) { - \is_string($level), + \is_string($level) => $level, $level instanceof \Stringable => (string) $level, $level instanceof \BackedEnum => (string) $level->value, $level instanceof LogLevel => $level->name, default => throw new PsrInvalidArgumentException('Invalid log level type provided.'), }); - /** @var array $context */ + // Process context data for structured logging using the processor manager + $processedContext = ($this->objectProcessor)($context); + match ($normalizedLevel) { PsrLogLevel::EMERGENCY, PsrLogLevel::ALERT, PsrLogLevel::CRITICAL, - PsrLogLevel::ERROR => $this->logger->error($message, $context), - PsrLogLevel::WARNING => $this->logger->warning($message, $context), - PsrLogLevel::NOTICE, PsrLogLevel::INFO => $this->logger->info((string) $message, $context), - 'log' => $this->logger->log((string) $message, $context), - PsrLogLevel::DEBUG => $this->logger->debug($message, $context), + PsrLogLevel::ERROR => $this->logger->error($message, $processedContext), + PsrLogLevel::WARNING => $this->logger->warning($message, $processedContext), + PsrLogLevel::NOTICE, PsrLogLevel::INFO => $this->logger->info((string) $message, $processedContext), + 'log' => $this->logger->log((string) $message, $processedContext), + PsrLogLevel::DEBUG => $this->logger->debug($message, $processedContext), default => throw new PsrInvalidArgumentException("Invalid log level `$normalizedLevel` provided."), }; } diff --git a/tests/Unit/Context/DefaultProcessorTest.php b/tests/Unit/Context/DefaultProcessorTest.php new file mode 100644 index 0000000..7edd2e0 --- /dev/null +++ b/tests/Unit/Context/DefaultProcessorTest.php @@ -0,0 +1,91 @@ + ['test string', 'test string'], + 'integer' => [42, 42], + 'float' => [3.14, 3.14], + 'boolean true' => [true, true], + 'boolean false' => [false, false], + 'null' => [null, null], + 'empty array' => [[], []], + 'simple array' => [[1, 2, 3], [1, 2, 3]], + 'associative array' => [['key' => 'value'], ['key' => 'value']], + 'resource' => [\fopen('php://memory', 'r'), 'stream resource'], + ]; + } + + #[DataProvider('builtInTypeValuesProvider')] + public function testCanProcessBuiltInTypes(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ($this->processor)($value)); + } + + public function testProcessNull(): void + { + $recursiveProcessor = static fn($v) => $v; + $result = ($this->processor)(null, $recursiveProcessor); + $this->assertNull($result); + } + + public function testProcessScalarValues(): void + { + $values = ['test string', 42, 3.14, true, false]; + $recursiveProcessor = static fn($v) => $v; + + foreach ($values as $value) { + $result = ($this->processor)($value, $recursiveProcessor); + $this->assertSame($value, $result); + } + } + + public function testProcessSimpleArray(): void + { + $array = [1, 2, 'three', true]; + $recursiveProcessor = static fn($v) => $v; // Identity function for simple values + + $result = ($this->processor)($array, $recursiveProcessor); + + $this->assertSame([1, 2, 'three', true], $result); + } + + public function testProcessNestedArray(): void + { + $array = [ + 'level1' => [ + 'level2' => [ + 'value' => 'deep', + ], + ], + ]; + + $result = ($this->processor)($array); + + $this->assertArrayHasKey('level1', $result); + $this->assertIsArray($result['level1']); + $this->assertArrayHasKey('level2', $result['level1']); + $this->assertIsArray($result['level1']['level2']); + $this->assertSame('deep', $result['level1']['level2']['value']); + } + + protected function setUp(): void + { + $this->processor = DefaultProcessor::create(); + } +} diff --git a/tests/Unit/Context/ObjectProcessor/DateTimeProcessorTest.php b/tests/Unit/Context/ObjectProcessor/DateTimeProcessorTest.php new file mode 100644 index 0000000..dd9935d --- /dev/null +++ b/tests/Unit/Context/ObjectProcessor/DateTimeProcessorTest.php @@ -0,0 +1,57 @@ +assertTrue($this->processor->canProcess($dateTime)); + } + + public function testCanProcessDateTimeImmutable(): void + { + $dateTime = new \DateTimeImmutable(); + $this->assertTrue($this->processor->canProcess($dateTime)); + } + + public function testCannotProcessNonDateTime(): void + { + $this->assertFalse($this->processor->canProcess(new \stdClass())); + } + + public function testProcessDateTime(): void + { + $dateTime = new \DateTime('2023-01-01T12:00:00+00:00'); + $recursiveProcessor = static fn($v) => $v; + + $result = $this->processor->process($dateTime, $recursiveProcessor); + + $this->assertSame('2023-01-01T12:00:00+00:00', $result); + } + + public function testProcessDateTimeImmutable(): void + { + $dateTime = new \DateTimeImmutable('2023-06-15T09:30:00+02:00'); + $recursiveProcessor = static fn($v) => $v; + + $result = $this->processor->process($dateTime, $recursiveProcessor); + + $this->assertSame('2023-06-15T09:30:00+02:00', $result); + } + + protected function setUp(): void + { + $this->processor = new DateTimeProcessor(); + } +} diff --git a/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php b/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php new file mode 100644 index 0000000..f63659d --- /dev/null +++ b/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php @@ -0,0 +1,56 @@ + [new \stdClass(), [ + '@class' => 'stdClass', + ]], + 'object with props' => [(object) ['foo' => 'bar'], [ + '@class' => 'stdClass', + 'foo' => 'bar', + ]], + ]; + + // Close the resource after creating the test data + \register_shutdown_function(static function () use ($resource): void { + if (\is_resource($resource)) { + \fclose($resource); + } + }); + + return $data; + } + + #[DataProvider('allTypesProvider')] + public function testProcessReturnsTypeString(object $value, mixed $expectedType): void + { + $recursiveProcessor = static fn($v) => $v; + $result = $this->processor->process($value, $recursiveProcessor); + + // FallbackProcessor should be able to process any object + $this->assertTrue($this->processor->canProcess($value)); + $this->assertSame($expectedType, $result); + } + + protected function setUp(): void + { + $this->processor = new FallbackProcessor(); + } +} diff --git a/tests/Unit/Context/ObjectProcessor/StringableProcessorTest.php b/tests/Unit/Context/ObjectProcessor/StringableProcessorTest.php new file mode 100644 index 0000000..76dbc4e --- /dev/null +++ b/tests/Unit/Context/ObjectProcessor/StringableProcessorTest.php @@ -0,0 +1,99 @@ +assertTrue($this->processor->canProcess($stringable)); + } + + #[DataProvider('nonStringableProvider')] + public function testCannotProcessNonStringable(mixed $value, $expected): void + { + $this->assertSame($expected, $this->processor->canProcess($value)); + } + + public function testProcessStringable(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'converted string'; + } + }; + + $recursiveProcessor = static fn($v) => $v; + $result = $this->processor->process($stringable, $recursiveProcessor); + + $this->assertSame('converted string', $result); + } + + public function testProcessStringableWithComplexLogic(): void + { + $stringable = new class implements \Stringable { + private string $data = 'complex data'; + + public function __toString(): string + { + return \strtoupper($this->data); + } + }; + + $recursiveProcessor = static fn($v) => $v; + $result = $this->processor->process($stringable, $recursiveProcessor); + + $this->assertSame('COMPLEX DATA', $result); + } + + public function testProcessStringableWithEmptyString(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return ''; + } + }; + + $recursiveProcessor = static fn($v) => $v; + $result = $this->processor->process($stringable, $recursiveProcessor); + + $this->assertSame('', $result); + } + + protected function setUp(): void + { + $this->processor = new StringableProcessor(); + } +} diff --git a/tests/Unit/Context/ObjectProcessor/ThrowableProcessorTest.php b/tests/Unit/Context/ObjectProcessor/ThrowableProcessorTest.php new file mode 100644 index 0000000..6bc2d7f --- /dev/null +++ b/tests/Unit/Context/ObjectProcessor/ThrowableProcessorTest.php @@ -0,0 +1,96 @@ + [new \Exception('test')], + 'RuntimeException' => [new \RuntimeException('test')], + 'InvalidArgumentException' => [new \InvalidArgumentException('test')], + 'Error' => [new \Error('test')], + 'TypeError' => [new \TypeError('test')], + ]; + } + + public static function nonThrowableProvider(): array + { + return [ + 'object' => [new \stdClass()], + ]; + } + + #[DataProvider('throwableProvider')] + public function testCanProcessThrowable(\Throwable $throwable): void + { + $this->assertTrue($this->processor->canProcess($throwable)); + } + + #[DataProvider('nonThrowableProvider')] + public function testCannotProcessNonThrowable(mixed $value): void + { + $this->assertFalse($this->processor->canProcess($value)); + } + + public function testProcessException(): void + { + $exception = new \RuntimeException('Test error message', 500); + $recursiveProcessor = static fn($v) => $v; + + $result = $this->processor->process($exception, $recursiveProcessor); + + $this->assertIsArray($result); + $this->assertSame('RuntimeException', $result['class']); + $this->assertSame('Test error message', $result['message']); + $this->assertSame(500, $result['code']); + $this->assertArrayHasKey('file', $result); + $this->assertArrayHasKey('line', $result); + $this->assertArrayHasKey('trace', $result); + $this->assertIsString($result['file']); + $this->assertIsInt($result['line']); + $this->assertIsString($result['trace']); + } + + public function testProcessError(): void + { + $error = new \Error('Test error', 123); + $recursiveProcessor = static fn($v) => $v; + + $result = $this->processor->process($error, $recursiveProcessor); + + $this->assertIsArray($result); + $this->assertSame('Error', $result['class']); + $this->assertSame('Test error', $result['message']); + $this->assertSame(123, $result['code']); + } + + public function testProcessCustomException(): void + { + $customException = new class('Custom message', 999) extends \Exception {}; + $recursiveProcessor = static fn($v) => $v; + + $result = $this->processor->process($customException, $recursiveProcessor); + + $this->assertIsArray($result); + $this->assertTrue(\str_contains($result['class'], 'Exception@anonymous')); + $this->assertSame('Custom message', $result['message']); + $this->assertSame(999, $result['code']); + } + + protected function setUp(): void + { + $this->processor = new ThrowableProcessor(); + } +} diff --git a/tests/Unit/RpcLoggerTest.php b/tests/Unit/RpcLoggerTest.php index 062f728..62536d5 100644 --- a/tests/Unit/RpcLoggerTest.php +++ b/tests/Unit/RpcLoggerTest.php @@ -9,8 +9,11 @@ use PHPUnit\Framework\TestCase; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel as PsrLogLevel; +use RoadRunner\AppLogger\DTO\V1\LogEntry; use RoadRunner\Logger\Logger as AppLogger; use RoadRunner\Logger\LogLevel; +use RoadRunner\PsrLogger\Context\DefaultProcessor; +use RoadRunner\PsrLogger\Context\ObjectProcessor; use RoadRunner\PsrLogger\RpcLogger; #[CoversClass(RpcLogger::class)] @@ -56,7 +59,7 @@ public function testLogWithEmergencyLevels(string $level): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('ErrorWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } public function testLogWithWarningLevel(): void @@ -69,7 +72,7 @@ public function testLogWithWarningLevel(): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('WarningWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } #[DataProvider('infoLevelsProvider')] @@ -83,7 +86,7 @@ public function testLogWithInfoLevels(string $level): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('InfoWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } public function testLogWithDebugLevel(): void @@ -96,7 +99,7 @@ public function testLogWithDebugLevel(): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('DebugWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } public function testLogWithStringableMessage(): void @@ -138,7 +141,7 @@ public function testLogWithCaseInsensitiveLevel(): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('ErrorWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } public function testLogWithMixedCaseLevel(): void @@ -151,7 +154,7 @@ public function testLogWithMixedCaseLevel(): void $this->assertSame(1, $this->rpc->getCallCount()); $lastCall = $this->rpc->getLastCall(); $this->assertSame('WarningWithContext', $lastCall['method']); - $this->assertInstanceOf(\RoadRunner\AppLogger\DTO\V1\LogEntry::class, $lastCall['payload']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); } public function testLogWithEnumLevel(): void @@ -354,10 +357,357 @@ public function testLogWithComplexContext(): void $this->assertSame('InfoWithContext', $lastCall['method']); } + public function testLogWithScalarContext(): void + { + $message = 'Scalar context message'; + $context = [ + 'string_value' => 'test string', + 'int_value' => 42, + 'float_value' => 3.14, + 'bool_value' => true, + 'null_value' => null, + ]; + + $this->rpcLogger->log(PsrLogLevel::INFO, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithDateTimeContext(): void + { + $message = 'DateTime context message'; + $dateTime = new \DateTime('2023-01-01T12:00:00+00:00'); + $dateTimeImmutable = new \DateTimeImmutable('2023-01-01T13:00:00+00:00'); + + $context = [ + 'created_at' => $dateTime, + 'updated_at' => $dateTimeImmutable, + ]; + + $this->rpcLogger->log(PsrLogLevel::INFO, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithExceptionContext(): void + { + $message = 'Exception context message'; + $exception = new \RuntimeException('Test exception message', 500); + + $context = [ + 'error' => $exception, + 'additional_info' => 'Some additional context', + ]; + + $this->rpcLogger->log(PsrLogLevel::ERROR, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('ErrorWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithStringableContext(): void + { + $message = 'Stringable context message'; + $stringableObject = new class implements \Stringable { + public function __toString(): string + { + return 'Custom stringable object'; + } + }; + + $context = [ + 'user' => $stringableObject, + 'status' => 'active', + ]; + + $this->rpcLogger->log(PsrLogLevel::INFO, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithNestedArrayContext(): void + { + $message = 'Nested array context message'; + $context = [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'deep_value' => 'nested data', + 'number' => 123, + ], + 'another_value' => true, + ], + 'simple_value' => 'test', + ], + 'root_value' => 'root', + ]; + + $this->rpcLogger->log(PsrLogLevel::DEBUG, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('DebugWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithObjectContext(): void + { + $message = 'Object context message'; + $object = new class { + public string $publicProp = 'public value'; + private string $privateProp = 'private value'; + protected string $protectedProp = 'protected value'; + }; + + $context = [ + 'user_data' => $object, + 'other_info' => 'additional data', + ]; + + $this->rpcLogger->log(PsrLogLevel::INFO, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithResourceContext(): void + { + $message = 'Resource context message'; + $resource = \fopen('php://memory', 'r'); + + $context = [ + 'file_handle' => $resource, + 'operation' => 'read', + ]; + + $this->rpcLogger->log(PsrLogLevel::INFO, $message, $context); + + \fclose($resource); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testLogWithMixedComplexContext(): void + { + $message = 'Mixed complex context message'; + $exception = new \InvalidArgumentException('Invalid input', 400); + $dateTime = new \DateTime('2023-01-01T12:00:00+00:00'); + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'Mixed context stringable'; + } + }; + + $context = [ + 'user_id' => 123, + 'error' => $exception, + 'timestamp' => $dateTime, + 'user_agent' => $stringable, + 'metadata' => [ + 'ip' => '127.0.0.1', + 'session_id' => 'abc123', + 'nested' => [ + 'deep' => [ + 'value' => 'very deep', + 'count' => 5, + ], + ], + ], + 'is_admin' => false, + 'score' => 98.5, + ]; + + $this->rpcLogger->log(PsrLogLevel::WARNING, $message, $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('WarningWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testCustomProcessorIntegration(): void + { + // Create a custom processor for email addresses + $emailProcessor = new class implements ObjectProcessor { + public function canProcess(mixed $value): bool + { + return \is_string($value) && \filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + } + + public function process(mixed $value, callable $processor): mixed + { + // Mask email for privacy + $parts = \explode('@', $value); + return \substr($parts[0], 0, 2) . '***@' . $parts[1]; + } + }; + + // Create processor manager with custom processor added first + $processorManager = DefaultProcessor::createDefault()->withObjectProcessors($emailProcessor); + + // Create logger with custom processor manager + $logger = new RpcLogger($this->appLogger, $processorManager); + + $context = [ + 'user_email' => 'john.doe@example.com', + 'admin_email' => 'admin@company.org', + 'regular_string' => 'not an email', + 'user_id' => 123, + ]; + + $logger->info('User action performed', $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('InfoWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testMultipleCustomProcessors(): void + { + // Custom processor for URLs + $urlProcessor = new class implements ObjectProcessor { + public function canProcess(mixed $value): bool + { + return \is_string($value) && \filter_var($value, FILTER_VALIDATE_URL) !== false; + } + + public function process(mixed $value, callable $processor): mixed + { + $parsed = \parse_url($value); + return [ + 'scheme' => $parsed['scheme'] ?? null, + 'host' => $parsed['host'] ?? null, + 'path' => $parsed['path'] ?? null, + ]; + } + }; + + // Custom processor for credit card numbers (mock) + $ccProcessor = new class implements ObjectProcessor { + public function canProcess(mixed $value): bool + { + return \is_string($value) && \preg_match('/^\d{4}-?\d{4}-?\d{4}-?\d{4}$/', $value); + } + + public function process(mixed $value, callable $processor): mixed + { + return '****-****-****-' . \substr($value, -4); + } + }; + + $processorManager = \RoadRunner\PsrLogger\Context\DefaultProcessor::createDefault() + ->withObjectProcessors($urlProcessor) + ->withObjectProcessors($ccProcessor); + + $logger = new RpcLogger($this->appLogger, $processorManager); + + $context = [ + 'website' => 'https://example.com/path/to/resource', + 'payment_card' => '1234-5678-9012-3456', + 'regular_data' => 'normal string', + 'amount' => 99.99, + ]; + + $logger->warning('Payment processed', $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('WarningWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testDefaultProcessorManagerWhenNoneProvided(): void + { + // Test that RpcLogger creates default processor manager when none provided + $logger = new RpcLogger($this->appLogger); + + $context = [ + 'timestamp' => new \DateTime('2023-01-01T12:00:00+00:00'), + 'exception' => new \RuntimeException('Test error'), + 'user_id' => 123, + ]; + + $logger->error('Test with default processors', $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('ErrorWithContext', $lastCall['method']); + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + + public function testProcessorOrdering(): void + { + // Create processors for the same type to test ordering + $firstProcessor = new class implements ObjectProcessor { + public function canProcess(mixed $value): bool + { + return \is_int($value); + } + + public function process(mixed $value, callable $processor): mixed + { + return 'first:' . $value; + } + }; + + $secondProcessor = new class implements ObjectProcessor { + public function canProcess(mixed $value): bool + { + return \is_int($value); + } + + public function process(mixed $value, callable $processor): mixed + { + return 'second:' . $value; + } + }; + + $processorManager = \RoadRunner\PsrLogger\Context\DefaultProcessor::createDefault() + ->withObjectProcessors($firstProcessor) // Added first, should be used + ->withObjectProcessors($secondProcessor); // Added second, should be skipped + + $logger = new RpcLogger($this->appLogger, $processorManager); + + $context = ['number' => 42]; + $logger->debug('Ordering test', $context); + + $this->assertSame(1, $this->rpc->getCallCount()); + $lastCall = $this->rpc->getLastCall(); + $this->assertSame('DebugWithContext', $lastCall['method']); + + // The first processor should have been used + // We can't directly inspect the processed context, but we know it was processed + $this->assertInstanceOf(LogEntry::class, $lastCall['payload']); + } + protected function setUp(): void { $this->rpc = new RpcSpy(); $this->appLogger = new AppLogger($this->rpc); $this->rpcLogger = new RpcLogger($this->appLogger); } + + protected function tearDown(): void + { + // Reset the RPC spy after each test to ensure clean state + $this->rpc->reset(); + } }