diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index cfc53004f..86ef7d326 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -54,6 +54,7 @@ jobs: 'Sampler/RuleBased', 'Shims/OpenTracing', 'Symfony', + 'Utils/Test' ] exclude: diff --git a/.gitsplit.yml b/.gitsplit.yml index 77a10900a..072982784 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -78,6 +78,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sampler-rulebased.git" - prefix: "src/Shims/OpenTracing" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-shim-opentracing.git" + - prefix: "src/Utils/Test" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-utils-test.git" # List of references to split (defined as regexp) origins: - ^main$ diff --git a/composer.json b/composer.json index edee34038..5cb416eb1 100644 --- a/composer.json +++ b/composer.json @@ -59,6 +59,12 @@ "src/ResourceDetectors/Container/_register.php" ] }, + "autoload-dev": { + "psr-4": { + "OpenTelemetry\\TestUtils\\": "src/", + "OpenTelemetry\\TestUtils\\Tests\\": "tests/" + } + }, "replace": { "open-telemetry/contrib-aws": "self.version", "open-telemetry/contrib-sdk-bundle": "self.version", @@ -81,7 +87,8 @@ "open-telemetry/opentelemetry-logger-monolog": "self.version", "open-telemetry/detector-container": "self.version", "open-telemetry/symfony-sdk-bundle": "self.version", - "open-telemetry/opentracing-shim": "self.version" + "open-telemetry/opentracing-shim": "self.version", + "open-telemetry/test-utils": "self.version" }, "config": { "sort-packages": true, diff --git a/src/Utils/Test/.gitattributes b/src/Utils/Test/.gitattributes new file mode 100644 index 000000000..1676cf825 --- /dev/null +++ b/src/Utils/Test/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore diff --git a/src/Utils/Test/.gitignore b/src/Utils/Test/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/src/Utils/Test/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/Utils/Test/.php-cs-fixer.php b/src/Utils/Test/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/Utils/Test/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); + diff --git a/src/Utils/Test/README.md b/src/Utils/Test/README.md new file mode 100644 index 000000000..6b1e365d6 --- /dev/null +++ b/src/Utils/Test/README.md @@ -0,0 +1,264 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-test-utils/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Utils/Test) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-test-utils) +[![Latest Version](http://poser.pugx.org/open-telemetry/test-utils/v/unstable)](https://packagist.org/packages/open-telemetry/test-utils/) +[![Stable](http://poser.pugx.org/open-telemetry/test-utils/v/stable)](https://packagist.org/packages/open-telemetry/test-utils/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry Test Utilities + +This package provides testing utilities for OpenTelemetry PHP instrumentations. It includes tools to help test and validate trace structures, span relationships, and other OpenTelemetry-specific functionality. + +## Features + +### TraceStructureAssertionTrait + +The `TraceStructureAssertionTrait` provides methods to assess if spans match an expected trace structure. It's particularly useful for testing complex trace hierarchies and relationships between spans. + +Key features: +- Support for hierarchical span relationships +- Verification of span names, kinds, attributes, events, and status +- Flexible matching with strict and non-strict modes +- Support for PHPUnit matchers/constraints for more flexible assertions +- Detailed error messages for failed assertions +- Two interfaces: array-based and fluent + +## Requirements + +* PHP 7.4 or higher +* OpenTelemetry SDK and API (for testing) +* PHPUnit 9.5 or higher + +## Usage + +### TraceStructureAssertionTrait + +Add the trait to your test class: + +```php +use OpenTelemetry\TestUtils\TraceStructureAssertionTrait; +use PHPUnit\Framework\TestCase; + +class MyTest extends TestCase +{ + use TraceStructureAssertionTrait; + + // Your test methods... +} +``` + +#### Array-Based Interface + +Use the `assertTraceStructure` method to verify trace structures using an array-based approach: + +```php +public function testTraceStructure(): void +{ + // Create spans using the OpenTelemetry SDK + // ... + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'root-span', + 'kind' => SpanKind::KIND_SERVER, + 'children' => [ + [ + 'name' => 'child-span', + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + ], + 'events' => [ + [ + 'name' => 'event.processed', + 'attributes' => [ + 'processed.id' => 'abc123', + ], + ], + ], + ], + [ + 'name' => 'another-child-span', + 'kind' => SpanKind::KIND_CLIENT, + 'status' => [ + 'code' => StatusCode::STATUS_ERROR, + 'description' => 'Something went wrong', + ], + ], + ], + ], + ]; + + // Assert the trace structure + $this->assertTraceStructure($spans, $expectedStructure); +} +``` + +The `assertTraceStructure` method takes the following parameters: +- `$spans`: An array or ArrayObject of spans (typically from an InMemoryExporter) +- `$expectedStructure`: An array defining the expected structure of the trace +- `$strict` (optional): Whether to perform strict matching (all attributes must match) + +#### Fluent Interface + +Use the `assertTrace` method to verify trace structures using a fluent, chainable interface: + +```php +public function testTraceStructure(): void +{ + // Create spans using the OpenTelemetry SDK + // ... + + // Assert the trace structure using the fluent interface + $this->assertTrace($spans) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + ->hasChild('child-span') + ->withKind(SpanKind::KIND_INTERNAL) + ->withAttribute('attribute.one', 'value1') + ->withAttribute('attribute.two', 42) + ->hasEvent('event.processed') + ->withAttribute('processed.id', 'abc123') + ->end() + ->end() + ->hasChild('another-child-span') + ->withKind(SpanKind::KIND_CLIENT) + ->withStatus(StatusCode::STATUS_ERROR, 'Something went wrong') + ->end() + ->end(); +} +``` + +The fluent interface provides the following methods: + +**TraceAssertion:** +- `hasRootSpan(string|Constraint $name)`: Assert that the trace has a root span with the given name +- `hasRootSpans(int $count)`: Assert that the trace has the expected number of root spans +- `inStrictMode()`: Enable strict mode for all assertions + +**SpanAssertion:** +- `withKind(int|Constraint $kind)`: Assert that the span has the expected kind +- `withAttribute(string $key, mixed|Constraint $value)`: Assert that the span has an attribute with the expected key and value +- `withAttributes(array $attributes)`: Assert that the span has the expected attributes +- `withStatus(int|Constraint $code, string|Constraint|null $description = null)`: Assert that the span has the expected status +- `hasEvent(string|Constraint $name)`: Assert that the span has an event with the expected name +- `hasChild(string|Constraint $name)`: Assert that the span has a child span with the expected name +- `hasChildren(int $count)`: Assert that the span has the expected number of children +- `end()`: Return to the parent assertion + +**SpanEventAssertion:** +- `withAttribute(string $key, mixed|Constraint $value)`: Assert that the event has an attribute with the expected key and value +- `withAttributes(array $attributes)`: Assert that the event has the expected attributes +- `end()`: Return to the parent span assertion + +### Using PHPUnit Matchers + +You can use PHPUnit constraints/matchers for more flexible assertions with both interfaces: + +#### Array-Based Interface with Matchers + +```php +use PHPUnit\Framework\Constraint\Callback; +use PHPUnit\Framework\Constraint\IsIdentical; +use PHPUnit\Framework\Constraint\IsType; +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\Constraint\StringContains; + +// Define the expected structure with matchers +$expectedStructure = [ + [ + 'name' => 'root-span', + 'kind' => new IsIdentical(SpanKind::KIND_SERVER), + 'attributes' => [ + 'string.attribute' => new StringContains('World'), + 'numeric.attribute' => new Callback(function ($value) { + return $value > 40 || $value === 42; + }), + 'boolean.attribute' => new IsType('boolean'), + 'array.attribute' => new Callback(function ($value) { + return is_array($value) && count($value) === 3 && in_array('b', $value); + }), + ], + 'children' => [ + [ + 'name' => new RegularExpression('/child-span-\d+/'), + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'timestamp' => new IsType('integer'), + ], + 'events' => [ + [ + 'name' => 'process.start', + 'attributes' => [ + 'process.id' => new IsType('integer'), + 'process.name' => new StringContains('process'), + ], + ], + ], + ], + ], + ], +]; + +// Assert the trace structure with matchers +$this->assertTraceStructure($spans, $expectedStructure); +``` + +#### Fluent Interface with Matchers + +```php +use PHPUnit\Framework\Constraint\Callback; +use PHPUnit\Framework\Constraint\IsIdentical; +use PHPUnit\Framework\Constraint\IsType; +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\Constraint\StringContains; + +// Assert the trace structure using the fluent interface with matchers +$this->assertTrace($spans) + ->hasRootSpan('root-span') + ->withKind(new IsIdentical(SpanKind::KIND_SERVER)) + ->withAttribute('string.attribute', new StringContains('World')) + ->withAttribute('numeric.attribute', new Callback(function ($value) { + return $value > 40 || $value === 42; + })) + ->withAttribute('boolean.attribute', new IsType('boolean')) + ->withAttribute('array.attribute', new Callback(function ($value) { + return is_array($value) && count($value) === 3 && in_array('b', $value); + })) + ->hasChild(new RegularExpression('/child-span-\d+/')) + ->withKind(SpanKind::KIND_INTERNAL) + ->withAttribute('timestamp', new IsType('integer')) + ->hasEvent('process.start') + ->withAttribute('process.id', new IsType('integer')) + ->withAttribute('process.name', new StringContains('process')) + ->end() + ->end() + ->end(); +``` + +Supported PHPUnit matchers include: +- `StringContains` for partial string matching +- `RegularExpression` for pattern matching +- `IsIdentical` for strict equality +- `IsEqual` for loose equality +- `IsType` for type checking +- `Callback` for custom validation logic + +## Installation via composer + +```bash +$ composer require --dev open-telemetry/test-utils +``` + +## Installing dependencies and executing tests + +From the Test Utils subdirectory: + +```bash +$ composer install +$ ./vendor/bin/phpunit tests +``` diff --git a/src/Utils/Test/composer.json b/src/Utils/Test/composer.json new file mode 100644 index 000000000..fcaffe793 --- /dev/null +++ b/src/Utils/Test/composer.json @@ -0,0 +1,29 @@ +{ + "name": "open-telemetry/test-utils", + "description": "Testing utilities for OpenTelemetry PHP instrumentations", + "type": "library", + "license": "Apache-2.0", + "autoload": { + "psr-4": { + "OpenTelemetry\\TestUtils\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OpenTelemetry\\TestUtils\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "require": {}, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5.0", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "psalm/plugin-phpunit": "^0.19.2", + "open-telemetry/api": "^1.0", + "open-telemetry/sdk": "^1.0", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4|^5|^6" + } +} diff --git a/src/Utils/Test/phpstan.neon.dist b/src/Utils/Test/phpstan.neon.dist new file mode 100644 index 000000000..ed94c13da --- /dev/null +++ b/src/Utils/Test/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + tmpDir: var/cache/phpstan + level: 5 + paths: + - src + - tests diff --git a/src/Utils/Test/phpunit.xml.dist b/src/Utils/Test/phpunit.xml.dist new file mode 100644 index 000000000..44d976f6f --- /dev/null +++ b/src/Utils/Test/phpunit.xml.dist @@ -0,0 +1,44 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + + diff --git a/src/Utils/Test/psalm.xml.dist b/src/Utils/Test/psalm.xml.dist new file mode 100644 index 000000000..155711712 --- /dev/null +++ b/src/Utils/Test/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/Utils/Test/src/.gitkeep b/src/Utils/Test/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/Utils/Test/src/Fluent/SpanAssertion.php b/src/Utils/Test/src/Fluent/SpanAssertion.php new file mode 100644 index 000000000..2a8b63b91 --- /dev/null +++ b/src/Utils/Test/src/Fluent/SpanAssertion.php @@ -0,0 +1,655 @@ +span = $span; + $this->traceAssertion = $traceAssertion; + $this->parentAssertion = $parentAssertion; + $this->expectedStructure = $expectedStructure; + $this->actualStructure = $actualStructure; + } + + /** + * Assert that the span has the expected kind. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param int|Constraint $kind The expected kind + * @throws TraceAssertionFailedException + * @return self + */ + public function withKind($kind): self + { + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'span_kind', + 'kind' => $kind instanceof Constraint ? 'constraint' : $kind, + ]; + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'span_kind', + 'kind' => $this->span->getKind(), + ]; + + // Perform the assertion + if ($kind instanceof Constraint) { + try { + Assert::assertThat( + $this->span->getKind(), + $kind, + 'Span kind does not match constraint' + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + sprintf( + "Span '%s' kind does not match constraint", + $this->span->getName() + ), + $this->expectedStructure, + $this->actualStructure + ); + } + } else { + try { + Assert::assertSame( + $kind, + $this->span->getKind(), + sprintf( + "Span '%s' expected kind %d, but got %d", + $this->span->getName(), + $kind, + $this->span->getKind() + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } + + return $this; + } + + /** + * Assert that the span has an attribute with the expected key and value. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param string $key The attribute key + * @param mixed|Constraint $value The expected value + * @throws TraceAssertionFailedException + * @return self + */ + public function withAttribute(string $key, $value): self + { + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'span_attribute', + 'key' => $key, + 'value' => $value instanceof Constraint ? 'constraint' : $value, + ]; + + // Check if the attribute exists + if (!$this->span->getAttributes()->has($key)) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_span_attribute', + 'key' => $key, + 'available_attributes' => array_keys($this->span->getAttributes()->toArray()), + ]; + + throw new TraceAssertionFailedException( + sprintf( + "Span '%s' is missing attribute '%s'", + $this->span->getName(), + $key + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Get the actual value + $actualValue = $this->span->getAttributes()->get($key); + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'span_attribute', + 'key' => $key, + 'value' => $actualValue, + ]; + + // Perform the assertion + if ($value instanceof Constraint) { + try { + Assert::assertThat( + $actualValue, + $value, + sprintf( + "Span '%s' attribute '%s' does not match constraint", + $this->span->getName(), + $key + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } else { + try { + Assert::assertEquals( + $value, + $actualValue, + sprintf( + "Span '%s' attribute '%s' expected value %s, but got %s", + $this->span->getName(), + $key, + $this->formatValue($value), + $this->formatValue($actualValue) + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } + + return $this; + } + + /** + * Assert that the span has the expected attributes. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param array $attributes The expected attributes + * @throws TraceAssertionFailedException + * @return self + */ + public function withAttributes(array $attributes): self + { + foreach ($attributes as $key => $value) { + $this->withAttribute($key, $value); + } + + return $this; + } + + /** + * Assert that the span has the expected status. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param string|Constraint $code The expected status code + * @param string|Constraint|null $description The expected status description + * @throws TraceAssertionFailedException + * @return self + */ + public function withStatus($code, $description = null): self + { + // Record the expectation + $expectation = [ + 'type' => 'span_status', + 'code' => $code instanceof Constraint ? 'constraint' : $code, + ]; + + if ($description !== null) { + $expectation['description'] = $description instanceof Constraint ? 'constraint' : $description; + } + + $this->expectedStructure[] = $expectation; + + // Get the actual status + $actualCode = $this->span->getStatus()->getCode(); + $actualDescription = $this->span->getStatus()->getDescription(); + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'span_status', + 'code' => $actualCode, + 'description' => $actualDescription, + ]; + + // Perform the assertion for code + if ($code instanceof Constraint) { + try { + Assert::assertThat( + $actualCode, + $code, + sprintf( + "Span '%s' status code does not match constraint", + $this->span->getName() + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } else { + try { + Assert::assertSame( + $code, + $actualCode, + sprintf( + "Span '%s' expected status code %d, but got %d", + $this->span->getName(), + $code, + $actualCode + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } + + // Perform the assertion for description if provided + if ($description !== null) { + if ($description instanceof Constraint) { + try { + Assert::assertThat( + $actualDescription, + $description, + sprintf( + "Span '%s' status description does not match constraint", + $this->span->getName() + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } else { + try { + Assert::assertSame( + $description, + $actualDescription, + sprintf( + "Span '%s' expected status description '%s', but got '%s'", + $this->span->getName(), + $description, + $actualDescription + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } + } + + return $this; + } + + /** + * Assert that the span has an event with the expected name. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param string|Constraint $name The expected event name + * @throws TraceAssertionFailedException + * @return SpanEventAssertion + */ + public function hasEvent($name): SpanEventAssertion + { + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'span_event', + 'name' => $name instanceof Constraint ? 'constraint' : $name, + ]; + + // Find the matching event + $events = $this->span->getEvents(); + $matchingEvent = null; + + if ($name instanceof Constraint) { + foreach ($events as $event) { + try { + Assert::assertThat( + $event->getName(), + $name, + 'Event name does not match constraint' + ); + $matchingEvent = $event; + + break; + } catch (AssertionFailedError $e) { + // This event doesn't match the constraint, skip it + continue; + } + } + } else { + foreach ($events as $event) { + if ($event->getName() === $name) { + $matchingEvent = $event; + + break; + } + } + } + + if (!$matchingEvent) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_span_event', + 'expected_name' => $name instanceof Constraint ? 'constraint' : $name, + 'available_events' => array_map(function ($event) { + return $event->getName(); + }, $events), + ]; + + throw new TraceAssertionFailedException( + sprintf( + "Span '%s' has no event matching name '%s'", + $this->span->getName(), + $name instanceof Constraint ? 'constraint' : $name + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'span_event', + 'name' => $matchingEvent->getName(), + 'event' => $matchingEvent, + ]; + + return new SpanEventAssertion($matchingEvent, $this, $this->expectedStructure, $this->actualStructure); + } + + /** + * Assert that the span has a child span with the expected name. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param string|Constraint $name The expected child span name + * @throws TraceAssertionFailedException + * @return SpanAssertion + */ + public function hasChild($name): SpanAssertion + { + // Find child spans + $childSpans = []; + $spanMap = $this->buildSpanMap($this->traceAssertion->getSpans()); + + // Get the children of this span + $childrenIds = $spanMap[$this->span->getSpanId()]['children'] ?? []; + foreach ($childrenIds as $childId) { + $childSpans[] = $spanMap[$childId]['span']; + } + + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'child_span', + 'name' => $name instanceof Constraint ? 'constraint' : $name, + ]; + + // Find the matching child span + $matchingSpan = null; + + if ($name instanceof Constraint) { + foreach ($childSpans as $childSpan) { + try { + Assert::assertThat( + $childSpan->getName(), + $name, + 'Child span name does not match constraint' + ); + $matchingSpan = $childSpan; + + break; + } catch (AssertionFailedError $e) { + // This span doesn't match the constraint, skip it + continue; + } + } + } else { + foreach ($childSpans as $childSpan) { + if ($childSpan->getName() === $name) { + $matchingSpan = $childSpan; + + break; + } + } + } + + if (!$matchingSpan) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_child_span', + 'expected_name' => $name instanceof Constraint ? 'constraint' : $name, + 'available_children' => array_map(function ($span) { + return $span->getName(); + }, $childSpans), + ]; + + throw new TraceAssertionFailedException( + sprintf( + "Span '%s' has no child span matching name '%s'", + $this->span->getName(), + $name instanceof Constraint ? 'constraint' : $name + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'child_span', + 'name' => $matchingSpan->getName(), + 'span' => $matchingSpan, + ]; + + return new SpanAssertion($matchingSpan, $this->traceAssertion, $this, $this->expectedStructure, $this->actualStructure); + } + + /** + * Assert that the span has a root span with the given name. + * This is a convenience method that delegates to the trace assertion. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param string|Constraint $name The expected name of the root span + * @throws TraceAssertionFailedException + * @return SpanAssertion + */ + public function hasRootSpan($name): SpanAssertion + { + return $this->traceAssertion->hasRootSpan($name); + } + + /** + * Assert that the span has the expected number of children. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @param int $count The expected number of children + * @throws TraceAssertionFailedException + * @return self + */ + public function hasChildren(int $count): self + { + // Find child spans + $childSpans = []; + $spanMap = $this->buildSpanMap($this->traceAssertion->getSpans()); + + // Get the children of this span + $childrenIds = $spanMap[$this->span->getSpanId()]['children'] ?? []; + foreach ($childrenIds as $childId) { + $childSpans[] = $spanMap[$childId]['span']; + } + + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'child_span_count', + 'count' => $count, + ]; + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'child_span_count', + 'count' => count($childSpans), + 'children' => array_map(function ($span) { + return $span->getName(); + }, $childSpans), + ]; + + try { + Assert::assertCount( + $count, + $childSpans, + sprintf( + "Span '%s' expected %d child spans, but found %d", + $this->span->getName(), + $count, + count($childSpans) + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + + return $this; + } + + /** + * Return to the parent assertion. + * + * @psalm-suppress PossiblyUnusedMethod + * + * @return SpanAssertion|TraceAssertion + */ + public function end() + { + return $this->parentAssertion ?? $this->traceAssertion; + } + + /** + * Format a value for display. + * + * @param mixed $value The value to format + * @return string + */ + private function formatValue($value): string + { + if (is_string($value)) { + return "\"$value\""; + } elseif (is_bool($value)) { + return $value ? 'true' : 'false'; + } elseif (null === $value) { + return 'null'; + } elseif (is_array($value)) { + $json = json_encode($value); + + return $json === false ? '[unable to encode]' : $json; + } + + return (string) $value; + + } + + /** + * Builds a map of spans indexed by their span IDs. + * + * @param array $spans + * @return array + */ + private function buildSpanMap(array $spans): array + { + $spanMap = []; + + foreach ($spans as $span) { + if (!$span instanceof ImmutableSpan) { + throw new \InvalidArgumentException('Each span must be an instance of ImmutableSpan'); + } + + $spanMap[$span->getSpanId()] = [ + 'span' => $span, + 'children' => [], + ]; + } + + // Establish parent-child relationships + foreach ($spanMap as $spanId => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // If the span has a parent and the parent is in our map + if ($parentSpanId && isset($spanMap[$parentSpanId])) { + $spanMap[$parentSpanId]['children'][] = $spanId; + } + } + + return $spanMap; + } +} diff --git a/src/Utils/Test/src/Fluent/SpanEventAssertion.php b/src/Utils/Test/src/Fluent/SpanEventAssertion.php new file mode 100644 index 000000000..c8a16efc6 --- /dev/null +++ b/src/Utils/Test/src/Fluent/SpanEventAssertion.php @@ -0,0 +1,188 @@ +event = $event; + $this->spanAssertion = $spanAssertion; + $this->expectedStructure = $expectedStructure; + $this->actualStructure = $actualStructure; + } + + /** + * Assert that the event has an attribute with the expected key and value. + * + * @param string $key The attribute key + * @param mixed|Constraint $value The expected value + * @throws TraceAssertionFailedException + * @return self + */ + public function withAttribute(string $key, $value): self + { + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'span_event_attribute', + 'key' => $key, + 'value' => $value instanceof Constraint ? 'constraint' : $value, + ]; + + // Check if the attribute exists + if (!$this->event->getAttributes()->has($key)) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_span_event_attribute', + 'key' => $key, + 'available_attributes' => array_keys($this->event->getAttributes()->toArray()), + ]; + + throw new TraceAssertionFailedException( + sprintf( + "Event '%s' is missing attribute '%s'", + $this->event->getName(), + $key + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Get the actual value + $actualValue = $this->event->getAttributes()->get($key); + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'span_event_attribute', + 'key' => $key, + 'value' => $actualValue, + ]; + + // Perform the assertion + if ($value instanceof Constraint) { + try { + Assert::assertThat( + $actualValue, + $value, + sprintf( + "Event '%s' attribute '%s' does not match constraint", + $this->event->getName(), + $key + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } else { + try { + Assert::assertEquals( + $value, + $actualValue, + sprintf( + "Event '%s' attribute '%s' expected value %s, but got %s", + $this->event->getName(), + $key, + $this->formatValue($value), + $this->formatValue($actualValue) + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + } + + return $this; + } + + /** + * Assert that the event has the expected attributes. + * + * @param array $attributes The expected attributes + * @throws TraceAssertionFailedException + * @return self + * @psalm-suppress PossiblyUnusedMethod + */ + public function withAttributes(array $attributes): self + { + foreach ($attributes as $key => $value) { + $this->withAttribute($key, $value); + } + + return $this; + } + + /** + * Return to the parent span assertion. + * + * @psalm-suppress UnusedMethodCall + * @return SpanAssertion + */ + public function end(): SpanAssertion + { + return $this->spanAssertion; + } + + /** + * Format a value for display. + * + * @param mixed $value The value to format + * @return string + */ + private function formatValue($value): string + { + if (is_string($value)) { + return "\"$value\""; + } elseif (is_bool($value)) { + return $value ? 'true' : 'false'; + } elseif (null === $value) { + return 'null'; + } elseif (is_array($value)) { + $json = json_encode($value); + + return $json === false ? '[unable to encode]' : $json; + } + + return (string) $value; + } +} diff --git a/src/Utils/Test/src/Fluent/TraceAssertion.php b/src/Utils/Test/src/Fluent/TraceAssertion.php new file mode 100644 index 000000000..a3ce0dca2 --- /dev/null +++ b/src/Utils/Test/src/Fluent/TraceAssertion.php @@ -0,0 +1,355 @@ +spans = $this->convertSpansToArray($spans); + $this->strict = $strict; + } + + /** + * Enable strict mode for all assertions. + * + * @return self + * @psalm-suppress PossiblyUnusedMethod + */ + public function inStrictMode(): self + { + $this->strict = true; + + return $this; + } + + /** + * Assert that the trace has a root span with the given name. + * + * @param string|Constraint $name The expected name of the root span + * @throws TraceAssertionFailedException + * @return SpanAssertion + */ + public function hasRootSpan($name): SpanAssertion + { + // Find root spans (spans without parents or with remote parents) + $rootSpans = []; + $spanMap = $this->buildSpanMap($this->spans); + + foreach ($spanMap as $_ => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // A span is a root span if it has no parent or its parent is not in our map + if (!$parentSpanId || !isset($spanMap[$parentSpanId])) { + $rootSpans[] = $span; + } + } + + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'root_span', + 'name' => $name instanceof Constraint ? 'constraint' : $name, + ]; + + // Find the matching root span + $matchingSpan = null; + + if ($name instanceof Constraint) { + foreach ($rootSpans as $rootSpan) { + try { + Assert::assertThat( + $rootSpan->getName(), + $name, + 'Root span name does not match constraint' + ); + $matchingSpan = $rootSpan; + + break; + } catch (AssertionFailedError $e) { + // This span doesn't match the constraint, skip it + continue; + } + } + } else { + foreach ($rootSpans as $rootSpan) { + if ($rootSpan->getName() === $name) { + $matchingSpan = $rootSpan; + + break; + } + } + } + + if (!$matchingSpan) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_root_span', + 'expected_name' => $name instanceof Constraint ? 'constraint' : $name, + 'available_root_spans' => array_map(function ($span) { + return $span->getName(); + }, $rootSpans), + ]; + + throw new TraceAssertionFailedException( + sprintf( + 'No root span matching name "%s" found', + $name instanceof Constraint ? 'constraint' : $name + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'root_span', + 'name' => $matchingSpan->getName(), + 'span' => $matchingSpan, + ]; + + return new SpanAssertion($matchingSpan, $this, null, $this->expectedStructure, $this->actualStructure); + } + + /** + * Assert that the trace has a child span with the expected name. + * + * @param string|Constraint $name The expected child span name + * @throws TraceAssertionFailedException + * @return SpanAssertion + */ + public function hasChild($name): SpanAssertion + { + // Find the matching span + $matchingSpan = null; + + if ($name instanceof Constraint) { + foreach ($this->spans as $span) { + try { + Assert::assertThat( + $span->getName(), + $name, + 'Span name does not match constraint' + ); + $matchingSpan = $span; + + break; + } catch (AssertionFailedError $e) { + // This span doesn't match the constraint, skip it + continue; + } + } + } else { + foreach ($this->spans as $span) { + if ($span->getName() === $name) { + $matchingSpan = $span; + + break; + } + } + } + + if (!$matchingSpan) { + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'missing_child_span', + 'expected_name' => $name instanceof Constraint ? 'constraint' : $name, + 'available_spans' => array_map(function ($span) { + return $span->getName(); + }, $this->spans), + ]; + + throw new TraceAssertionFailedException( + sprintf( + 'No span matching name "%s" found', + $name instanceof Constraint ? 'constraint' : $name + ), + $this->expectedStructure, + $this->actualStructure + ); + } + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'child_span', + 'name' => $matchingSpan->getName(), + 'span' => $matchingSpan, + ]; + + return new SpanAssertion($matchingSpan, $this, null, $this->expectedStructure, $this->actualStructure); + } + + /** + * Assert that the trace has the expected number of root spans. + * + * @param int $count The expected number of root spans + * @throws TraceAssertionFailedException + * @return self + */ + public function hasRootSpans(int $count): self + { + // Find root spans (spans without parents or with remote parents) + $rootSpans = []; + $spanMap = $this->buildSpanMap($this->spans); + + foreach ($spanMap as $_ => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // A span is a root span if it has no parent or its parent is not in our map + if (!$parentSpanId || !isset($spanMap[$parentSpanId])) { + $rootSpans[] = $span; + } + } + + // Record the expectation + $this->expectedStructure[] = [ + 'type' => 'root_span_count', + 'count' => $count, + ]; + + // Record the actual result + $this->actualStructure[] = [ + 'type' => 'root_span_count', + 'count' => count($rootSpans), + 'spans' => array_map(function ($span) { + return $span->getName(); + }, $rootSpans), + ]; + + try { + Assert::assertCount( + $count, + $rootSpans, + sprintf( + 'Expected %d root spans, but found %d', + $count, + count($rootSpans) + ) + ); + } catch (AssertionFailedError $e) { + throw new TraceAssertionFailedException( + $e->getMessage(), + $this->expectedStructure, + $this->actualStructure + ); + } + + return $this; + } + + /** + * Get the spans being asserted against. + * + * @return array + */ + public function getSpans(): array + { + return $this->spans; + } + + /** + * Check if strict mode is enabled. + * + * @return bool + * @psalm-suppress PossiblyUnusedMethod + */ + public function isStrict(): bool + { + return $this->strict; + } + + /** + * Return the trace assertion itself. + * This method is used to maintain a consistent fluent interface. + * + * @return self + */ + public function end(): self + { + return $this; + } + + /** + * Converts spans to an array if they are in a different format. + * + * @param array|ArrayObject|Traversable $spans + * @return array + */ + private function convertSpansToArray($spans): array + { + if (is_array($spans)) { + return $spans; + } + + if ($spans instanceof ArrayObject || $spans instanceof Traversable) { + return iterator_to_array($spans); + } + + /** @phpstan-ignore deadCode.unreachable */ + throw new \InvalidArgumentException('Spans must be an array, ArrayObject, or Traversable'); + } + + /** + * Builds a map of spans indexed by their span IDs. + * + * @param array $spans + * @return array + */ + private function buildSpanMap(array $spans): array + { + $spanMap = []; + + foreach ($spans as $span) { + if (!$span instanceof ImmutableSpan) { + throw new \InvalidArgumentException('Each span must be an instance of ImmutableSpan'); + } + + $spanMap[$span->getSpanId()] = [ + 'span' => $span, + 'children' => [], + ]; + } + + // Establish parent-child relationships + foreach ($spanMap as $spanId => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // If the span has a parent and the parent is in our map + if ($parentSpanId && isset($spanMap[$parentSpanId])) { + $spanMap[$parentSpanId]['children'][] = $spanId; + } + } + + return $spanMap; + } +} diff --git a/src/Utils/Test/src/Fluent/TraceAssertionFailedException.php b/src/Utils/Test/src/Fluent/TraceAssertionFailedException.php new file mode 100644 index 000000000..7e4c0083b --- /dev/null +++ b/src/Utils/Test/src/Fluent/TraceAssertionFailedException.php @@ -0,0 +1,271 @@ +formatDiff($expectedStructure, $actualStructure)); + $this->expectedStructure = $expectedStructure; + $this->actualStructure = $actualStructure; + } + + /** + * Get the expected structure. + * + * @return array + */ + public function getExpectedStructure(): array + { + return $this->expectedStructure; + } + + /** + * Get the actual structure. + * + * @return array + */ + public function getActualStructure(): array + { + return $this->actualStructure; + } + + /** + * Format the diff between expected and actual structures. + * + * @param array $expected The expected structure + * @param array $actual The actual structure + * @return string + */ + private function formatDiff(array $expected, array $actual): string + { + $output = "\n\nExpected Trace Structure:\n"; + $output .= $this->formatExpectedStructure($expected); + + $output .= "\n\nActual Trace Structure:\n"; + $output .= $this->formatActualStructure($actual); + + return $output; + } + + /** + * Format the expected structure. + * + * @param array $expected The expected structure + * @param int $indent The indentation level + * @return string + */ + private function formatExpectedStructure(array $expected, int $indent = 0): string + { + $output = ''; + $indentation = str_repeat(' ', $indent); + + foreach ($expected as $item) { + if (!isset($item['type'])) { + continue; + } + + switch ($item['type']) { + case 'root_span': + $output .= $indentation . "Root Span: \"{$item['name']}\"\n"; + + break; + case 'root_span_count': + $output .= $indentation . "Root Span Count: {$item['count']}\n"; + + break; + case 'span_kind': + $output .= $indentation . 'Kind: ' . $this->formatKind($item['kind']) . "\n"; + + break; + case 'span_attribute': + $output .= $indentation . "Attribute \"{$item['key']}\": " . $this->formatValue($item['value']) . "\n"; + + break; + case 'span_status': + $output .= $indentation . 'Status: Code=' . $this->formatValue($item['code']); + if (isset($item['description'])) { + $output .= ", Description=\"{$item['description']}\""; + } + $output .= "\n"; + + break; + case 'span_event': + $output .= $indentation . "Event: \"{$item['name']}\"\n"; + + break; + case 'span_event_attribute': + $output .= $indentation . "Event Attribute \"{$item['key']}\": " . $this->formatValue($item['value']) . "\n"; + + break; + case 'child_span': + $output .= $indentation . "Child Span: \"{$item['name']}\"\n"; + + break; + case 'child_span_count': + $output .= $indentation . "Child Span Count: {$item['count']}\n"; + + break; + } + } + + return $output; + } + + /** + * Format the actual structure. + * + * @param array $actual The actual structure + * @param int $indent The indentation level + * @return string + */ + private function formatActualStructure(array $actual, int $indent = 0): string + { + $output = ''; + $indentation = str_repeat(' ', $indent); + + foreach ($actual as $item) { + if (!isset($item['type'])) { + continue; + } + + switch ($item['type']) { + case 'root_span': + $output .= $indentation . "Root Span: \"{$item['name']}\"\n"; + + break; + case 'missing_root_span': + $output .= $indentation . "Missing Root Span: \"{$item['expected_name']}\"\n"; + if (!empty($item['available_root_spans'])) { + $output .= $indentation . ' Available Root Spans: ' . implode(', ', array_map(function ($name) { + return "\"$name\""; + }, $item['available_root_spans'])) . "\n"; + } + + break; + case 'root_span_count': + $output .= $indentation . "Root Span Count: {$item['count']}\n"; + if (!empty($item['spans'])) { + $output .= $indentation . ' Root Spans: ' . implode(', ', array_map(function ($name) { + return "\"$name\""; + }, $item['spans'])) . "\n"; + } + + break; + case 'span_kind': + $output .= $indentation . 'Kind: ' . $this->formatKind($item['kind']) . "\n"; + + break; + case 'span_attribute': + $output .= $indentation . "Attribute \"{$item['key']}\": " . $this->formatValue($item['value']) . "\n"; + + break; + case 'missing_span_attribute': + $output .= $indentation . "Missing Attribute: \"{$item['key']}\"\n"; + + break; + case 'span_status': + $output .= $indentation . 'Status: Code=' . $this->formatValue($item['code']); + if (isset($item['description'])) { + $output .= ", Description=\"{$item['description']}\""; + } + $output .= "\n"; + + break; + case 'span_event': + $output .= $indentation . "Event: \"{$item['name']}\"\n"; + + break; + case 'missing_span_event': + $output .= $indentation . "Missing Event: \"{$item['expected_name']}\"\n"; + + break; + case 'span_event_attribute': + $output .= $indentation . "Event Attribute \"{$item['key']}\": " . $this->formatValue($item['value']) . "\n"; + + break; + case 'child_span': + $output .= $indentation . "Child Span: \"{$item['name']}\"\n"; + + break; + case 'missing_child_span': + $output .= $indentation . "Missing Child Span: \"{$item['expected_name']}\"\n"; + + break; + case 'unexpected_child_span': + $output .= $indentation . "Unexpected Child Span: \"{$item['name']}\"\n"; + + break; + case 'child_span_count': + $output .= $indentation . "Child Span Count: {$item['count']}\n"; + + break; + } + } + + return $output; + } + + /** + * Format a value for display. + * + * @param mixed $value The value to format + * @return string + */ + private function formatValue($value): string + { + if (is_string($value)) { + return "\"$value\""; + } elseif (is_bool($value)) { + return $value ? 'true' : 'false'; + } elseif (null === $value) { + return 'null'; + } elseif (is_array($value)) { + $json = json_encode($value); + + return $json === false ? '[unable to encode]' : $json; + } + + return (string) $value; + } + + /** + * Format a span kind for display. + * + * @param int $kind The span kind + * @return string + */ + private function formatKind(int $kind): string + { + $kinds = [ + 0 => 'KIND_INTERNAL', + 1 => 'KIND_SERVER', + 2 => 'KIND_CLIENT', + 3 => 'KIND_PRODUCER', + 4 => 'KIND_CONSUMER', + ]; + + return $kinds[$kind] ?? "UNKNOWN_KIND($kind)"; + } +} diff --git a/src/Utils/Test/src/TraceStructureAssertionTrait.php b/src/Utils/Test/src/TraceStructureAssertionTrait.php new file mode 100644 index 000000000..dd79ec7af --- /dev/null +++ b/src/Utils/Test/src/TraceStructureAssertionTrait.php @@ -0,0 +1,646 @@ +convertSpansToArray($spans); + + // Build a map of spans by ID + $spanMap = $this->buildSpanMap($spansArray); + + // Build the actual trace structure + $actualStructure = $this->buildTraceStructure($spanMap); + + // Compare the actual structure with the expected structure + $this->compareTraceStructures($actualStructure, $expectedStructure, $strict); + } + + /** + * Converts spans to an array if they are in a different format and filters out non-span items. + * + * @psalm-suppress UnusedVariable + * @param array|ArrayObject|Traversable $spans + * @return array + */ + private function convertSpansToArray($spans): array + { + $array = []; + + if (is_array($spans)) { + $array = $spans; + } elseif ($spans instanceof ArrayObject || $spans instanceof Traversable) { + $array = iterator_to_array($spans); + } else { + /** @phpstan-ignore deadCode.unreachable */ + throw new \InvalidArgumentException('Spans must be an array, ArrayObject, or Traversable'); + } + + // Filter out non-span items + return array_filter($array, function ($item) { + return $item instanceof ImmutableSpan; + }); + } + + /** + * Builds a map of spans indexed by their span IDs. + * + * @param array $spans + * @return array + */ + private function buildSpanMap(array $spans): array + { + $spanMap = []; + + foreach ($spans as $span) { + // All non-span items should have been filtered out in convertSpansToArray + $spanMap[$span->getSpanId()] = [ + 'span' => $span, + 'children' => [], + ]; + } + + return $spanMap; + } + + /** + * Builds the hierarchical trace structure from the span map. + * + * @param array $spanMap + * @return array + */ + private function buildTraceStructure(array $spanMap): array + { + // First, establish parent-child relationships + foreach ($spanMap as $spanId => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // If the span has a parent and the parent is in our map + if ($parentSpanId && isset($spanMap[$parentSpanId])) { + $spanMap[$parentSpanId]['children'][] = $spanId; + } + } + + // Find root spans (spans without parents or with remote parents) + $rootSpans = []; + foreach ($spanMap as $spanId => $data) { + $span = $data['span']; + $parentSpanId = $span->getParentSpanId(); + + // A span is a root span if it has no parent or its parent is not in our map + if (!$parentSpanId || !isset($spanMap[$parentSpanId])) { + $rootSpans[] = $spanId; + } + } + + // Build the trace structure starting from root spans + $traceStructure = []; + foreach ($rootSpans as $rootSpanId) { + $traceStructure[] = $this->buildSpanStructure($rootSpanId, $spanMap); + } + + return $traceStructure; + } + + /** + * Recursively builds the structure for a span and its children. + * + * @param string $spanId + * @param array $spanMap + * @return array + */ + private function buildSpanStructure(string $spanId, array $spanMap): array + { + $data = $spanMap[$spanId]; + $span = $data['span']; + $childrenIds = $data['children']; + + $structure = [ + 'name' => $span->getName(), + 'kind' => $span->getKind(), + 'spanId' => $span->getSpanId(), + 'traceId' => $span->getTraceId(), + 'parentSpanId' => $span->getParentSpanId(), + 'attributes' => $span->getAttributes()->toArray(), + 'status' => [ + 'code' => $span->getStatus()->getCode(), + 'description' => $span->getStatus()->getDescription(), + ], + 'events' => $this->extractEvents($span->getEvents()), + 'children' => [], + ]; + + // Recursively build children structures + foreach ($childrenIds as $childId) { + $structure['children'][] = $this->buildSpanStructure($childId, $spanMap); + } + + return $structure; + } + + /** + * Extracts event data from span events. + * + * @param array $events + * @return array + */ + private function extractEvents(array $events): array + { + $extractedEvents = []; + + foreach ($events as $event) { + $extractedEvents[] = [ + 'name' => $event->getName(), + 'attributes' => $event->getAttributes()->toArray(), + ]; + } + + return $extractedEvents; + } + + /** + * Compares the actual trace structure with the expected structure. + * + * @param array $actualStructure + * @param array $expectedStructure + * @param bool $strict + * @throws AssertionFailedError + * @return void + */ + private function compareTraceStructures(array $actualStructure, array $expectedStructure, bool $strict): void + { + // Check if the number of root spans matches + Assert::assertCount( + count($expectedStructure), + $actualStructure, + sprintf( + 'Expected %d root spans, but found %d', + count($expectedStructure), + count($actualStructure) + ) + ); + + // For each expected root span, find a matching actual root span + foreach ($expectedStructure as $expectedRootSpan) { + $this->findMatchingSpan($expectedRootSpan, $actualStructure, $strict); + } + } + + /** + * Finds a span in the actual structure that matches the expected span. + * + * @param array $expectedSpan + * @param array $actualSpans + * @param bool $strict + * @throws AssertionFailedError + * @return void + */ + private function findMatchingSpan(array $expectedSpan, array $actualSpans, bool $strict): void + { + $expectedName = $expectedSpan['name'] ?? null; + + if ($expectedName === null) { + throw new \InvalidArgumentException('Expected span must have a name'); + } + + // Check if the expected name is a constraint + if ($this->isConstraint($expectedName)) { + // Find spans that match the constraint + $matchingSpans = []; + foreach ($actualSpans as $actualSpan) { + try { + Assert::assertThat( + $actualSpan['name'], + $expectedName, + 'Span name does not match constraint' + ); + $matchingSpans[] = $actualSpan; + } catch (AssertionFailedError $e) { + // This span doesn't match the constraint, skip it + continue; + } + } + } else { + // Find spans with the exact matching name + $matchingSpans = array_filter($actualSpans, function ($actualSpan) use ($expectedName) { + return $actualSpan['name'] === $expectedName; + }); + } + + Assert::assertNotEmpty( + $matchingSpans, + sprintf( + 'No span matching name "%s" found', + $this->isConstraint($expectedName) ? 'constraint' : $expectedName + ) + ); + + // If multiple spans match, try to match based on other properties + foreach ($matchingSpans as $actualSpan) { + try { + // For constraint-based names, we need to modify the expected span for comparison + // since compareSpans expects exact name matching + $spanToCompare = $expectedSpan; + if ($this->isConstraint($expectedName)) { + $spanToCompare = $expectedSpan; + $spanToCompare['name'] = $actualSpan['name']; + } + + $this->compareSpans($spanToCompare, $actualSpan, $strict); + + // If we get here, the spans match + // Now check children if they exist + if (isset($expectedSpan['children']) && !empty($expectedSpan['children'])) { + $this->compareChildren($expectedSpan['children'], $actualSpan['children'] ?? [], $strict); + } + + // If we get here, the span and its children match + return; + } catch (AssertionFailedError $e) { + // This span didn't match, try the next one + continue; + } + } + + // If we get here, none of the spans matched + Assert::fail( + sprintf( + 'No matching span found for expected span "%s"', + $this->isConstraint($expectedName) ? 'constraint' : $expectedName + ) + ); + } + + /** + * Compares an expected span with an actual span. + * + * @param array $expectedSpan + * @param array $actualSpan + * @param bool $strict + * @throws AssertionFailedError + * @return void + */ + private function compareSpans(array $expectedSpan, array $actualSpan, bool $strict): void + { + // Compare name (already matched in findMatchingSpan, but double-check) + Assert::assertSame( + $expectedSpan['name'], + $actualSpan['name'], + 'Span names do not match' + ); + + // Compare kind if specified + if (isset($expectedSpan['kind'])) { + $expectedKind = $expectedSpan['kind']; + + if ($this->isConstraint($expectedKind)) { + Assert::assertThat( + $actualSpan['kind'], + $expectedKind, + sprintf('Span kind does not match constraint for span "%s"', $expectedSpan['name']) + ); + } else { + Assert::assertSame( + $expectedKind, + $actualSpan['kind'], + sprintf('Span kinds do not match for span "%s"', $expectedSpan['name']) + ); + } + } + + // Compare attributes if specified + if (isset($expectedSpan['attributes'])) { + $this->compareAttributes( + $expectedSpan['attributes'], + $actualSpan['attributes'], + $strict, + $expectedSpan['name'] + ); + } + + // Compare status if specified + if (isset($expectedSpan['status'])) { + $this->compareStatus( + $expectedSpan['status'], + $actualSpan['status'], + $expectedSpan['name'] + ); + } + + // Compare events if specified + if (isset($expectedSpan['events'])) { + $this->compareEvents( + $expectedSpan['events'], + $actualSpan['events'], + $strict, + $expectedSpan['name'] + ); + } + } + + /** + * Compares the children of an expected span with the children of an actual span. + * + * @param array $expectedChildren + * @param array $actualChildren + * @param bool $strict + * @throws AssertionFailedError + * @return void + */ + private function compareChildren(array $expectedChildren, array $actualChildren, bool $strict): void + { + // Check if the number of children matches + Assert::assertCount( + count($expectedChildren), + $actualChildren, + sprintf( + 'Expected %d child spans, but found %d', + count($expectedChildren), + count($actualChildren) + ) + ); + + // For each expected child, find a matching actual child + foreach ($expectedChildren as $expectedChild) { + $this->findMatchingSpan($expectedChild, $actualChildren, $strict); + } + } + + /** + * Checks if a value is a PHPUnit constraint object. + * + * @param mixed $value + * @return bool + */ + private function isConstraint($value): bool + { + return $value instanceof Constraint; + } + + /** + * Compares the attributes of an expected span with the attributes of an actual span. + * + * @param array $expectedAttributes + * @param array $actualAttributes + * @param bool $strict + * @param string $spanName + * @throws AssertionFailedError + * @return void + */ + private function compareAttributes(array $expectedAttributes, array $actualAttributes, bool $strict, string $spanName): void + { + foreach ($expectedAttributes as $key => $expectedValue) { + // In strict mode, all attributes must be present and match + if ($strict) { + Assert::assertArrayHasKey( + $key, + $actualAttributes, + sprintf('Attribute "%s" not found in span "%s"', $key, $spanName) + ); + } elseif (!isset($actualAttributes[$key])) { + // In non-strict mode, if the attribute is not present, skip it + continue; + } + + // Get the actual value + $actualValue = $actualAttributes[$key]; + + // Check if the expected value is a constraint + if ($this->isConstraint($expectedValue)) { + // Use assertThat for constraint evaluation + Assert::assertThat( + $actualValue, + $expectedValue, + sprintf('Attribute "%s" value does not match constraint in span "%s"', $key, $spanName) + ); + } else { + // Use regular assertEquals for direct comparison + Assert::assertEquals( + $expectedValue, + $actualValue, + sprintf('Attribute "%s" value does not match in span "%s"', $key, $spanName) + ); + } + } + } + + /** + * Compares the status of an expected span with the status of an actual span. + * + * @param array $expectedStatus + * @param array $actualStatus + * @param string $spanName + * @throws AssertionFailedError + * @return void + */ + private function compareStatus(array $expectedStatus, array $actualStatus, string $spanName): void + { + // Compare status code if specified + if (isset($expectedStatus['code'])) { + $expectedCode = $expectedStatus['code']; + + if ($this->isConstraint($expectedCode)) { + Assert::assertThat( + $actualStatus['code'], + $expectedCode, + sprintf('Status code does not match constraint for span "%s"', $spanName) + ); + } else { + Assert::assertSame( + $expectedCode, + $actualStatus['code'], + sprintf('Status code does not match for span "%s"', $spanName) + ); + } + } + + // Compare status description if specified + if (isset($expectedStatus['description'])) { + $expectedDescription = $expectedStatus['description']; + + if ($this->isConstraint($expectedDescription)) { + Assert::assertThat( + $actualStatus['description'], + $expectedDescription, + sprintf('Status description does not match constraint for span "%s"', $spanName) + ); + } else { + Assert::assertSame( + $expectedDescription, + $actualStatus['description'], + sprintf('Status description does not match for span "%s"', $spanName) + ); + } + } + } + + /** + * Compares the events of an expected span with the events of an actual span. + * + * @param array $expectedEvents + * @param array $actualEvents + * @param bool $strict + * @param string $spanName + * @throws AssertionFailedError + * @return void + */ + private function compareEvents(array $expectedEvents, array $actualEvents, bool $strict, string $spanName): void + { + // In strict mode, the number of events must match + if ($strict) { + Assert::assertCount( + count($expectedEvents), + $actualEvents, + sprintf( + 'Expected %d events, but found %d in span "%s"', + count($expectedEvents), + count($actualEvents), + $spanName + ) + ); + } else { + // In non-strict mode, there must be at least as many actual events as expected + Assert::assertGreaterThanOrEqual( + count($expectedEvents), + count($actualEvents), + sprintf( + 'Expected at least %d events, but found only %d in span "%s"', + count($expectedEvents), + count($actualEvents), + $spanName + ) + ); + } + + // For each expected event, find a matching actual event + foreach ($expectedEvents as $expectedEvent) { + $this->findMatchingEvent($expectedEvent, $actualEvents, $strict, $spanName); + } + } + + /** + * Finds an event in the actual events that matches the expected event. + * + * @param array $expectedEvent + * @param array $actualEvents + * @param bool $strict + * @param string $spanName + * @throws AssertionFailedError + * @return void + */ + private function findMatchingEvent(array $expectedEvent, array $actualEvents, bool $strict, string $spanName): void + { + $expectedName = $expectedEvent['name'] ?? null; + + if ($expectedName === null) { + throw new \InvalidArgumentException('Expected event must have a name'); + } + + // Check if the expected name is a constraint + if ($this->isConstraint($expectedName)) { + // Find events that match the constraint + $matchingEvents = []; + foreach ($actualEvents as $actualEvent) { + try { + Assert::assertThat( + $actualEvent['name'], + $expectedName, + 'Event name does not match constraint' + ); + $matchingEvents[] = $actualEvent; + } catch (AssertionFailedError $e) { + // This event doesn't match the constraint, skip it + continue; + } + } + } else { + // Find events with the exact matching name + $matchingEvents = array_filter($actualEvents, function ($actualEvent) use ($expectedName) { + return $actualEvent['name'] === $expectedName; + }); + } + + Assert::assertNotEmpty( + $matchingEvents, + sprintf( + 'No event matching name "%s" found in span "%s"', + $this->isConstraint($expectedName) ? 'constraint' : $expectedName, + $spanName + ) + ); + + // If multiple events match, try to match based on attributes + foreach ($matchingEvents as $actualEvent) { + try { + // Compare attributes if specified + if (isset($expectedEvent['attributes'])) { + $this->compareAttributes( + $expectedEvent['attributes'], + $actualEvent['attributes'], + $strict, + sprintf( + 'Event "%s" in span "%s"', + $this->isConstraint($expectedName) ? $actualEvent['name'] : $expectedName, + $spanName + ) + ); + } + + // If we get here, the event matches + return; + } catch (AssertionFailedError $e) { + // This event didn't match, try the next one + continue; + } + } + + // If we get here, none of the events matched + Assert::fail( + sprintf( + 'No matching event found for expected event "%s" in span "%s"', + $this->isConstraint($expectedName) ? 'constraint' : $expectedName, + $spanName + ) + ); + } +} diff --git a/src/Utils/Test/tests/Unit/FluentTraceAssertionTest.php b/src/Utils/Test/tests/Unit/FluentTraceAssertionTest.php new file mode 100644 index 000000000..e99a31b37 --- /dev/null +++ b/src/Utils/Test/tests/Unit/FluentTraceAssertionTest.php @@ -0,0 +1,340 @@ +storage = new ArrayObject(); + + // Create a TracerProvider with an InMemoryExporter + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + } + + /** + * Test asserting a simple trace structure with a single span using the fluent interface. + */ + public function test_assert_simple_trace_structure_with_fluent_interface(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a single span + $span = $tracer->spanBuilder('test-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $span->setAttribute('attribute.one', 'value1'); + $span->setAttribute('attribute.two', 42); + + $span->end(); + + // Assert the trace structure using the fluent interface + $this->assertTrace($this->storage) + ->hasRootSpan('test-span') + ->withKind(SpanKind::KIND_SERVER) + ->withAttribute('attribute.one', 'value1') + ->withAttribute('attribute.two', 42) + ->end(); + } + + /** + * Test asserting a complex trace structure with parent-child relationships using the fluent interface. + */ + public function test_assert_complex_trace_structure_with_fluent_interface(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('attribute.one', 'value1'); + $childSpan->addEvent('event.processed', [ + 'processed.id' => 'abc123', + ]); + + $childSpan->end(); + + // Create another child span + $anotherChildSpan = $tracer->spanBuilder('another-child-span') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->startSpan(); + + $anotherChildSpan->setStatus(StatusCode::STATUS_ERROR, 'Something went wrong'); + + $anotherChildSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Assert the trace structure using the fluent interface + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + ->hasChild('child-span') + ->withKind(SpanKind::KIND_INTERNAL) + ->withAttribute('attribute.one', 'value1') + ->hasEvent('event.processed') + ->withAttribute('processed.id', 'abc123') + ->end() + ->end() + ->hasChild('another-child-span') + ->withKind(SpanKind::KIND_CLIENT) + ->withStatus(StatusCode::STATUS_ERROR, 'Something went wrong') + ->end() + ->end(); + } + + /** + * Test asserting a trace structure with strict matching using the fluent interface. + */ + public function test_assert_trace_structure_with_strict_matching_using_fluent_interface(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a span with multiple attributes + $span = $tracer->spanBuilder('test-span') + ->startSpan(); + + $span->setAttribute('attribute.one', 'value1'); + $span->setAttribute('attribute.two', 42); + $span->setAttribute('attribute.three', true); + + $span->end(); + + // Assert the trace structure with non-strict matching (should pass) + $this->assertTrace($this->storage) + ->hasRootSpan('test-span') + ->withAttribute('attribute.one', 'value1') + ->withAttribute('attribute.two', 42) + ->end(); + + // Assert the trace structure with strict matching (should pass) + $this->assertTrace($this->storage, true) + ->hasRootSpan('test-span') + ->withAttributes([ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + 'attribute.three' => true, + ]) + ->end(); + } + + /** + * Test asserting a trace structure with multiple root spans using the fluent interface. + */ + public function test_assert_trace_structure_with_multiple_root_spans_using_fluent_interface(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create first root span + $rootSpan1 = $tracer->spanBuilder('root-span-1') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan1->end(); + + // Create second root span + $rootSpan2 = $tracer->spanBuilder('root-span-2') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan2->end(); + + // Assert the trace structure using the fluent interface + $this->assertTrace($this->storage) + ->hasRootSpans(2) + ->hasRootSpan('root-span-1') + ->withKind(SpanKind::KIND_SERVER) + ->end() + ->hasRootSpan('root-span-2') + ->withKind(SpanKind::KIND_SERVER) + ->end(); + } + + /** + * Test asserting a trace structure using PHPUnit matchers with the fluent interface. + */ + public function test_assert_trace_structure_with_phpunit_matchers_using_fluent_interface(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span-with-matchers') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan->setAttribute('string.attribute', 'Hello, World!'); + $rootSpan->setAttribute('numeric.attribute', 42); + $rootSpan->setAttribute('boolean.attribute', true); + $rootSpan->setAttribute('array.attribute', ['a', 'b', 'c']); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span-123') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('timestamp', time()); + $childSpan->addEvent('process.start', [ + 'process.id' => 12345, + 'process.name' => 'test-process', + ]); + + $childSpan->end(); + + // Create another child span + $anotherChildSpan = $tracer->spanBuilder('error-span') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->startSpan(); + + $anotherChildSpan->setStatus(StatusCode::STATUS_ERROR, 'Error message'); + $anotherChildSpan->setAttribute('error.type', 'RuntimeException'); + + $anotherChildSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Assert the trace structure using the fluent interface with PHPUnit matchers + $this->assertTrace($this->storage) + ->hasRootSpan('root-span-with-matchers') + ->withKind(new IsIdentical(SpanKind::KIND_SERVER)) + ->withAttribute('string.attribute', new StringContains('World')) + ->withAttribute('numeric.attribute', new Callback(function ($value) { + /** @phpstan-ignore identical.alwaysFalse */ + return $value > 40 || $value === 42; + })) + ->withAttribute('boolean.attribute', new IsType('boolean')) + ->withAttribute('array.attribute', new Callback(function ($value) { + return is_array($value) && count($value) === 3 && in_array('b', $value); + })) + ->hasChild(new RegularExpression('/child-span-\d+/')) + ->withKind(SpanKind::KIND_INTERNAL) + ->withAttribute('timestamp', new IsType('integer')) + ->hasEvent('process.start') + ->withAttribute('process.id', new IsType('integer')) + ->withAttribute('process.name', new StringContains('process')) + ->end() + ->end() + ->hasChild(new StringContains('error')) + ->withKind(SpanKind::KIND_CLIENT) + ->withStatus(StatusCode::STATUS_ERROR, new StringContains('Error')) + ->withAttribute('error.type', new StringContains('Exception')) + ->end() + ->end(); + } + + /** + * Test that both the fluent interface and the array-based interface can be used together. + */ + public function test_both_interfaces_can_be_used_together(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('attribute.one', 'value1'); + + $childSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Assert using the fluent interface + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + ->hasChild('child-span') + ->withKind(SpanKind::KIND_INTERNAL) + ->withAttribute('attribute.one', 'value1') + ->end() + ->end(); + + // Assert using the array-based interface + $this->assertTraceStructure($this->storage, [ + [ + 'name' => 'root-span', + 'kind' => SpanKind::KIND_SERVER, + 'children' => [ + [ + 'name' => 'child-span', + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'attribute.one' => 'value1', + ], + ], + ], + ], + ]); + } +} diff --git a/src/Utils/Test/tests/Unit/MixedBufferTest.php b/src/Utils/Test/tests/Unit/MixedBufferTest.php new file mode 100644 index 000000000..f1fd3b6d2 --- /dev/null +++ b/src/Utils/Test/tests/Unit/MixedBufferTest.php @@ -0,0 +1,110 @@ +sharedBuffer = new ArrayObject(); + + // Create a TracerProvider with an InMemoryExporter using the shared buffer + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->sharedBuffer) + ) + ); + } + + /** + * Test that demonstrates the trait now automatically filters out logrecords. + */ + public function test_trait_automatically_filters_logrecords(): void + { + // Create a span + $tracer = $this->tracerProvider->getTracer('test-tracer'); + $span = $tracer->spanBuilder('test-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + $span->setAttribute('attribute.one', 'value1'); + $span->end(); + + // Manually add a logrecord to the shared buffer + $logRecord = new LogRecord('Test log message'); + $this->sharedBuffer->append($logRecord); + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'test-span', + 'kind' => SpanKind::KIND_SERVER, + 'attributes' => [ + 'attribute.one' => 'value1', + ], + ], + ]; + + // This should now pass because the trait automatically filters out logrecords + $this->assertTraceStructure($this->sharedBuffer, $expectedStructure); + } + + /** + * Test that demonstrates the manual solution to filter out logrecords still works. + */ + public function test_manual_filtering_of_logrecords_still_works(): void + { + // Create a span + $tracer = $this->tracerProvider->getTracer('test-tracer'); + $span = $tracer->spanBuilder('test-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + $span->setAttribute('attribute.one', 'value1'); + $span->end(); + + // Manually add a logrecord to the shared buffer + $logRecord = new LogRecord('Test log message'); + $this->sharedBuffer->append($logRecord); + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'test-span', + 'kind' => SpanKind::KIND_SERVER, + 'attributes' => [ + 'attribute.one' => 'value1', + ], + ], + ]; + + // Filter the buffer to only include spans + $spansOnly = array_filter(iterator_to_array($this->sharedBuffer), function ($item) { + return $item instanceof ImmutableSpan; + }); + + // This should pass because we've filtered out the logrecords + $this->assertTraceStructure($spansOnly, $expectedStructure); + } +} diff --git a/src/Utils/Test/tests/Unit/MultiSpanTest.php b/src/Utils/Test/tests/Unit/MultiSpanTest.php new file mode 100644 index 000000000..c3ed90533 --- /dev/null +++ b/src/Utils/Test/tests/Unit/MultiSpanTest.php @@ -0,0 +1,355 @@ +storage = new ArrayObject(); + + // Create a TracerProvider with an InMemoryExporter + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + } + + /** + * Test creating multiple spans with parent-child relationships. + */ + public function test_create_multiple_spans_with_parent_child_relationship(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Activate the root span to make it the current span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span (automatically becomes a child of the active span) + $childSpan = $tracer->spanBuilder('child-span') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + // Add some attributes to the child span + $childSpan->setAttribute('attribute.one', 'value1'); + $childSpan->setAttribute('attribute.two', 42); + + // Add an event to the child span + $childSpan->addEvent('event.processed', [ + 'processed.id' => 'abc123', + 'processed.timestamp' => time(), + ]); + + // End the child span + $childSpan->end(); + + // Create another child span + $anotherChildSpan = $tracer->spanBuilder('another-child-span') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->startSpan(); + + // Set error status on this span + $anotherChildSpan->setStatus(StatusCode::STATUS_ERROR, 'Something went wrong'); + + // End the second child span + $anotherChildSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Verify spans were created + $this->assertCount(3, $this->storage); + + // Get the spans from storage (they are stored in the order they were ended) + /** @var ImmutableSpan $firstChildSpan */ + $firstChildSpan = $this->storage[0]; + /** @var ImmutableSpan $secondChildSpan */ + $secondChildSpan = $this->storage[1]; + /** @var ImmutableSpan $exportedRootSpan */ + $exportedRootSpan = $this->storage[2]; + + // Verify the root span + $this->assertSame('root-span', $exportedRootSpan->getName()); + $this->assertSame(SpanKind::KIND_SERVER, $exportedRootSpan->getKind()); + + // Verify the first child span + $this->assertSame('child-span', $firstChildSpan->getName()); + $this->assertSame(SpanKind::KIND_INTERNAL, $firstChildSpan->getKind()); + $this->assertSame($exportedRootSpan->getSpanId(), $firstChildSpan->getParentSpanId()); + $this->assertSame($exportedRootSpan->getTraceId(), $firstChildSpan->getTraceId()); + + // Verify attributes on the first child span + $this->assertTrue($firstChildSpan->getAttributes()->has('attribute.one')); + $this->assertSame('value1', $firstChildSpan->getAttributes()->get('attribute.one')); + $this->assertTrue($firstChildSpan->getAttributes()->has('attribute.two')); + $this->assertSame(42, $firstChildSpan->getAttributes()->get('attribute.two')); + + // Verify events on the first child span + $this->assertCount(1, $firstChildSpan->getEvents()); + $event = $firstChildSpan->getEvents()[0]; + $this->assertSame('event.processed', $event->getName()); + $this->assertTrue($event->getAttributes()->has('processed.id')); + $this->assertSame('abc123', $event->getAttributes()->get('processed.id')); + + // Verify the second child span + $this->assertSame('another-child-span', $secondChildSpan->getName()); + $this->assertSame(SpanKind::KIND_CLIENT, $secondChildSpan->getKind()); + $this->assertSame($exportedRootSpan->getSpanId(), $secondChildSpan->getParentSpanId()); + $this->assertSame($exportedRootSpan->getTraceId(), $secondChildSpan->getTraceId()); + + // Verify status on the second child span + $this->assertSame(StatusCode::STATUS_ERROR, $secondChildSpan->getStatus()->getCode()); + $this->assertSame('Something went wrong', $secondChildSpan->getStatus()->getDescription()); + + // Verify the trace structure using the TraceStructureAssertionTrait + $expectedStructure = [ + [ + 'name' => 'root-span', + 'kind' => SpanKind::KIND_SERVER, + 'children' => [ + [ + 'name' => 'child-span', + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + ], + 'events' => [ + [ + 'name' => 'event.processed', + 'attributes' => [ + 'processed.id' => 'abc123', + ], + ], + ], + ], + [ + 'name' => 'another-child-span', + 'kind' => SpanKind::KIND_CLIENT, + 'status' => [ + 'code' => StatusCode::STATUS_ERROR, + 'description' => 'Something went wrong', + ], + ], + ], + ], + ]; + + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test creating multiple spans with explicit parent. + */ + public function test_create_multiple_spans_with_explicit_parent(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Create a child span with explicit parent + $childContext = Context::getCurrent()->withContextValue($rootSpan); + $childSpan = $tracer->spanBuilder('child-span') + ->setParent($childContext) + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + // Create a grandchild span with explicit parent + $grandchildContext = Context::getCurrent()->withContextValue($childSpan); + $grandchildSpan = $tracer->spanBuilder('grandchild-span') + ->setParent($grandchildContext) + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + // End spans in reverse order + $grandchildSpan->end(); + $childSpan->end(); + $rootSpan->end(); + + // Verify spans were created + $this->assertCount(3, $this->storage); + + // Get the spans from storage (they are stored in the order they were ended) + /** @var ImmutableSpan $exportedGrandchildSpan */ + $exportedGrandchildSpan = $this->storage[0]; + /** @var ImmutableSpan $exportedChildSpan */ + $exportedChildSpan = $this->storage[1]; + /** @var ImmutableSpan $exportedRootSpan */ + $exportedRootSpan = $this->storage[2]; + + // Verify the span hierarchy + $this->assertSame('root-span', $exportedRootSpan->getName()); + $this->assertSame('child-span', $exportedChildSpan->getName()); + $this->assertSame('grandchild-span', $exportedGrandchildSpan->getName()); + + // Verify parent-child relationships + $this->assertSame($exportedRootSpan->getSpanId(), $exportedChildSpan->getParentSpanId()); + $this->assertSame($exportedChildSpan->getSpanId(), $exportedGrandchildSpan->getParentSpanId()); + + // Verify all spans are part of the same trace + $this->assertSame($exportedRootSpan->getTraceId(), $exportedChildSpan->getTraceId()); + $this->assertSame($exportedRootSpan->getTraceId(), $exportedGrandchildSpan->getTraceId()); + + // Verify the trace structure using the TraceStructureAssertionTrait + $expectedStructure = [ + [ + 'name' => 'root-span', + 'kind' => SpanKind::KIND_SERVER, + 'children' => [ + [ + 'name' => 'child-span', + 'kind' => SpanKind::KIND_INTERNAL, + 'children' => [ + [ + 'name' => 'grandchild-span', + 'kind' => SpanKind::KIND_INTERNAL, + ], + ], + ], + ], + ], + ]; + + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test creating spans with different attributes and events. + */ + public function test_create_spans_with_different_attributes_and_events(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a span with multiple attributes + $span1 = $tracer->spanBuilder('span-with-attributes') + ->startSpan(); + + $span1->setAttribute('string.attribute', 'string-value'); + $span1->setAttribute('int.attribute', 42); + $span1->setAttribute('bool.attribute', true); + $span1->setAttribute('array.attribute', ['value1', 'value2', 'value3']); + + $span1->end(); + + // Create a span with multiple events + $span2 = $tracer->spanBuilder('span-with-events') + ->startSpan(); + + $span2->addEvent('event.one'); + $span2->addEvent('event.two', ['key1' => 'value1']); + $span2->addEvent('event.three', [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]); + + $span2->end(); + + // Verify spans were created + $this->assertCount(2, $this->storage); + + // Get the spans from storage + /** @var ImmutableSpan $exportedSpan1 */ + $exportedSpan1 = $this->storage[0]; + /** @var ImmutableSpan $exportedSpan2 */ + $exportedSpan2 = $this->storage[1]; + + // Verify attributes on span1 + $this->assertSame('span-with-attributes', $exportedSpan1->getName()); + $this->assertCount(4, $exportedSpan1->getAttributes()); + $this->assertSame('string-value', $exportedSpan1->getAttributes()->get('string.attribute')); + $this->assertSame(42, $exportedSpan1->getAttributes()->get('int.attribute')); + $this->assertTrue($exportedSpan1->getAttributes()->get('bool.attribute')); + $this->assertSame(['value1', 'value2', 'value3'], $exportedSpan1->getAttributes()->get('array.attribute')); + + // Verify events on span2 + $this->assertSame('span-with-events', $exportedSpan2->getName()); + $this->assertCount(3, $exportedSpan2->getEvents()); + + $events = $exportedSpan2->getEvents(); + $this->assertSame('event.one', $events[0]->getName()); + $this->assertCount(0, $events[0]->getAttributes()); + + $this->assertSame('event.two', $events[1]->getName()); + $this->assertCount(1, $events[1]->getAttributes()); + $this->assertSame('value1', $events[1]->getAttributes()->get('key1')); + + $this->assertSame('event.three', $events[2]->getName()); + $this->assertCount(3, $events[2]->getAttributes()); + $this->assertSame('value1', $events[2]->getAttributes()->get('key1')); + $this->assertSame('value2', $events[2]->getAttributes()->get('key2')); + $this->assertSame('value3', $events[2]->getAttributes()->get('key3')); + + // Verify the trace structure using the TraceStructureAssertionTrait + $expectedStructure = [ + [ + 'name' => 'span-with-attributes', + 'attributes' => [ + 'string.attribute' => 'string-value', + 'int.attribute' => 42, + 'bool.attribute' => true, + 'array.attribute' => ['value1', 'value2', 'value3'], + ], + ], + [ + 'name' => 'span-with-events', + 'events' => [ + ['name' => 'event.one'], + [ + 'name' => 'event.two', + 'attributes' => ['key1' => 'value1'], + ], + [ + 'name' => 'event.three', + 'attributes' => [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ], + ], + ], + ], + ]; + + $this->assertTraceStructure($this->storage, $expectedStructure); + } +} diff --git a/src/Utils/Test/tests/Unit/TraceAssertionFailedExceptionTest.php b/src/Utils/Test/tests/Unit/TraceAssertionFailedExceptionTest.php new file mode 100644 index 000000000..172894a3e --- /dev/null +++ b/src/Utils/Test/tests/Unit/TraceAssertionFailedExceptionTest.php @@ -0,0 +1,237 @@ +storage = new ArrayObject(); + + // Create a TracerProvider with an InMemoryExporter + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + } + + /** + * Test that the TraceAssertionFailedException provides a visual diff when an assertion fails. + */ + public function test_trace_assertion_failed_exception_provides_visual_diff(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('attribute.one', 'value1'); + $childSpan->setAttribute('attribute.two', 42); + + $childSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Intentionally create an assertion that will fail + try { + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + ->hasChild('child-span') + ->withKind(SpanKind::KIND_INTERNAL) + // This attribute doesn't exist, so it will fail + ->withAttribute('attribute.three', 'value3') + ->end() + ->end(); + + // If we get here, the test failed + $this->fail('Expected TraceAssertionFailedException was not thrown'); + } catch (TraceAssertionFailedException $e) { + // Verify that the exception message contains the expected and actual structures + $message = $e->getMessage(); + + // Check that the message contains the expected structure + $this->assertStringContainsString('Expected Trace Structure:', $message); + $this->assertStringContainsString('Attribute "attribute.three"', $message); + + // Check that the message contains the actual structure + $this->assertStringContainsString('Actual Trace Structure:', $message); + $this->assertStringContainsString('Missing Attribute: "attribute.three"', $message); + + // Verify that the exception contains the expected and actual structures + $this->assertNotEmpty($e->getExpectedStructure()); + $this->assertNotEmpty($e->getActualStructure()); + } + } + + /** + * Test that the TraceAssertionFailedException provides a visual diff when a child span is missing. + */ + public function test_trace_assertion_failed_exception_provides_visual_diff_for_missing_child(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan->end(); + + // Intentionally create an assertion that will fail due to a missing child span + try { + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + // This child span doesn't exist, so it will fail + ->hasChild('non-existent-child') + ->end(); + + // If we get here, the test failed + $this->fail('Expected TraceAssertionFailedException was not thrown'); + } catch (TraceAssertionFailedException $e) { + // Verify that the exception message contains the expected and actual structures + $message = $e->getMessage(); + + // Check that the message contains the expected structure + $this->assertStringContainsString('Expected Trace Structure:', $message); + $this->assertStringContainsString('Child Span: "non-existent-child"', $message); + + // Check that the message contains the actual structure + $this->assertStringContainsString('Actual Trace Structure:', $message); + $this->assertStringContainsString('Missing Child Span: "non-existent-child"', $message); + + // Verify that the exception contains the expected and actual structures + $this->assertNotEmpty($e->getExpectedStructure()); + $this->assertNotEmpty($e->getActualStructure()); + } + } + + /** + * Test that the TraceAssertionFailedException provides a visual diff when a span event is missing. + * @psalm-suppress UnusedMethodCall + */ + public function test_trace_assertion_failed_exception_provides_visual_diff_for_missing_event(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Add an event to the root span + $rootSpan->addEvent('event.one'); + + $rootSpan->end(); + + // Intentionally create an assertion that will fail due to a missing event + try { + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + ->withKind(SpanKind::KIND_SERVER) + // This event doesn't exist, so it will fail + ->hasEvent('non-existent-event') + ->end(); + + // If we get here, the test failed + $this->fail('Expected TraceAssertionFailedException was not thrown'); + } catch (TraceAssertionFailedException $e) { + // Verify that the exception message contains the expected and actual structures + $message = $e->getMessage(); + + // Check that the message contains the expected structure + $this->assertStringContainsString('Expected Trace Structure:', $message); + $this->assertStringContainsString('Event: "non-existent-event"', $message); + + // Check that the message contains the actual structure + $this->assertStringContainsString('Actual Trace Structure:', $message); + $this->assertStringContainsString('Missing Event: "non-existent-event"', $message); + + // Verify that the exception contains the expected and actual structures + $this->assertNotEmpty($e->getExpectedStructure()); + $this->assertNotEmpty($e->getActualStructure()); + } + } + + /** + * Test that the TraceAssertionFailedException provides a visual diff when a span kind is incorrect. + */ + public function test_trace_assertion_failed_exception_provides_visual_diff_for_incorrect_kind(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan->end(); + + // Intentionally create an assertion that will fail due to an incorrect kind + try { + $this->assertTrace($this->storage) + ->hasRootSpan('root-span') + // This kind is incorrect, so it will fail + ->withKind(SpanKind::KIND_CLIENT) + ->end(); + + // If we get here, the test failed + $this->fail('Expected TraceAssertionFailedException was not thrown'); + } catch (TraceAssertionFailedException $e) { + // Verify that the exception message contains the expected and actual structures + $message = $e->getMessage(); + + // Check that the message contains the expected structure + $this->assertStringContainsString('Expected Trace Structure:', $message); + $this->assertStringContainsString('Kind: KIND_CLIENT', $message); + + // Check that the message contains the actual structure + $this->assertStringContainsString('Actual Trace Structure:', $message); + $this->assertStringContainsString('Kind: KIND_SERVER', $message); + + // Verify that the exception contains the expected and actual structures + $this->assertNotEmpty($e->getExpectedStructure()); + $this->assertNotEmpty($e->getActualStructure()); + } + } +} diff --git a/src/Utils/Test/tests/Unit/TraceStructureAssertionTraitTest.php b/src/Utils/Test/tests/Unit/TraceStructureAssertionTraitTest.php new file mode 100644 index 000000000..ef97748d5 --- /dev/null +++ b/src/Utils/Test/tests/Unit/TraceStructureAssertionTraitTest.php @@ -0,0 +1,494 @@ +storage = new ArrayObject(); + + // Create a TracerProvider with an InMemoryExporter + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + } + + /** + * Test asserting a simple trace structure with a single span. + */ + public function test_assert_simple_trace_structure(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a single span + $span = $tracer->spanBuilder('test-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $span->setAttribute('attribute.one', 'value1'); + $span->setAttribute('attribute.two', 42); + + $span->end(); + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'test-span', + 'kind' => SpanKind::KIND_SERVER, + 'attributes' => [ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + ], + ], + ]; + + // Assert the trace structure + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test asserting a complex trace structure with parent-child relationships. + */ + public function test_assert_complex_trace_structure(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('attribute.one', 'value1'); + $childSpan->addEvent('event.processed', [ + 'processed.id' => 'abc123', + ]); + + $childSpan->end(); + + // Create another child span + $anotherChildSpan = $tracer->spanBuilder('another-child-span') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->startSpan(); + + $anotherChildSpan->setStatus(StatusCode::STATUS_ERROR, 'Something went wrong'); + + $anotherChildSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'root-span', + 'kind' => SpanKind::KIND_SERVER, + 'children' => [ + [ + 'name' => 'child-span', + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'attribute.one' => 'value1', + ], + 'events' => [ + [ + 'name' => 'event.processed', + 'attributes' => [ + 'processed.id' => 'abc123', + ], + ], + ], + ], + [ + 'name' => 'another-child-span', + 'kind' => SpanKind::KIND_CLIENT, + 'status' => [ + 'code' => StatusCode::STATUS_ERROR, + 'description' => 'Something went wrong', + ], + ], + ], + ], + ]; + + // Assert the trace structure + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test asserting a trace structure with strict matching. + */ + public function test_assert_trace_structure_with_strict_matching(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a span with multiple attributes + $span = $tracer->spanBuilder('test-span') + ->startSpan(); + + $span->setAttribute('attribute.one', 'value1'); + $span->setAttribute('attribute.two', 42); + $span->setAttribute('attribute.three', true); + + $span->end(); + + // Define the expected structure with only a subset of attributes + $expectedStructure = [ + [ + 'name' => 'test-span', + 'attributes' => [ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + ], + ], + ]; + + // Assert the trace structure with non-strict matching (should pass) + $this->assertTraceStructure($this->storage, $expectedStructure, false); + + // Define the expected structure with all attributes + $expectedStructureStrict = [ + [ + 'name' => 'test-span', + 'attributes' => [ + 'attribute.one' => 'value1', + 'attribute.two' => 42, + 'attribute.three' => true, + ], + ], + ]; + + // Assert the trace structure with strict matching (should pass) + $this->assertTraceStructure($this->storage, $expectedStructureStrict, true); + } + + /** + * Test asserting a trace structure with multiple root spans. + */ + public function test_assert_trace_structure_with_multiple_root_spans(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create first root span + $rootSpan1 = $tracer->spanBuilder('root-span-1') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan1->end(); + + // Create second root span + $rootSpan2 = $tracer->spanBuilder('root-span-2') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan2->end(); + + // Define the expected structure + $expectedStructure = [ + [ + 'name' => 'root-span-1', + 'kind' => SpanKind::KIND_SERVER, + ], + [ + 'name' => 'root-span-2', + 'kind' => SpanKind::KIND_SERVER, + ], + ]; + + // Assert the trace structure + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test that assertTraceStructure fails when there are additional root spans. + */ + public function test_assert_fails_with_additional_root_spans(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create two root spans + $rootSpan1 = $tracer->spanBuilder('root-span-1')->startSpan(); + $rootSpan1->end(); + + $rootSpan2 = $tracer->spanBuilder('root-span-2')->startSpan(); + $rootSpan2->end(); + + // Define expected structure with only one root span + $expectedStructure = [ + [ + 'name' => 'root-span-1', + ], + ]; + + // Expect assertion to fail + $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + $this->expectExceptionMessage('Expected 1 root spans, but found 2'); + + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test that assertTraceStructure fails when there are additional child spans. + */ + public function test_assert_fails_with_additional_child_spans(): void + { + $tracer = $this->tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span')->startSpan(); + $rootScope = $rootSpan->activate(); + + try { + // Create two child spans + $childSpan1 = $tracer->spanBuilder('child-span-1')->startSpan(); + $childSpan1->end(); + + $childSpan2 = $tracer->spanBuilder('child-span-2')->startSpan(); + $childSpan2->end(); + } finally { + $rootSpan->end(); + $rootScope->detach(); + } + + // Define expected structure with only one child span + $expectedStructure = [ + [ + 'name' => 'root-span', + 'children' => [ + [ + 'name' => 'child-span-1', + ], + ], + ], + ]; + + // Expect assertion to fail + $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + // We don't check the exact message as it might vary based on implementation details + + $this->assertTraceStructure($this->storage, $expectedStructure); + } + + /** + * Test that assertTraceStructure fails in strict mode when there are additional events. + */ + public function test_assert_fails_with_additional_events_in_strict_mode(): void + { + // Create a new test setup to avoid interference from previous tests + $storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($storage) + ) + ); + + $tracer = $tracerProvider->getTracer('test-tracer'); + + // Create a span with multiple events + $span = $tracer->spanBuilder('test-span')->startSpan(); + $span->addEvent('event-1'); + $span->addEvent('event-2'); + $span->end(); + + // Define expected structure with only one event + $expectedStructure = [ + [ + 'name' => 'test-span', + 'events' => [ + [ + 'name' => 'event-1', + ], + ], + ], + ]; + + // Assert passes in non-strict mode + $this->assertTraceStructure($storage, $expectedStructure, false); + + // Create a new test setup for the strict mode test + $strictStorage = new ArrayObject(); + $strictTracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($strictStorage) + ) + ); + + $strictTracer = $strictTracerProvider->getTracer('test-tracer'); + + // Create the same span structure again + $strictSpan = $strictTracer->spanBuilder('test-span')->startSpan(); + $strictSpan->addEvent('event-1'); + $strictSpan->addEvent('event-2'); + $strictSpan->end(); + + // Expect assertion to fail in strict mode + $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + // We don't check the exact message as it might vary based on implementation details + + $this->assertTraceStructure($strictStorage, $expectedStructure, true); + } + + /** + * Test asserting a trace structure using PHPUnit matchers. + */ + public function test_assert_trace_structure_with_phpunit_matchers(): void + { + // Create a new test setup to avoid interference from previous tests + $storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($storage) + ) + ); + + $tracer = $tracerProvider->getTracer('test-tracer'); + + // Create a root span + $rootSpan = $tracer->spanBuilder('root-span-with-matchers') + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $rootSpan->setAttribute('string.attribute', 'Hello, World!'); + $rootSpan->setAttribute('numeric.attribute', 42); + $rootSpan->setAttribute('boolean.attribute', true); + $rootSpan->setAttribute('array.attribute', ['a', 'b', 'c']); + + // Activate the root span + $rootScope = $rootSpan->activate(); + + try { + // Create a child span + $childSpan = $tracer->spanBuilder('child-span-123') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->startSpan(); + + $childSpan->setAttribute('timestamp', time()); + $childSpan->addEvent('process.start', [ + 'process.id' => 12345, + 'process.name' => 'test-process', + ]); + + $childSpan->end(); + + // Create another child span + $anotherChildSpan = $tracer->spanBuilder('error-span') + ->setSpanKind(SpanKind::KIND_CLIENT) + ->startSpan(); + + $anotherChildSpan->setStatus(StatusCode::STATUS_ERROR, 'Error message'); + $anotherChildSpan->setAttribute('error.type', 'RuntimeException'); + + $anotherChildSpan->end(); + } finally { + // End the root span + $rootSpan->end(); + + // Detach the root scope + $rootScope->detach(); + } + + // First, test with a simple structure without matchers + $simpleExpectedStructure = [ + [ + 'name' => 'root-span-with-matchers', + ], + ]; + + // This should pass + $this->assertTraceStructure($storage, $simpleExpectedStructure); + + // Now test with PHPUnit matchers + $expectedStructure = [ + [ + // Use exact string match for the name (not a matcher) + 'name' => 'root-span-with-matchers', + 'kind' => new IsIdentical(SpanKind::KIND_SERVER), + 'attributes' => [ + 'string.attribute' => new StringContains('World'), + 'numeric.attribute' => new Callback(function ($value) { + /** @phpstan-ignore identical.alwaysFalse */ + return $value > 40 || $value === 42; + }), + 'boolean.attribute' => new IsType('boolean'), + 'array.attribute' => new Callback(function ($value) { + return is_array($value) && count($value) === 3 && in_array('b', $value); + }), + ], + 'children' => [ + [ + 'name' => new RegularExpression('/child-span-\d+/'), + 'kind' => SpanKind::KIND_INTERNAL, + 'attributes' => [ + 'timestamp' => new IsType('integer'), + ], + 'events' => [ + [ + 'name' => 'process.start', + 'attributes' => [ + 'process.id' => new IsType('integer'), + 'process.name' => new StringContains('process'), + ], + ], + ], + ], + [ + 'name' => new StringContains('error'), + 'kind' => SpanKind::KIND_CLIENT, + 'status' => [ + 'code' => StatusCode::STATUS_ERROR, + 'description' => new StringContains('Error'), + ], + 'attributes' => [ + 'error.type' => new StringContains('Exception'), + ], + ], + ], + ], + ]; + + // Assert the trace structure with matchers + $this->assertTraceStructure($storage, $expectedStructure); + } +}