From 39c99e5a2fc60ee1a30c31d67f023ce4edf0af8a Mon Sep 17 00:00:00 2001 From: Grunet Date: Sun, 27 Mar 2022 20:18:32 -0500 Subject: [PATCH] Map OTEL Resources to Jaeger Process Tags (#636) * Commit in progress work because Gitpod is weird * Finish initial stab at the changes * Forgot to update the exporter * Add in proper handling of serialization of ResourceInfo * ksort doesn't return the array... * Only PHP 8.1 supports key-ed array destructuring * Creating some adapters to break the hard dependency on Batch to make testing easier * Refactoring to break the hard dependency on Batch * Add initial unit tests around the new logic in HttpSender * Some refactoring of the test's helpers * Another small refactor * Some more refactoring * Rename ResourceInfo's test file to match it's file name * Move a test our of REsourceInfoTest into a more appropriate file * Add some basic conformance tests around the new ResourceInfo::serialize method * Add a test to try and catch future properties that aren't added to ResourceInfo::serialize * CLean up comments * Replace string with constant * Improve variable names * Cleaning up comment * Split out tag creation into a helper class * Phan didn't like the type docs * Fix style * Fix tests after merge from main * Fix coverage reporting issues on 2 of the new files * Fix style * Rename vals to values * Shortening the batch adapter factory method name to create * Inline a method call * Inline some method calls * Shorten factory method to just "create" * Inline some code * Split assertions into 3 tests * Rename parameter in interface implementation for psalm + update the rest of the file to match Co-authored-by: Timo Michna --- .../Jaeger/BatchAdapter/BatchAdapter.php | 23 +++ .../BatchAdapter/BatchAdapterFactory.php | 13 ++ .../BatchAdapterFactoryInterface.php | 10 + .../BatchAdapter/BatchAdapterInterface.php | 12 ++ src/Contrib/Jaeger/HttpCollectorExporter.php | 8 +- src/Contrib/Jaeger/HttpSender.php | 139 ++++++++------ src/Contrib/Jaeger/SpanConverter.php | 82 +------- src/Contrib/Jaeger/TagFactory/TagFactory.php | 88 +++++++++ src/SDK/Resource/ResourceInfo.php | 12 ++ .../JaegerHttpCollectorExporterTest.php | 3 + tests/Unit/Contrib/JaegerHttpSenderTest.php | 180 ++++++++++++++++++ .../Unit/Contrib/JaegerSpanConverterTest.php | 1 + .../SDK/Resource/Detectors/ComposerTest.php | 8 + ...{ResourceTest.php => ResourceInfoTest.php} | 54 +++++- 14 files changed, 488 insertions(+), 145 deletions(-) create mode 100644 src/Contrib/Jaeger/BatchAdapter/BatchAdapter.php create mode 100644 src/Contrib/Jaeger/BatchAdapter/BatchAdapterFactory.php create mode 100644 src/Contrib/Jaeger/BatchAdapter/BatchAdapterFactoryInterface.php create mode 100644 src/Contrib/Jaeger/BatchAdapter/BatchAdapterInterface.php create mode 100644 src/Contrib/Jaeger/TagFactory/TagFactory.php create mode 100644 tests/Unit/Contrib/JaegerHttpSenderTest.php rename tests/Unit/SDK/Resource/{ResourceTest.php => ResourceInfoTest.php} (90%) diff --git a/src/Contrib/Jaeger/BatchAdapter/BatchAdapter.php b/src/Contrib/Jaeger/BatchAdapter/BatchAdapter.php new file mode 100644 index 00000000..62e4df05 --- /dev/null +++ b/src/Contrib/Jaeger/BatchAdapter/BatchAdapter.php @@ -0,0 +1,23 @@ +batchInstance = new Batch($values); + } + + public function write(TProtocol $output): void + { + $this->batchInstance->write($output); + } +} diff --git a/src/Contrib/Jaeger/BatchAdapter/BatchAdapterFactory.php b/src/Contrib/Jaeger/BatchAdapter/BatchAdapterFactory.php new file mode 100644 index 00000000..883a4a1f --- /dev/null +++ b/src/Contrib/Jaeger/BatchAdapter/BatchAdapterFactory.php @@ -0,0 +1,13 @@ +spanConverter = new SpanConverter(); } /** @@ -48,9 +44,7 @@ class HttpCollectorExporter implements SpanExporterInterface */ public function doExport(iterable $spans): int { - $this->sender->send( - $this->spanConverter->convert($spans) - ); + $this->sender->send($spans); return SpanExporterInterface::STATUS_SUCCESS; } diff --git a/src/Contrib/Jaeger/HttpSender.php b/src/Contrib/Jaeger/HttpSender.php index abb5572a..60d8e5bb 100644 --- a/src/Contrib/Jaeger/HttpSender.php +++ b/src/Contrib/Jaeger/HttpSender.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace OpenTelemetry\Contrib\Jaeger; -use Jaeger\Thrift\Batch; use Jaeger\Thrift\Process; -use Jaeger\Thrift\Span as JTSpan; +use OpenTelemetry\Contrib\Jaeger\BatchAdapter\BatchAdapterFactory; +use OpenTelemetry\Contrib\Jaeger\BatchAdapter\BatchAdapterFactoryInterface; +use OpenTelemetry\Contrib\Jaeger\BatchAdapter\BatchAdapterInterface; +use OpenTelemetry\Contrib\Jaeger\TagFactory\TagFactory; use OpenTelemetry\SDK\Behavior\LogsMessagesTrait; +use OpenTelemetry\SDK\Resource\ResourceInfo; +use OpenTelemetry\SemConv\ResourceAttributes; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -22,75 +26,104 @@ class HttpSender private TProtocol $protocol; + private BatchAdapterFactoryInterface $batchAdapterFactory; + public function __construct( ClientInterface $client, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, string $serviceName, - ParsedEndpointUrl $parsedEndpoint + ParsedEndpointUrl $parsedEndpoint, + BatchAdapterFactoryInterface $batchAdapterFactory = null ) { $this->serviceName = $serviceName; - $transport = (new ThriftHttpTransport( - $client, - $requestFactory, - $streamFactory, - $parsedEndpoint - )); + $this->protocol = new TBinaryProtocol( + new ThriftHttpTransport( + $client, + $requestFactory, + $streamFactory, + $parsedEndpoint + ) + ); - $this->protocol = new TBinaryProtocol($transport); + $this->batchAdapterFactory = $batchAdapterFactory ?? new BatchAdapterFactory(); } - /** - * @param JTSpan[] $spans - */ - public function send(array $spans): void + public function send(iterable $spans): void { - ///** @var Tag[] $tags */ TODO - uncomment this once the code below is uncommented/adapted + $batches = $this->createBatchesPerResource( + self::groupSpansByResource($spans) + ); + + foreach ($batches as $batch) { + $this->sendBatch($batch); + } + } + + private static function groupSpansByResource(iterable $spans): array + { + $spansGroupedByResource = []; + foreach ($spans as $span) { + /** @var ResourceInfo */ + $resource = $span->getResource(); + $resourceAsKey = $resource->serialize(); + + if (!isset($spansGroupedByResource[$resourceAsKey])) { + $spansGroupedByResource[$resourceAsKey] = [ + 'spans' => [], + 'resource' => $resource, + ]; + } + + $spansGroupedByResource[$resourceAsKey]['spans'][] = $span; + } + + return $spansGroupedByResource; + } + + private function createBatchesPerResource(array $spansGroupedByResource): array + { + $batches = []; + foreach ($spansGroupedByResource as $unused => $dataForBatch) { + $batch = $this->batchAdapterFactory->create([ + 'spans' => (new SpanConverter())->convert( + $dataForBatch['spans'] + ), + 'process' => $this->createProcessFromResource( + $dataForBatch['resource'] + ), + ]); + + $batches[] = $batch; + } + + return $batches; + } + + private function createProcessFromResource(ResourceInfo $resource): Process + { + $serviceName = $this->serviceName; //Defaulting to (what should be) the default resource's service name + $tags = []; + foreach ($resource->getAttributes() as $key => $value) { + if ($key === ResourceAttributes::SERVICE_NAME) { + $serviceName = (string) $value; - //TODO - determine what of this is still needed and how to adapt it for spec compliance - // foreach ($this->tracer->getTags() as $k => $v) { - // if (!in_array($k, $this->mapper->getSpecialSpanTags())) { - // if (strpos($k, $this->mapper->getProcessTagsPrefix()) !== 0) { - // continue ; - // } + continue; + } - // $quoted = preg_quote($this->mapper->getProcessTagsPrefix()); - // $k = preg_replace(sprintf('/^%s/', $quoted), '', $k); - // } + $tags[] = TagFactory::create($key, $value); + } - // if ($k === JAEGER_HOSTNAME_TAG_KEY) { - // $k = "hostname"; - // } - - // $tags[] = new Tag([ - // "key" => $k, - // "vType" => TagType::STRING, - // "vStr" => $v - // ]); - // } - - // $tags[] = new Tag([ - // "key" => "format", - // "vType" => TagType::STRING, - // "vStr" => "jaeger.thrift" - // ]); - - // $tags[] = new Tag([ - // "key" => "ip", - // "vType" => TagType::STRING, - // "vStr" => $this->tracer->getIpAddress() - // ]); - - $batch = new Batch([ - 'spans' => $spans, - 'process' => new Process([ - 'serviceName' => $this->serviceName, - 'tags' => $tags, - ]), + return new Process([ + 'serviceName' => $serviceName, + 'tags' => $tags, ]); + } + private function sendBatch(BatchAdapterInterface $batch): void + { $batch->write($this->protocol); $this->protocol->getTransport()->flush(); } diff --git a/src/Contrib/Jaeger/SpanConverter.php b/src/Contrib/Jaeger/SpanConverter.php index 26806892..b981a51b 100644 --- a/src/Contrib/Jaeger/SpanConverter.php +++ b/src/Contrib/Jaeger/SpanConverter.php @@ -8,10 +8,9 @@ use Jaeger\Thrift\Log; use Jaeger\Thrift\Span as JTSpan; use Jaeger\Thrift\SpanRef; use Jaeger\Thrift\SpanRefType; -use Jaeger\Thrift\Tag; -use Jaeger\Thrift\TagType; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\Contrib\Jaeger\TagFactory\TagFactory; use OpenTelemetry\SDK\Common\Time\Util as TimeUtil; use OpenTelemetry\SDK\Trace\EventInterface; use OpenTelemetry\SDK\Trace\LinkInterface; @@ -197,89 +196,12 @@ class SpanConverter implements SpanConverterInterface { $tags = []; foreach ($tagPairs as $key => $value) { - $tags[] = self::buildTag($key, $value); + $tags[] = TagFactory::create($key, $value); } return $tags; } - private static function buildTag(string $key, $value): Tag - { - return self::createJaegerTagInstance( - $key, - self::convertValueToTypeJaegerTagsSupport($value) - ); - } - - private static function convertValueToTypeJaegerTagsSupport($value) - { - if (is_array($value)) { - return self::serializeArrayToString($value); - } - - return $value; - } - - private static function createJaegerTagInstance(string $key, $value) - { - if (is_bool($value)) { - return new Tag([ - 'key' => $key, - 'vType' => TagType::BOOL, - 'vBool' => $value, - ]); - } - - if (is_integer($value)) { - return new Tag([ - 'key' => $key, - 'vType' => TagType::LONG, - 'vLong' => $value, - ]); - } - - if (is_numeric($value)) { - return new Tag([ - 'key' => $key, - 'vType' => TagType::DOUBLE, - 'vDouble' => $value, - ]); - } - - return new Tag([ - 'key' => $key, - 'vType' => TagType::STRING, - 'vStr' => (string) $value, - ]); - } - - private static function serializeArrayToString(array $arrayToSerialize): string - { - return self::recursivelySerializeArray($arrayToSerialize); - } - - private static function recursivelySerializeArray($value): string - { - if (is_array($value)) { - return join(',', array_map(function ($val) { - return self::recursivelySerializeArray($val); - }, $value)); - } - - // Casting false to string makes an empty string - if (is_bool($value)) { - return $value ? 'true' : 'false'; - } - - // Floats will lose precision if their string representation - // is >=14 or >=17 digits, depending on PHP settings. - // Can also throw E_RECOVERABLE_ERROR if $value is an object - // without a __toString() method. - // This is possible because OpenTelemetry\Trace\Span does not verify - // setAttribute() $value input. - return (string) $value; - } - private static function convertOtelEventsToJaegerLogs(SpanDataInterface $span): array { return array_map( diff --git a/src/Contrib/Jaeger/TagFactory/TagFactory.php b/src/Contrib/Jaeger/TagFactory/TagFactory.php new file mode 100644 index 00000000..86fab275 --- /dev/null +++ b/src/Contrib/Jaeger/TagFactory/TagFactory.php @@ -0,0 +1,88 @@ + $key, + 'vType' => TagType::BOOL, + 'vBool' => $value, + ]); + } + + if (is_integer($value)) { + return new Tag([ + 'key' => $key, + 'vType' => TagType::LONG, + 'vLong' => $value, + ]); + } + + if (is_numeric($value)) { + return new Tag([ + 'key' => $key, + 'vType' => TagType::DOUBLE, + 'vDouble' => $value, + ]); + } + + return new Tag([ + 'key' => $key, + 'vType' => TagType::STRING, + 'vStr' => (string) $value, + ]); + } + + private static function serializeArrayToString(array $arrayToSerialize): string + { + return self::recursivelySerializeArray($arrayToSerialize); + } + + private static function recursivelySerializeArray($value): string + { + if (is_array($value)) { + return join(',', array_map(function ($val) { + return self::recursivelySerializeArray($val); + }, $value)); + } + + // Casting false to string makes an empty string + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + // Floats will lose precision if their string representation + // is >=14 or >=17 digits, depending on PHP settings. + // Can also throw E_RECOVERABLE_ERROR if $value is an object + // without a __toString() method. + // This is possible because OpenTelemetry\Trace\Span does not verify + // setAttribute() $value input. + return (string) $value; + } +} diff --git a/src/SDK/Resource/ResourceInfo.php b/src/SDK/Resource/ResourceInfo.php index b14eeaf4..1e0d0d98 100644 --- a/src/SDK/Resource/ResourceInfo.php +++ b/src/SDK/Resource/ResourceInfo.php @@ -41,6 +41,18 @@ class ResourceInfo return $this->schemaUrl; } + public function serialize(): string + { + $copyOfAttributesAsArray = array_slice($this->attributes->toArray(), 0); //This may be overly cautious (in trying to avoid mutating the source array) + ksort($copyOfAttributesAsArray); //sort the associative array by keys since the serializer will consider equal arrays different otherwise + + //The exact return value doesn't matter, as long as it can distingusih between instances that represent the same/different resources + return serialize([ + 'schemaUrl' => $this->schemaUrl, + 'attributes' => $copyOfAttributesAsArray, + ]); + } + /** * Backward compatibility methods * diff --git a/tests/Unit/Contrib/JaegerHttpCollectorExporterTest.php b/tests/Unit/Contrib/JaegerHttpCollectorExporterTest.php index 043f70d7..b70e735d 100644 --- a/tests/Unit/Contrib/JaegerHttpCollectorExporterTest.php +++ b/tests/Unit/Contrib/JaegerHttpCollectorExporterTest.php @@ -14,6 +14,9 @@ use PHPUnit\Framework\TestCase; * @covers OpenTelemetry\Contrib\Jaeger\HttpSender * @covers OpenTelemetry\Contrib\Jaeger\ThriftHttpTransport * @covers OpenTelemetry\Contrib\Jaeger\ParsedEndpointUrl + * @covers OpenTelemetry\Contrib\Jaeger\BatchAdapter\BatchAdapter + * @covers OpenTelemetry\Contrib\Jaeger\BatchAdapter\BatchAdapterFactory + * */ class JaegerHttpCollectorExporterTest extends TestCase { diff --git a/tests/Unit/Contrib/JaegerHttpSenderTest.php b/tests/Unit/Contrib/JaegerHttpSenderTest.php new file mode 100644 index 00000000..64a7f786 --- /dev/null +++ b/tests/Unit/Contrib/JaegerHttpSenderTest.php @@ -0,0 +1,180 @@ + $serviceName + ] = $inputs; + + $mockBatchAdapterFactory = $this->createBatchAdapterFactoryMock(); + + /** + * @psalm-suppress PossiblyInvalidArgument + */ + $sender = new HttpSender( + $this->getClientInterfaceMock(), + $this->getRequestFactoryInterfaceMock(), + $this->getStreamFactoryInterfaceMock(), + $serviceName, + $this->createParsedEndpointUrlMock(), + $mockBatchAdapterFactory + ); + + return [ + 'sender' => $sender, + 'mockBatchAdapterFactory' => $mockBatchAdapterFactory, + ]; + } + + private function createParsedEndpointUrlMock(): ParsedEndpointUrl + { + /** @var ParsedEndpointUrl */ + $mock = $this->createMock(ParsedEndpointUrl::class); + + return $mock; + } + + private function createBatchAdapterFactoryMock(): BatchAdapterFactoryInterface + { + return new class() implements BatchAdapterFactoryInterface { + //Just enough spy functionality for what was needed for now. Generalize and extend as needed + private array $interceptedValues = []; + + public function getInterceptedValues() + { + return $this->interceptedValues; + } + + public function create(array $values): BatchAdapterInterface + { + $this->interceptedValues[] = $values; + + $mockBatchAdapter = new class() implements BatchAdapterInterface { + public function write(TProtocol $output): void + { + } + }; + + return $mockBatchAdapter; + } + }; + } + + public function test_span_and_process_data_are_batched_by_resource(): void + { + [ + 'sender' => $sender, + 'mockBatchAdapterFactory' => $mockBatchAdapterFactory + ] = $this->createSenderAndMocks([ + 'serviceName' => 'nameOfThe1stLogicalApp', + ]); + + $spans = [ + (new SpanData())->setResource(ResourceInfo::create( + new Attributes(), //code should default service.name from how its set above + )), + (new SpanData())->setResource(ResourceInfo::create( + new Attributes([ + 'service.name' => 'nameOfThe2ndLogicalApp', + ]), + )), + ]; + + $sender->send($spans); + + $interceptedValues = $mockBatchAdapterFactory->getInterceptedValues(); + $this->assertSame(2, count($interceptedValues)); + + //1st batch + $this->assertSame(1, count($interceptedValues[0]['spans'])); //Detailed tests for the span conversion live elsewhere + + //2nd batch + $this->assertSame(1, count($interceptedValues[1]['spans'])); //Detailed tests for the span conversion live elsewhere + } + + public function test_process_service_names_are_correctly_set_from_resource_attributes_or_the_default_service_name(): void + { + [ + 'sender' => $sender, + 'mockBatchAdapterFactory' => $mockBatchAdapterFactory + ] = $this->createSenderAndMocks([ + 'serviceName' => 'nameOfThe1stLogicalApp', + ]); + + $spans = [ + (new SpanData())->setResource(ResourceInfo::create( + new Attributes(), //code should default service.name from how its set above + )), + (new SpanData())->setResource(ResourceInfo::create( + new Attributes([ + 'service.name' => 'nameOfThe2ndLogicalApp', + ]), + )), + ]; + + $sender->send($spans); + + $interceptedValues = $mockBatchAdapterFactory->getInterceptedValues(); + + //1st batch + $this->assertSame('nameOfThe1stLogicalApp', $interceptedValues[0]['process']->serviceName); + + //2nd batch + $this->assertSame('nameOfThe2ndLogicalApp', $interceptedValues[1]['process']->serviceName); + } + + public function test_tags_are_correctly_set_from_resource_attributes(): void + { + [ + 'sender' => $sender, + 'mockBatchAdapterFactory' => $mockBatchAdapterFactory + ] = $this->createSenderAndMocks([ + 'serviceName' => 'someServiceName', + ]); + + $spans = [ + (new SpanData())->setResource(ResourceInfo::create( + new Attributes(), + )), + (new SpanData())->setResource(ResourceInfo::create( + new Attributes([ + 'telemetry.sdk.name' => 'opentelemetry', + ]), + )), + ]; + + $sender->send($spans); + + $interceptedValues = $mockBatchAdapterFactory->getInterceptedValues(); + + //1st batch + $this->assertSame(0, count($interceptedValues[0]['process']->tags)); + + //2nd batch + $this->assertSame(1, count($interceptedValues[1]['process']->tags)); + + $this->assertSame('telemetry.sdk.name', $interceptedValues[1]['process']->tags[0]->key); + $this->assertSame('opentelemetry', $interceptedValues[1]['process']->tags[0]->vStr); + } +} diff --git a/tests/Unit/Contrib/JaegerSpanConverterTest.php b/tests/Unit/Contrib/JaegerSpanConverterTest.php index 6b37eca0..cace395e 100644 --- a/tests/Unit/Contrib/JaegerSpanConverterTest.php +++ b/tests/Unit/Contrib/JaegerSpanConverterTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\TestCase; /** * @covers OpenTelemetry\Contrib\Jaeger\SpanConverter + * @covers OpenTelemetry\Contrib\Jaeger\TagFactory\TagFactory */ class JaegerSpanConverterTest extends TestCase { diff --git a/tests/Unit/SDK/Resource/Detectors/ComposerTest.php b/tests/Unit/SDK/Resource/Detectors/ComposerTest.php index 7f0876c2..6ef9e7ac 100644 --- a/tests/Unit/SDK/Resource/Detectors/ComposerTest.php +++ b/tests/Unit/SDK/Resource/Detectors/ComposerTest.php @@ -25,4 +25,12 @@ class ComposerTest extends TestCase $this->assertSame($name, $resource->getAttributes()->get(ResourceAttributes::SERVICE_NAME)); $this->assertSame($version, $resource->getAttributes()->get(ResourceAttributes::SERVICE_VERSION)); } + + public function test_composer_detector(): void + { + $resource = (new Detectors\Composer())->getResource(); + + $this->assertNotNull($resource->getAttributes()->get(ResourceAttributes::SERVICE_NAME)); + $this->assertNotNull($resource->getAttributes()->get(ResourceAttributes::SERVICE_VERSION)); + } } diff --git a/tests/Unit/SDK/Resource/ResourceTest.php b/tests/Unit/SDK/Resource/ResourceInfoTest.php similarity index 90% rename from tests/Unit/SDK/Resource/ResourceTest.php rename to tests/Unit/SDK/Resource/ResourceInfoTest.php index c18a4138..0df3af2e 100644 --- a/tests/Unit/SDK/Resource/ResourceTest.php +++ b/tests/Unit/SDK/Resource/ResourceInfoTest.php @@ -17,7 +17,7 @@ use PHPUnit\Framework\TestCase; /** * @covers OpenTelemetry\SDK\Resource\ResourceInfo */ -class ResourceTest extends TestCase +class ResourceInfoTest extends TestCase { use EnvironmentVariables; @@ -322,11 +322,55 @@ class ResourceTest extends TestCase $this->assertEquals('foo', $resource->getAttributes()->get('service.name')); } - public function test_composer_detector(): void + /** + * @dataProvider sameResourcesProvider + */ + public function test_serialize_returns_same_output_for_objects_representing_the_same_resource(ResourceInfo $resource1, ResourceInfo $resource2): void { - $resource = (new Detectors\Composer())->getResource(); + $this->assertSame($resource1->serialize(), $resource2->serialize()); + } - $this->assertNotNull($resource->getAttributes()->get(ResourceAttributes::SERVICE_NAME)); - $this->assertNotNull($resource->getAttributes()->get(ResourceAttributes::SERVICE_VERSION)); + public function sameResourcesProvider(): iterable + { + yield 'Attribute keys sorted in ascending order vs Attribute keys sorted in descending order' => [ + ResourceInfo::create(new Attributes([ + 'a' => 'someValue', + 'b' => 'someValue', + 'c' => 'someValue', + ])), + ResourceInfo::create(new Attributes([ + 'c' => 'someValue', + 'b' => 'someValue', + 'a' => 'someValue', + ])), + ]; + } + + /** + * @dataProvider differentResourcesProvider + */ + public function test_serialize_returns_different_output_for_objects_representing_different_resources(ResourceInfo $resource1, ResourceInfo $resource2): void + { + $this->assertNotSame($resource1->serialize(), $resource2->serialize()); + } + + public function differentResourcesProvider(): iterable + { + yield 'Null schema url vs Some schema url' => [ + ResourceInfo::create(new Attributes(), null), + ResourceInfo::create(new Attributes(), 'someSchemaUrl'), + ]; + } + + public function test_serialize_incorporates_all_properties(): void + { + $resource = ResourceInfoFactory::emptyResource(); + $properties = (new \ReflectionClass($resource))->getProperties(); + + $serializedResource = $resource->serialize(); + + foreach ($properties as $property) { + $this->assertStringContainsString($property->getName(), $serializedResource); + } } }