diff --git a/composer.json b/composer.json index c3ed8e7..8293cb9 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require": { "php": "^8.2", "ext-json": "*", + "ext-mbstring": "*", "monolog/monolog": "^3.0", "pccomponentes/ddd": "^2.1|^3.0|^4.0", "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", diff --git a/src/Context/NormalizeMessageProcessor.php b/src/Context/NormalizeMessageProcessor.php index 38e79aa..f05a59c 100644 --- a/src/Context/NormalizeMessageProcessor.php +++ b/src/Context/NormalizeMessageProcessor.php @@ -30,7 +30,10 @@ public function __invoke(LogRecord $record): LogRecord 'message_id' => $message->messageId()->value(), 'name' => $message::messageName(), 'type' => $message::messageType(), - 'payload' => \json_encode($this->sanitizePayloadForLogging($message->messagePayload())), + 'payload' => \json_encode( + $this->sanitizePayloadForLogging($message->messagePayload()), + \JSON_INVALID_UTF8_SUBSTITUTE | \JSON_PARTIAL_OUTPUT_ON_ERROR, + ), ]; if ($message instanceof AggregateMessage) { @@ -65,7 +68,7 @@ private function sanitizeValueForLogging(mixed $value): mixed private function formatTruncatedString(string $value): string { - $preview = \substr($value, 0, self::STRING_PREVIEW_LENGTH); + $preview = \mb_strcut($value, 0, self::STRING_PREVIEW_LENGTH, 'UTF-8'); return \sprintf( '%s...[string truncated; original_length=%d]', diff --git a/src/DomainTrace/MessageTraceMiddleware.php b/src/DomainTrace/MessageTraceMiddleware.php index bd1be13..e5b9933 100644 --- a/src/DomainTrace/MessageTraceMiddleware.php +++ b/src/DomainTrace/MessageTraceMiddleware.php @@ -22,7 +22,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $message = $this->messageFromEnvelope($envelope); $this->tracker->assignReplyTo( - $message->messageId()->value() + $message->messageId()->value(), ); return $stack->next()->handle($envelope, $stack); diff --git a/src/DomainTrace/RequestTraceMiddleware.php b/src/DomainTrace/RequestTraceMiddleware.php index 41e8209..80b863a 100644 --- a/src/DomainTrace/RequestTraceMiddleware.php +++ b/src/DomainTrace/RequestTraceMiddleware.php @@ -27,6 +27,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $this->assertCorrelationId(); $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { return $stack->next()->handle($envelope, $stack); } @@ -34,11 +35,13 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $message = $this->messageFromEnvelope($envelope); $correlationId = $this->correlationIdFromRequest($request); + if (null !== $correlationId) { $this->tracker->assignCorrelationId($correlationId, $message->messageId()); } $replyTo = $this->replyToFromRequest($request); + if (null !== $replyTo) { $this->tracker->assignReplyTo($replyTo, $message->messageId()); } @@ -58,7 +61,7 @@ private function assertCorrelationId(): void } $this->tracker->assignCorrelationId( - Uuid::v4()->value() + Uuid::v4()->value(), ); } diff --git a/src/DomainTrace/Tracker.php b/src/DomainTrace/Tracker.php index d55966e..3e177c9 100644 --- a/src/DomainTrace/Tracker.php +++ b/src/DomainTrace/Tracker.php @@ -71,6 +71,7 @@ public function assignCorrelationId(string $correlationId, ?Uuid $messageId = nu public function assignReplyTo(string $replyTo, ?Uuid $messageId = null): void { $this->replyTo = $replyTo; + if (null === $messageId) { return; } diff --git a/src/MessageLogger/Action.php b/src/MessageLogger/Action.php index 4b05a67..980ce9d 100644 --- a/src/MessageLogger/Action.php +++ b/src/MessageLogger/Action.php @@ -6,5 +6,6 @@ interface Action { public function success(): string; + public function error(): string; } diff --git a/src/Util/AssocSerializer.php b/src/Util/AssocSerializer.php index 8397490..8f15d6e 100644 --- a/src/Util/AssocSerializer.php +++ b/src/Util/AssocSerializer.php @@ -7,22 +7,22 @@ final class AssocSerializer { public static function from($anything): array { - switch (true) { - case $anything instanceof \Throwable: - return self::throwable($anything); - case \is_array($anything): - return \array_map( - static function ($item) { - return self::from($item); - }, - $anything - ); - case $anything instanceof \JsonSerializable: - case \is_object($anything): - return self::basic($anything); - default: - return ['value' => $anything]; + if ($anything instanceof \Throwable) { + return self::throwable($anything); } + + if (\is_array($anything)) { + return \array_map( + static fn ($item) => self::from($item), + $anything, + ); + } + + if ($anything instanceof \JsonSerializable || \is_object($anything)) { + return self::basic($anything); + } + + return ['value' => $anything]; } private static function throwable(\Throwable $throwable): array @@ -30,7 +30,7 @@ private static function throwable(\Throwable $throwable): array return [ 'class' => \get_class($throwable), 'message' => $throwable->getMessage(), - 'code' => $throwable->getCode(), + 'code' => (string)$throwable->getCode(), 'file' => $throwable->getFile(), 'line' => $throwable->getLine(), 'trace' => self::trace($throwable), diff --git a/src/Util/Filebeat/template.json b/src/Util/Filebeat/template.json index 6f7c7ed..7354635 100644 --- a/src/Util/Filebeat/template.json +++ b/src/Util/Filebeat/template.json @@ -21,7 +21,7 @@ } }, "code": { - "type": "integer" + "type": "keyword" }, "file": { "type": "text", diff --git a/tests/Context/NormalizeContextProcessorTest.php b/tests/Context/NormalizeContextProcessorTest.php index 99760ac..6604055 100644 --- a/tests/Context/NormalizeContextProcessorTest.php +++ b/tests/Context/NormalizeContextProcessorTest.php @@ -99,6 +99,75 @@ public function testShouldReturnedRecordWithLargeNonBase64PayloadTruncated() $this->assertStringNotContainsString($largeString, $payload['attachment']); } + /** + * @dataProvider utf8TruncationProvider + */ + public function testShouldTruncateLargeUtf8StringWithoutSplittingCharacters( + string $prefix, + string $character, + string $expectedPreview + ) { + $largeString = $prefix . $character . \str_repeat('b', 18000); + + $result = (new NormalizeMessageProcessor())($this->createRecordWithPayload([ + 'address' => $largeString, + ])); + + $this->assertIsString($result['context']['message']['payload']); + $payload = $this->decodePayload($result); + + $this->assertSame( + $expectedPreview . '...[string truncated; original_length=' . \strlen($largeString) . ']', + $payload['address'], + ); + } + + public function utf8TruncationProvider(): array + { + return [ + 'two-byte character crossing boundary' => [\str_repeat('a', 127), 'é', \str_repeat('a', 127)], + 'three-byte character crossing boundary' => [\str_repeat('a', 127), '€', \str_repeat('a', 127)], + 'four-byte character crossing boundary' => [\str_repeat('a', 127), '😀', \str_repeat('a', 127)], + 'two-byte character fitting boundary' => [\str_repeat('a', 126), 'é', \str_repeat('a', 126) . 'é'], + 'three-byte character fitting boundary' => [\str_repeat('a', 125), '€', \str_repeat('a', 125) . '€'], + 'four-byte character fitting boundary' => [\str_repeat('a', 124), '😀', \str_repeat('a', 124) . '😀'], + ]; + } + + public function testShouldReplaceInvalidUtf8WithoutLosingOtherPayloadFields() + { + $result = (new NormalizeMessageProcessor())($this->createRecordWithPayload([ + 'address' => "street \xB1 name", + 'city' => 'Madrid', + ])); + + $this->assertIsString($result['context']['message']['payload']); + $this->assertSame([ + 'address' => "street \u{FFFD} name", + 'city' => 'Madrid', + ], $this->decodePayload($result)); + } + + public function testShouldKeepSerializablePayloadFieldsWhenJsonEncodingFails() + { + $resource = \fopen('php://memory', 'r'); + + try { + $result = (new NormalizeMessageProcessor())($this->createRecordWithPayload([ + 'attachment' => $resource, + 'address' => 'street name', + ])); + } finally { + \fclose($resource); + } + + $this->assertIsString($result['context']['message']['payload']); + $this->assertSame([ + 'attachment' => null, + 'address' => 'street name', + ], $this->decodePayload($result)); + } + public function testShouldReturnedRecordWithTruncatedNestedLargeStringPayload() { $largeString = \str_repeat('b', 18000); diff --git a/tests/Util/AssocSerializerTest.php b/tests/Util/AssocSerializerTest.php new file mode 100644 index 0000000..d3841e7 --- /dev/null +++ b/tests/Util/AssocSerializerTest.php @@ -0,0 +1,38 @@ +assertSame('0', $result['code']); + } + + public function testShouldSerializeIntegerExceptionCodeAsString() + { + $result = AssocSerializer::from(new \Exception('message', 123)); + + $this->assertSame('123', $result['code']); + } + + public function testShouldPreserveAlphanumericExceptionCode() + { + $exception = new class extends \Exception { + public function __construct() + { + $this->code = 'HY000'; + } + }; + + $result = AssocSerializer::from($exception); + + $this->assertSame('HY000', $result['code']); + } +}