diff --git a/src/Instrumentation/Psr3/composer.json b/src/Instrumentation/Psr3/composer.json index 54fde7dbc..c8a390de0 100644 --- a/src/Instrumentation/Psr3/composer.json +++ b/src/Instrumentation/Psr3/composer.json @@ -36,6 +36,7 @@ "cakephp/log": "*", "monolog/monolog": "*", "symfony/console": "*", + "symfony/uid": "*", "yiisoft/log": "*", "friendsofphp/php-cs-fixer": "^3", "phan/phan": "^5.0", diff --git a/src/Instrumentation/Psr3/src/Formatter.php b/src/Instrumentation/Psr3/src/Formatter.php index 38a577815..35613ea8b 100644 --- a/src/Instrumentation/Psr3/src/Formatter.php +++ b/src/Instrumentation/Psr3/src/Formatter.php @@ -4,10 +4,16 @@ namespace OpenTelemetry\Contrib\Instrumentation\Psr3; +use JsonSerializable; +use OpenTelemetry\API\Behavior\LogsMessagesTrait; +use Stringable; use Throwable; class Formatter { + + use LogsMessagesTrait; + public static function format(array $context): array { $formatted = []; @@ -15,7 +21,35 @@ public static function format(array $context): array if ($key === 'exception' && $value instanceof Throwable) { $formatted[$key] = self::formatThrowable($value); } else { - $formatted[$key] = json_decode(json_encode($value) ?: ''); + switch (gettype($value)) { + case 'integer': + case 'double': + case 'boolean': + $formatted[$key] = $value; + + break; + case 'string': + case 'array': + // Handle UTF-8 encoding issues + $encoded = json_encode($value, JSON_INVALID_UTF8_SUBSTITUTE); + if ($encoded === false) { + self::logWarning('Failed to encode value: ' . json_last_error_msg()); + } else { + $formatted[$key] = json_decode($encoded); + } + + break; + case 'object': + if ($value instanceof Stringable) { + $formatted[$key] = (string) $value; + } elseif ($value instanceof JsonSerializable) { + $formatted[$key] = $value->jsonSerialize(); + } + + break; + default: + //do nothing + } } } diff --git a/src/Instrumentation/Psr3/tests/Unit/FormatterTest.php b/src/Instrumentation/Psr3/tests/Unit/FormatterTest.php index b827abe8b..116dcdbe4 100644 --- a/src/Instrumentation/Psr3/tests/Unit/FormatterTest.php +++ b/src/Instrumentation/Psr3/tests/Unit/FormatterTest.php @@ -4,17 +4,50 @@ namespace OpenTelemetry\Contrib\Psr3\tests\Unit; +use Exception; +use JsonSerializable; +use OpenTelemetry\API\Behavior\Internal\Logging; +use OpenTelemetry\API\Behavior\Internal\LogWriter\LogWriterInterface; use OpenTelemetry\Contrib\Instrumentation\Psr3\Formatter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Stringable; class FormatterTest extends TestCase { + /** @var LogWriterInterface&MockObject */ + private LogWriterInterface $logWriter; + + public function setUp(): void + { + $this->logWriter = $this->createMock(LogWriterInterface::class); + Logging::setLogWriter($this->logWriter); + } + + public function tearDown(): void + { + Logging::reset(); + } + public function test_format(): void { $context = [ 0 => 'zero', 'foo' => 'bar', - 'exception' => new \Exception('foo', 500, new \RuntimeException('bar')), + 'exception' => new Exception('foo', 500, new \RuntimeException('bar')), + 'j' => new class() implements JsonSerializable { + public function jsonSerialize(): array + { + return ['foo' => 'bar']; + } + }, + 's' => new class() implements Stringable { + public function __toString(): string + { + return 'string_value'; + } + }, + 'b' => true, ]; $formatted = Formatter::format($context); $this->assertSame('bar', $formatted['foo']); @@ -22,5 +55,23 @@ public function test_format(): void $this->assertSame('foo', $formatted['exception']['message']); $this->assertSame(500, $formatted['exception']['code']); $this->assertSame('bar', $formatted['exception']['previous']['message']); + $this->assertSame('string_value', $formatted['s']); + $this->assertSame(['foo' => 'bar'], $formatted['j']); + $this->assertTrue($formatted['b']); + } + + public function test_invalid_input_logs_warning(): void + { + $this->logWriter->expects($this->once())->method('write')->with( + $this->equalTo('warning'), + $this->stringContains('Failed to encode value'), + ); + $context = [ + 'good' => 'foo', + 'bad' => [fopen('php://memory', 'r+')], //resource cannot be encoded + ]; + $formatted = Formatter::format($context); + $this->assertSame('foo', $formatted['good']); + $this->assertArrayNotHasKey('bad', $formatted); } } diff --git a/src/Instrumentation/Psr3/tests/phpt/export_context.phpt b/src/Instrumentation/Psr3/tests/phpt/export_context.phpt new file mode 100644 index 000000000..6224b362f --- /dev/null +++ b/src/Instrumentation/Psr3/tests/phpt/export_context.phpt @@ -0,0 +1,110 @@ +--TEST-- +Test generating otel LogRecord with complex message context +--FILE-- + +getTracer('demo')->spanBuilder('root')->startSpan(); +$scope = $span->activate(); + +$context = [ + 's' => 'string', + 'i' => 1234, + 'l' => 3.14159, + 't' => true, + 'f' => false, + 'stringable' => new class() implements \Stringable { + public function __toString(): string + { + return 'some_string'; + } + }, + 'j' => new class() implements JsonSerializable { + public function jsonSerialize(): array + { + return ['key' => 'value']; + } + }, + 'array' => ['a', 'b', 'c'], + 'exception' => new \Exception('my_exception'), + 'bin' => \Symfony\Component\Uid\Uuid::v4()->toBinary(), +]; + + +$logger->info('test message', $context); + +$scope->detach(); +$span->end(); +?> + +--EXPECTF-- +{ + "resource": { + "attributes": [], + "dropped_attributes_count": 0 + }, + "scopes": [ + { + "name": "io.opentelemetry.contrib.php.psr3", + "version": null, + "attributes": [], + "dropped_attributes_count": 0, + "schema_url": "https:\/\/opentelemetry.io\/schemas\/%s", + "logs": [ + { + "timestamp": null, + "observed_timestamp": %d, + "severity_number": %d, + "severity_text": null, + "body": "test message", + "trace_id": "%s", + "span_id": "%s", + "trace_flags": 1, + "attributes": { + "s": "string", + "i": 1234, + "l": 3.14159, + "t": true, + "f": false, + "stringable": "some_string", + "j": { + "key": "value" + }, + "array": [ + "a", + "b", + "c" + ], + "exception": { + "message": "my_exception", + "code": 0, + "file": "Standard input code", + "line": %d, + "trace": [], + "previous": [] + }, + "bin": "%s" + }, + "dropped_attributes_count": 0 + } + ] + } + ] +} \ No newline at end of file