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 @@
+[](https://github.com/opentelemetry-php/contrib-test-utils/releases)
+[](https://github.com/open-telemetry/opentelemetry-php/issues)
+[](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Utils/Test)
+[](https://github.com/opentelemetry-php/contrib-test-utils)
+[](https://packagist.org/packages/open-telemetry/test-utils/)
+[](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);
+ }
+}