diff --git a/.gitignore b/.gitignore
index 0dcf066..9c1cabb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,9 +24,6 @@ prod/php/vendor_*/
.php_cs.cache
# PHPUnit
-# phpunit.xml.dist is checked into version control and phpunit.xml is ignored by version control.
-# This allows a developer to use a custom configuration without running the risk of accidentally checking it into version control.
-phpunit.xml
.phpunit.result.cache
#phpt results folder
diff --git a/composer.json b/composer.json
index 23acfa2..346807c 100644
--- a/composer.json
+++ b/composer.json
@@ -26,13 +26,20 @@
"slim/slim": "*"
},
"require-dev": {
+ "ext-ctype": "*",
+ "ext-curl": "*",
+ "ext-zlib": "*",
+ "guzzlehttp/guzzle": "^7.9.2",
+ "nikic/php-parser": "^5.4.0",
+ "php-ds/php-ds": "^1.5.0",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "1.4.0",
- "phpstan/phpstan": "2.0.2",
- "phpstan/phpstan-phpunit": "^2.0.1",
- "phpunit/phpunit": "^10.5",
+ "phpstan/phpstan": "2.1.3",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^10.5.45",
+ "react/http": "^1.6",
"slevomat/coding-standard": "8.15.0",
- "squizlabs/php_codesniffer": "3.11.1"
+ "squizlabs/php_codesniffer": "3.11.3"
},
"autoload-dev": {
"psr-4": {
@@ -61,31 +68,65 @@
"./tools/build/configure_php_templates.sh"
],
"parallel-lint": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
"parallel-lint ./prod/php/ ./tests/ --exclude ./tests/polyfills/"
],
"php_codesniffer_check": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
"phpcs -s ./prod/php/",
"phpcs -s ./tests/"
],
"php_codesniffer_fix": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
"phpcbf ./prod/php/",
"phpcbf ./tests/"
],
"fix_code_format_for": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
"phpcbf"
],
"phpstan-junit-report-for-ci": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
"phpstan analyse --error-format=junit ./prod/php/ --level max --memory-limit=1G | tee build/elastic-otel-phpstan-junit.xml",
"phpstan analyse --error-format=junit ./tests/ --level max --memory-limit=1G --error-format=junit | tee build/tests-phpstan-junit.xml"
],
"phpstan": [
- "phpstan analyse ./prod/php/",
- "phpstan analyse ./tests/"
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
+ "phpstan analyse ./prod/php/ --memory-limit=1G",
+ "phpstan analyse ./tests/ --memory-limit=1G"
],
"static_check": [
"composer run-script -- parallel-lint",
"composer run-script -- php_codesniffer_check",
"composer run-script -- phpstan"
+ ],
+ "run_unit_tests": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
+ "phpunit"
+ ],
+ "static_check_and_run_unit_tests": [
+ "composer run-script -- static_check",
+ "composer run-script -- run_unit_tests"
+ ],
+ "run_component_tests_configured_custom_config": [
+ "@putenv ELASTIC_OTEL_ENABLED=false",
+ "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all",
+ "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false",
+ "phpunit"
]
}
}
diff --git a/phpcs.xml.dist b/phpcs.xml
similarity index 84%
rename from phpcs.xml.dist
rename to phpcs.xml
index 1e7b114..dfed5e6 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml
@@ -28,4 +28,9 @@
*/prod/php/ElasticOTel/Log/LogFeature.php
*/prod/php/ElasticOTel/PhpPartVersion.php
+
+ */tests/polyfills/AllowDynamicProperties.php
+ */tests/polyfills/Override.php
+
+ */tests/substitutes/*/original/*
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 218b437..7a01ec4 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -1,5 +1,5 @@
includes:
- - vendor/phpstan/phpstan-phpunit/extension.neon
+ - ./vendor/phpstan/phpstan-phpunit/extension.neon
parameters:
bootstrapFiles:
@@ -8,3 +8,6 @@ parameters:
level: max
reportUnmatchedIgnoredErrors: true
+
+ excludePaths:
+ - ./tests/substitutes/*/original/*
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..1dc3c11
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,82 @@
+
+
+
+
+ ./tests/ElasticOTelTests/UnitTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./prod/php
+
+
+
+
+
+
+
+
diff --git a/phpunit_component_tests.xml b/phpunit_component_tests.xml
new file mode 100644
index 0000000..bee6c69
--- /dev/null
+++ b/phpunit_component_tests.xml
@@ -0,0 +1,82 @@
+
+
+
+
+ ./tests/ElasticOTelTests/ComponentTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./prod/php
+
+
+
+
+
+
+
+
diff --git a/prod/native/extension/code/ModuleInit.cpp b/prod/native/extension/code/ModuleInit.cpp
index 75da437..4746e6a 100644
--- a/prod/native/extension/code/ModuleInit.cpp
+++ b/prod/native/extension/code/ModuleInit.cpp
@@ -83,6 +83,11 @@ void elasticApmModuleInit(int moduleType, int moduleNumber) {
return;
}
+ if (EAPM_CFG(bootstrap_php_part_file).empty()) {
+ ELOGF_WARNING(globals->logger_, MODULE, "bootstrap_php_part_file configuration option is not set - extension will be disabled");
+ return;
+ }
+
ELOGF_DEBUG(globals->logger_, MODULE, "MINIT Replacing hooks");
elasticapm::php::Hooking::getInstance().fetchOriginalHooks();
elasticapm::php::Hooking::getInstance().replaceHooks(globals->config_->get().inferred_spans_enabled, globals->config_->get().dependency_autoloader_guard_enabled);
@@ -105,6 +110,10 @@ void elasticApmModuleShutdown( int moduleType, int moduleNumber ) {
return;
}
+ if (EAPM_CFG(bootstrap_php_part_file).empty()) {
+ return;
+ }
+
elasticapm::php::Hooking::getInstance().restoreOriginalHooks();
// curl_global_cleanup();
diff --git a/prod/native/extension/phpt/tests/includes/tests_util.inc b/prod/native/extension/phpt/tests/includes/tests_util.inc
index fd7f9af..1bf40c3 100644
--- a/prod/native/extension/phpt/tests/includes/tests_util.inc
+++ b/prod/native/extension/phpt/tests/includes/tests_util.inc
@@ -1,15 +1,15 @@
bootstrap_php_part_file.c_str());
bridge_->compileAndExecuteFile((*config_)->bootstrap_php_part_file);
+ ELOGF_DEBUG(log_, REQUEST, "Executing entry point in bootstrap_php_part_file: '%s' ...", (*config_)->bootstrap_php_part_file.c_str());
bridge_->callPHPSideEntryPoint(log_->getMaxLogLevel(), requestStartTime);
} catch (std::exception const &e) {
ELOGF_CRITICAL(log_, REQUEST, "Unable to bootstrap PHP-side instrumentation '%s'", e.what());
return false;
}
+ ELOGF_DEBUG(log_, REQUEST, "Executed entry point in bootstrap_php_part_file: %s", (*config_)->bootstrap_php_part_file.data());
return true;
}
diff --git a/prod/php/ElasticOTel/Autoloader.php b/prod/php/ElasticOTel/Autoloader.php
index 8a72dbf..4d781f3 100644
--- a/prod/php/ElasticOTel/Autoloader.php
+++ b/prod/php/ElasticOTel/Autoloader.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel;
@@ -32,11 +34,8 @@ final class Autoloader
{
private const AUTOLOAD_FQ_CLASS_NAME_PREFIX = 'Elastic\\OTel\\';
- /** @var int */
- private static $autoloadFqClassNamePrefixLength;
-
- /** @var string */
- private static $srcRootDir;
+ private static int $autoloadFqClassNamePrefixLength;
+ private static string $srcRootDir;
public static function register(string $rootDir): void
{
@@ -54,7 +53,7 @@ private static function shouldAutoloadCodeForClass(string $fqClassName): bool
public static function autoloadCodeForClass(string $fqClassName): void
{
- // Example of $fqClassName: Elastic\Apm\Impl\Util\Assert
+ // Example of $fqClassName: Elastic\OTel\Autoloader
BootstrapStageLogger::logTrace("Entered with fqClassName: `$fqClassName'", __FILE__, __LINE__, __CLASS__, __FUNCTION__);
diff --git a/prod/php/ElasticOTel/BootstrapStageLogger.php b/prod/php/ElasticOTel/BootstrapStageLogger.php
index 3e7738c..b0677dd 100644
--- a/prod/php/ElasticOTel/BootstrapStageLogger.php
+++ b/prod/php/ElasticOTel/BootstrapStageLogger.php
@@ -19,12 +19,16 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel;
use Throwable;
+use function elastic_otel_log_feature;
+
/**
* Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility.
*
@@ -114,7 +118,6 @@ public static function configure(int $maxEnabledLevel, string $phpSrcCodeRootDir
. '; maxEnabledLevel: ' . self::levelToString($maxEnabledLevel)
. '; phpSrcCodePathPrefixToRemove: ' . self::$phpSrcCodePathPrefixToRemove
. '; classNamePrefixToRemove: ' . self::$classNamePrefixToRemove
- . '; maxEnabledLevel: ' . self::levelToString($maxEnabledLevel)
. '; pid: ' . self::nullableToLog(self::$pid),
__FILE__,
__LINE__,
@@ -235,12 +238,7 @@ private static function logWithLevel(int $statementLevel, string $message, strin
return;
}
- /**
- * elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- \elastic_otel_log_feature( // @phpstan-ignore function.notFound
+ elastic_otel_log_feature(
0 /* $isForced */,
$statementLevel,
Log\LogFeature::BOOTSTRAP,
diff --git a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php
index e28cb43..0662d55 100644
--- a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php
+++ b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\HttpTransport;
@@ -59,12 +61,8 @@ public function __construct(
$this->endpoint = $endpoint;
$this->contentType = $contentType;
- /**
- * \Elastic\OTel\HttpTransport\* functions are provided by the extension
- *
- * @noinspection PhpUnnecessaryFullyQualifiedNameInspection, PhpUndefinedFunctionInspection
- */
- \Elastic\OTel\HttpTransport\initialize($endpoint, $contentType, $headers, $timeout, $retryDelay, $maxRetries); // @phpstan-ignore function.notFound
+ // \Elastic\OTel\HttpTransport\initialize is provided by the extension
+ initialize($endpoint, $contentType, $headers, $timeout, $retryDelay, $maxRetries);
}
public function contentType(): string
@@ -77,12 +75,8 @@ public function contentType(): string
*/
public function send(string $payload, ?CancellationInterface $cancellation = null): FutureInterface
{
- /**
- * \Elastic\OTel\HttpTransport\* functions are provided by the extension
- *
- * @noinspection PhpUnnecessaryFullyQualifiedNameInspection, PhpUndefinedFunctionInspection
- */
- \Elastic\OTel\HttpTransport\enqueue($this->endpoint, $payload); // @phpstan-ignore function.notFound
+ // \Elastic\OTel\HttpTransport\enqueue is provided by the extension
+ enqueue($this->endpoint, $payload);
return new CompletedFuture(null);
}
diff --git a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php
index 1775c8e..f186057 100644
--- a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php
+++ b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php
@@ -40,7 +40,6 @@ public function create(
?string $cert = null,
?string $key = null
): ElasticHttpTransport {
- spl_autoload_call("Elastic\OTel\PhpPartVersion");
$headers['User-Agent'] = "elastic-otlp-http-php/" . PhpPartVersion::VALUE;
return new ElasticHttpTransport($endpoint, $contentType, $headers, $compression, $timeout, $retryDelay, $maxRetries, $cacert, $cert, $key);
}
diff --git a/prod/php/ElasticOTel/InferredSpans/InferredSpans.php b/prod/php/ElasticOTel/InferredSpans/InferredSpans.php
index 620acf6..27257c6 100644
--- a/prod/php/ElasticOTel/InferredSpans/InferredSpans.php
+++ b/prod/php/ElasticOTel/InferredSpans/InferredSpans.php
@@ -19,27 +19,34 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\InferredSpans;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Behavior\LogsMessagesTrait;
+use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\TracerInterface;
use OpenTelemetry\Context\Context;
+use OpenTelemetry\Context\ContextStorageScopeInterface;
+use OpenTelemetry\SDK\Trace\Span;
use OpenTelemetry\SemConv\TraceAttributes;
use OpenTelemetry\API\Common\Time\Clock;
use OpenTelemetry\SemConv\Version;
use OpenTelemetry\Context\ContextInterface;
+use Throwable;
use WeakReference;
/**
- * @phpstan-type ExtendedStackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: '->'|'::', span: \WeakReference<\OpenTelemetry\API\Trace\SpanInterface>,
- * context: \WeakReference<\OpenTelemetry\Context\ContextInterface>, scope: \WeakReference<\OpenTelemetry\Context\ContextStorageScopeInterface>, stackTraceId: int }
+ * @phpstan-type StackTraceFrameCallType '->'|'::'
+ * @phpstan-type ExtendedStackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, span: WeakReference,
+ * context: WeakReference, scope: WeakReference, stackTraceId: int}
* @phpstan-type ExtendedStackTrace array
- * @phpstan-type DebugBackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: '->'|'::', args?: array, object?: object}
- * @phpstan-type DebugBackTrace array, DebugBackTraceFrame>
+ * @phpstan-type DebugBackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, args?: array, object?: object}
+ * @phpstan-type DebugBackTrace array
*/
class InferredSpans
{
@@ -75,7 +82,7 @@ public function __construct(private readonly bool $spanReductionEnabled, private
$this->shutdown = false;
}
- // $durationMs - duration between interrupt request and interrupt occurence
+ // $durationMs - duration between interrupt request and interrupt occurrence
public function captureStackTrace(int $durationMs, bool $topFrameIsInternalFunction): void
{
self::logDebug("captureStackTrace topFrameInternal: $topFrameIsInternalFunction, duration: $durationMs ms shutdown: " . $this->shutdown);
@@ -92,7 +99,7 @@ public function captureStackTrace(int $durationMs, bool $topFrameIsInternalFunct
$apmFramesFilteredOutCount = $this->filterOutAPMFrames($stackTrace);
$this->compareStackTraces($stackTrace, $durationMs, $topFrameIsInternalFunction, $apmFramesFilteredOutCount);
- } catch (\Throwable $throwable) {
+ } catch (Throwable $throwable) {
self::logError($throwable->__toString());
}
}
@@ -124,7 +131,7 @@ private function compareStackTraces(array $stackTrace, int $durationMs, bool $to
for ($index = 0; $index < $oldFramesCount; $index++) {
$endEpochNanos = null;
- // if last frame was internal function, so duraton contains it's time, previous ones ended between sampling interval - they're shorter
+ // if last frame was internal function, so duration contains it's time, previous ones ended between sampling interval - they're shorter
if ($topFrameIsInternalFunction) {
$endEpochNanos = $this->getStartTime($durationMs);
}
@@ -167,7 +174,8 @@ private function compareStackTraces(array $stackTrace, int $durationMs, bool $to
}
if ($index == 0 && $topFrameIsInternalFunction) {
- $this->endFrameSpan($newFrame, false, null); // we don't need to save newest internal frame, it ended
+ /** @noinspection PhpRedundantOptionalArgumentInspection */
+ $this->endFrameSpan($newFrame, false, null); // we don't need to save the newest internal frame, it ended
} else {
array_unshift($this->lastStackTrace, $newFrame); // push-copy frame in front of last stack trace for next interruption processing
}
@@ -188,7 +196,6 @@ private function filterOutAPMFrames(array &$stackTrace): ?int
// Filter out Elastic and Otel stack frames
$cutIndex = null;
for ($index = count($stackTrace) - 1; $index >= 0; $index--) {
- /** @var DebugBackTraceFrame $frame */
$frame = $stackTrace[$index];
if (
array_key_exists('class', $frame) &&
@@ -209,11 +216,13 @@ private function filterOutAPMFrames(array &$stackTrace): ?int
/** @param DebugBackTrace $stackTrace */
private function getHowManyStackFramesAreIdenticalFromStackBottom(array $stackTrace): int
{
- // Helper function to check if two frames are identical
- /** @param DebugBackTraceFrame $frame1
- * @param DebugBackTraceFrame $frame2
+ /**
+ * Helper function to check if two frames are identical
+ *
+ * @phpstan-param DebugBackTraceFrame $frame1
+ * @phpstan-param DebugBackTraceFrame $frame2
*/
- $isSameFrame = function (array $frame1, array $frame2) {
+ $isSameFrame = function (array $frame1, array $frame2): bool {
$keysToCompare = ['class', 'function', 'file', 'line', 'type'];
foreach ($keysToCompare as $key) {
if (($frame1[$key] ?? null) !== ($frame2[$key] ?? null)) {
@@ -232,7 +241,7 @@ private function getHowManyStackFramesAreIdenticalFromStackBottom(array $stackTr
$stFrame = &$stackTrace[$stackTraceCount - $index];
$lastStFrame = &$this->lastStackTrace[$lastStackTraceCount - $index];
- if ($isSameFrame($stFrame, $lastStFrame) == false) {
+ if (!$isSameFrame($stFrame, $lastStFrame)) {
return $index - 1;
}
}
@@ -256,7 +265,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo
}
$span = $this->lastStackTrace[$i][self::METADATA_SPAN]->get();
- if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) {
+ if (!$span instanceof Span) {
break;
}
@@ -265,7 +274,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo
if ($lastSpanParent) {
$span = $this->lastStackTrace[$index][self::METADATA_SPAN]->get();
- if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) {
+ if (!$span instanceof Span) {
return false;
}
@@ -274,9 +283,6 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo
['new', $lastSpanParent, 'old', $span->getParentContext()]
);
- /** @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- * @phpstan-ignore function.notFound
- */
$forceParentChangeFailed = !force_set_object_property_value($span, "parentSpanContext", $lastSpanParent);
}
}
@@ -289,7 +295,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo
}
/**
- * @param ExtendedStackTraceFrame $frame
+ * @phpstan-param ExtendedStackTraceFrame $frame
*/
private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null): bool
{
@@ -298,18 +304,11 @@ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null
}
$span = $frame[self::METADATA_SPAN]->get();
- if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) {
+ if (!$span instanceof Span) {
return false;
}
- /** @var int */
- $duration = 0;
- if ($endEpochNanos) {
- $duration = $endEpochNanos - $span->getStartEpochNanos();
- } else {
- $duration = $span->getDuration();
- }
-
+ $duration = $endEpochNanos ? ($endEpochNanos - $span->getStartEpochNanos()) : $span->getDuration();
if ($duration < $this->minSpanDuration * self::MILLIS_TO_NANOS) {
self::logDebug('Span ' . $span->getName() . ' duration ' . intval($duration / self::MILLIS_TO_NANOS)
. 'ms is too short to fit within the minimum span duration limit: ' . $this->minSpanDuration . 'ms');
@@ -321,7 +320,7 @@ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null
/**
* @param ExtendedStackTrace $stackTrace
*/
- private function getStackTrace($stackTrace): string
+ private function getStackTrace(array $stackTrace): string
{
$str = "#0 {main}\n";
$id = 1;
@@ -339,26 +338,26 @@ private function getStackTrace($stackTrace): string
}
/**
- * @param DebugBackTraceFrame $frame
- * @return ExtendedStackTraceFrame
- */
+ * @phpstan-param DebugBackTraceFrame $frame
+ *
+ * @phpstan-return ExtendedStackTraceFrame
+ */
private function startFrameSpan(array $frame, int $durationMs, ?ContextInterface $parentContext, int $stackTraceId): array
{
- $parent = $parentContext ? $parentContext : Context::getCurrent();
+ $parent = $parentContext ?? Context::getCurrent();
$builder = $this->tracer->spanBuilder(!empty($frame['function']) ? $frame['function'] : '[unknown]')
->setParent($parent)
->setStartTimestamp($this->getStartTime($durationMs))
->setSpanKind(SpanKind::KIND_INTERNAL)
- ->setAttribute(TraceAttributes::CODE_FUNCTION, $frame['function'])
+ ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $frame['function'])
->setAttribute(TraceAttributes::CODE_FILEPATH, $frame['file'] ?? null)
- ->setAttribute(TraceAttributes::CODE_LINENO, $frame['line'] ?? null)
+ ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $frame['line'] ?? null)
->setAttribute('is_inferred', true);
$span = $builder->startSpan(); //OpenTelemetry\API\Trace\SpanInterface
$context = $span->storeInContext($parent); //OpenTelemetry\Context\ContextInterface
$scope = Context::storage()->attach($context); //OpenTelemetry\Context\ContextStorageScopeInterface
- /* @var ExtendedStackTraceFrame $newFrame */
$newFrame = $frame;
$newFrame[self::METADATA_SPAN] = WeakReference::create($span);
$newFrame[self::METADATA_CONTEXT] = WeakReference::create($context);
@@ -370,12 +369,11 @@ private function startFrameSpan(array $frame, int $durationMs, ?ContextInterface
}
/**
- * @param ExtendedStackTraceFrame $frame
- */
+ * @phpstan-param ExtendedStackTraceFrame $frame
+ */
private function endFrameSpan(array $frame, bool $dropSpan, ?int $endEpochNanos = null): void
{
- /** @phpstan-ignore-next-line */
- if (!array_key_exists(self::METADATA_SPAN, $frame)) {
+ if (!array_key_exists(self::METADATA_SPAN, $frame)) { // @phpstan-ignore function.alreadyNarrowedType
self::logError("endFrameSpan missing metadata.", [$frame]);
return;
}
@@ -385,7 +383,7 @@ private function endFrameSpan(array $frame, bool $dropSpan, ?int $endEpochNanos
}
$span = $frame[self::METADATA_SPAN]->get();
- if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) {
+ if (!$span instanceof Span) {
self::logDebug("Span in frame is not instanceof Trace\Span", [$span, $frame]);
return;
}
diff --git a/prod/php/ElasticOTel/InstrumentationBridge.php b/prod/php/ElasticOTel/InstrumentationBridge.php
index 25d0a6c..d4c408c 100644
--- a/prod/php/ElasticOTel/InstrumentationBridge.php
+++ b/prod/php/ElasticOTel/InstrumentationBridge.php
@@ -24,14 +24,24 @@
namespace Elastic\OTel;
use Closure;
+use Elastic\OTel\Log\LogLevel;
use Throwable;
use Elastic\OTel\Util\SingletonInstanceTrait;
+use function elastic_otel_get_config_option_by_name;
+use function elastic_otel_hook;
+use function elastic_otel_log_feature;
+
/**
* Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility.
*
* @internal
*
+ * @phpstan-type PreHook Closure(?object $thisObj, array $params, string $class, string $function, ?string $filename, ?int $lineno): (void|array)
+ * return value is modified parameters
+ *
+ * @phpstan-type PostHook Closure(?object $thisObj, array $params, mixed $returnValue, ?Throwable $throwable): mixed
+ * return value is modified return value
*/
final class InstrumentationBridge
{
@@ -49,15 +59,19 @@ final class InstrumentationBridge
public function bootstrap(): void
{
- self::elasticOTelHook(null, 'spl_autoload_register', null, Closure::fromCallable([$this, 'retryDelayedHooks']));
+ self::elasticOTelHook(null, 'spl_autoload_register', null, $this->retryDelayedHooks(...));
require ProdPhpDir::$fullPath . DIRECTORY_SEPARATOR . 'OpenTelemetry' . DIRECTORY_SEPARATOR . 'Instrumentation' . DIRECTORY_SEPARATOR . 'hook.php';
- $this->enableDebugHooks = (bool)\elastic_otel_get_config_option_by_name('debug_php_hooks_enabled'); // @phpstan-ignore function.notFound
+ $this->enableDebugHooks = (bool)elastic_otel_get_config_option_by_name('debug_php_hooks_enabled');
BootstrapStageLogger::logDebug('Finished successfully', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
}
+ /**
+ * @phpstan-param PreHook $pre
+ * @phpstan-param PostHook $post
+ */
public function hook(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): bool
{
BootstrapStageLogger::logTrace('Entered. class: ' . $class . ' function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__);
@@ -76,6 +90,10 @@ public function hook(?string $class, string $function, ?Closure $pre = null, ?Cl
return $success;
}
+ /**
+ * @phpstan-param PreHook $pre
+ * @phpstan-param PostHook $post
+ */
private function addToDelayedHooks(string $class, string $function, ?Closure $pre = null, ?Closure $post = null): void
{
BootstrapStageLogger::logTrace('Adding to delayed hooks. class: ' . $class . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__);
@@ -83,17 +101,17 @@ private function addToDelayedHooks(string $class, string $function, ?Closure $pr
$this->delayedHooks[] = [$class, $function, $pre, $post];
}
+ /**
+ * @phpstan-param PreHook $pre
+ * @phpstan-param PostHook $post
+ */
private static function elasticOTelHook(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): void
{
$dbgClassAsString = BootstrapStageLogger::nullableToLog($class);
BootstrapStageLogger::logTrace('Entered. class: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__);
- /**
- * \elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- $retVal = \elastic_otel_hook($class, $function, $pre, $post); // @phpstan-ignore function.notFound
+ // elastic_otel_hook function is provided by the extension
+ $retVal = elastic_otel_hook($class, $function, $pre, $post);
if ($retVal) {
BootstrapStageLogger::logTrace('Successfully hooked. class: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__);
return;
@@ -102,6 +120,10 @@ private static function elasticOTelHook(?string $class, string $function, ?Closu
BootstrapStageLogger::logDebug('elastic_otel_hook returned false: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__);
}
+ /**
+ * @phpstan-param PreHook $pre
+ * @phpstan-param PostHook $post
+ */
private static function elasticOTelHookNoThrow(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): bool
{
try {
@@ -151,38 +173,33 @@ private static function placeDebugHooks(?string $class, string $function): void
}
$func .= $function . '\'';
- self::elasticOTelHookNoThrow($class, $function, function () use ($func) {
- /**
- * elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- \elastic_otel_log_feature( // @phpstan-ignore function.notFound
- 0,
- Log\Level::DEBUG,
- Log\LogFeature::INSTRUMENTATION,
- 'PRE HOOK',
- '',
- null,
- $func,
- ('pre-hook data: ' . var_export(func_get_args(), true))
- );
- }, function () use ($func) {
- /**
- * elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- \elastic_otel_log_feature( // @phpstan-ignore function.notFound
- 0,
- Log\Level::DEBUG,
- Log\LogFeature::INSTRUMENTATION,
- 'POST HOOK',
- '',
- null,
- $func,
- ('post-hook data: ' . var_export(func_get_args(), true))
- );
- });
+ self::elasticOTelHookNoThrow(
+ $class,
+ $function,
+ function () use ($func) {
+ elastic_otel_log_feature(
+ 0,
+ LogLevel::debug->value,
+ Log\LogFeature::INSTRUMENTATION,
+ 'PRE HOOK',
+ '',
+ null,
+ $func,
+ ('pre-hook data: ' . var_export(func_get_args(), true))
+ );
+ },
+ function () use ($func) {
+ elastic_otel_log_feature(
+ 0,
+ LogLevel::debug->value,
+ Log\LogFeature::INSTRUMENTATION,
+ 'POST HOOK',
+ '',
+ null,
+ $func,
+ ('post-hook data: ' . var_export(func_get_args(), true))
+ );
+ }
+ );
}
}
diff --git a/prod/php/ElasticOTel/Log/ElasticLogWriter.php b/prod/php/ElasticOTel/Log/ElasticLogWriter.php
index be2b7bb..8926dfd 100644
--- a/prod/php/ElasticOTel/Log/ElasticLogWriter.php
+++ b/prod/php/ElasticOTel/Log/ElasticLogWriter.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Log;
@@ -41,25 +43,20 @@ public function __construct()
*/
public function write(mixed $level, string $message, array $context): void
{
- $edotLevel = is_string($level) ? Level::getFromPsrLevel($level) : Level::OFF;
+ $edotLevel = is_string($level) ? (LogLevel::fromPsrLevel($level) ?? LogLevel::off) : LogLevel::off;
$caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4)[3];
$func = ($caller['class'] ?? '') . ($caller['type'] ?? '') . $caller['function'];
$logContext = $this->attachLogContext ? (' context: ' . var_export($context, true)) : '';
- /**
- * elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- \elastic_otel_log_feature( // @phpstan-ignore function.notFound
- 0 /* isForced */,
- $edotLevel,
+ elastic_otel_log_feature(
+ 0 /* <- isForced */,
+ $edotLevel->value,
LogFeature::OTEL,
- 'OpenTelemetry',
+ 'OpenTelemetry' /* <- category */,
$caller['file'] ?? '',
- $caller['line'] ?? '',
+ $caller['line'] ?? null,
$func,
$message . $logContext
);
diff --git a/prod/php/ElasticOTel/Log/Level.php b/prod/php/ElasticOTel/Log/Level.php
deleted file mode 100644
index c435473..0000000
--- a/prod/php/ElasticOTel/Log/Level.php
+++ /dev/null
@@ -1,106 +0,0 @@
- */
- private static array $nameIntPairs
- = [
- ['OFF', Level::OFF],
- ['CRITICAL', Level::CRITICAL],
- ['ERROR', Level::ERROR],
- ['WARNING', Level::WARNING],
- ['INFO', Level::INFO],
- ['DEBUG', Level::DEBUG],
- ['TRACE', Level::TRACE],
- ];
-
- /** @var ?array */
- private static ?array $intToName = null;
-
- /**
- * @return array
- *
- * @noinspection PhpUnused
- */
- public static function nameIntPairs(): array
- {
- return self::$nameIntPairs;
- }
-
- /**
- * @noinspection PhpUnused
- */
- public static function intToName(int $intValueToMap): string
- {
- if (self::$intToName === null) {
- self::$intToName = [];
- foreach (self::$nameIntPairs as $nameIntPair) {
- self::$intToName[$nameIntPair[1]] = $nameIntPair[0];
- }
- }
- return self::$intToName[$intValueToMap];
- }
-
- /**
- * @noinspection PhpUnused
- */
- public static function getHighest(): int
- {
- return self::TRACE;
- }
-
- /**
- * @noinspection PhpFullyQualifiedNameUsageInspection
- */
- public static function getFromPsrLevel(string $level): int
- {
- return match ($level) {
- \Psr\Log\LogLevel::EMERGENCY, \Psr\Log\LogLevel::ALERT, \Psr\Log\LogLevel::CRITICAL => Level::CRITICAL,
- \Psr\Log\LogLevel::ERROR => Level::ERROR,
- \Psr\Log\LogLevel::WARNING => Level::WARNING,
- \Psr\Log\LogLevel::NOTICE, \Psr\Log\LogLevel::INFO => Level::INFO,
- \Psr\Log\LogLevel::DEBUG => Level::DEBUG,
- default => Level::OFF,
- };
- }
-}
diff --git a/prod/php/ElasticOTel/Log/LogFeature.php.template b/prod/php/ElasticOTel/Log/LogFeature.php.template
index d849028..8fe8518 100644
--- a/prod/php/ElasticOTel/Log/LogFeature.php.template
+++ b/prod/php/ElasticOTel/Log/LogFeature.php.template
@@ -25,6 +25,7 @@ declare(strict_types=1);
namespace Elastic\OTel\Log;
-final class LogFeature {
+final class LogFeature
+{
@_PROJECT_PROPERTIES_LOGGER_FEATURES_ENUM_VALUES@
}
diff --git a/prod/php/ElasticOTel/Log/LogLevel.php b/prod/php/ElasticOTel/Log/LogLevel.php
new file mode 100644
index 0000000..8e4e4b3
--- /dev/null
+++ b/prod/php/ElasticOTel/Log/LogLevel.php
@@ -0,0 +1,58 @@
+ self::critical,
+ \Psr\Log\LogLevel::ERROR => self::error,
+ \Psr\Log\LogLevel::WARNING => self::warning,
+ \Psr\Log\LogLevel::NOTICE, \Psr\Log\LogLevel::INFO => self::info,
+ \Psr\Log\LogLevel::DEBUG => self::debug,
+ default => null,
+ };
+ }
+}
diff --git a/prod/php/ElasticOTel/PhpPartFacade.php b/prod/php/ElasticOTel/PhpPartFacade.php
index cccfd35..a7f80d5 100644
--- a/prod/php/ElasticOTel/PhpPartFacade.php
+++ b/prod/php/ElasticOTel/PhpPartFacade.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel;
@@ -28,6 +30,7 @@
use Elastic\OTel\Log\ElasticLogWriter;
use Elastic\OTel\Util\HiddenConstructorTrait;
use OpenTelemetry\API\Globals;
+use OpenTelemetry\SDK\Registry;
use OpenTelemetry\SDK\SdkAutoloader;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\API\Trace\SpanKind;
@@ -38,14 +41,14 @@
use RuntimeException;
use Throwable;
+use function elastic_otel_get_config_option_by_name;
+
/**
* Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility.
*
* @internal
*
* Called by the extension
- *
- * @noinspection PhpUnused, PhpMultipleClassDeclarationsInspection
*/
final class PhpPartFacade
{
@@ -54,10 +57,26 @@ final class PhpPartFacade
*/
use HiddenConstructorTrait;
+ public static bool $wasBootstrapCalled = false;
+
private static ?self $singletonInstance = null;
private static bool $rootSpanEnded = false;
private ?InferredSpans $inferredSpans = null;
+ public const CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV = 'ELASTIC_OTEL_PHP_DEV_INTERNAL_MODE_IS_DEV';
+
+ /**
+ * We need to use TELEMETRY_DISTRO_NAME and TELEMETRY_DISTRO_VERSION attribute names before OTel SDK is loaded by composer
+ * so we copy those values to local constants
+ *
+ * @see \OpenTelemetry\SemConv\TraceAttributes::TELEMETRY_DISTRO_NAME
+ * @see \OpenTelemetry\SemConv\TraceAttributes::TELEMETRY_DISTRO_VERSION
+ *
+ * @noinspection PhpUnnecessaryFullyQualifiedNameInspection
+ */
+ public const OTEL_ATTR_NAME_TELEMETRY_DISTRO_NAME = 'telemetry.distro.name';
+ public const OTEL_ATTR_NAME_TELEMETRY_DISTRO_VERSION = 'telemetry.distro.version';
+
/**
* Called by the extension
*
@@ -69,6 +88,8 @@ final class PhpPartFacade
*/
public static function bootstrap(string $elasticOTelNativePartVersion, int $maxEnabledLogLevel, float $requestInitStartTime): bool
{
+ self::$wasBootstrapCalled = true;
+
require __DIR__ . DIRECTORY_SEPARATOR . 'BootstrapStageLogger.php';
BootstrapStageLogger::configure($maxEnabledLogLevel, __DIR__, __NAMESPACE__);
@@ -117,15 +138,11 @@ public static function bootstrap(string $elasticOTelNativePartVersion, int $maxE
self::$singletonInstance = new self();
- /**
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- * @phpstan-ignore function.notFound
- */
- if (\elastic_otel_get_config_option_by_name('inferred_spans_enabled')) {
+ if (elastic_otel_get_config_option_by_name('inferred_spans_enabled')) {
self::$singletonInstance->inferredSpans = new InferredSpans(
- (bool)\elastic_otel_get_config_option_by_name('inferred_spans_reduction_enabled'), // @phpstan-ignore-line
- (bool)\elastic_otel_get_config_option_by_name('inferred_spans_stacktrace_enabled'), // @phpstan-ignore-line
- \elastic_otel_get_config_option_by_name('inferred_spans_min_duration') // @phpstan-ignore-line
+ (bool)elastic_otel_get_config_option_by_name('inferred_spans_reduction_enabled'),
+ (bool)elastic_otel_get_config_option_by_name('inferred_spans_stacktrace_enabled'),
+ elastic_otel_get_config_option_by_name('inferred_spans_min_duration') // @phpstan-ignore argument.type
);
}
} catch (Throwable $throwable) {
@@ -137,15 +154,20 @@ public static function bootstrap(string $elasticOTelNativePartVersion, int $maxE
return true;
}
+ /**
+ * Called by the extension
+ *
+ * @noinspection PhpUnused
+ */
public static function inferredSpans(int $durationMs, bool $internalFunction): bool
{
if (self::$singletonInstance === null) {
- BootstrapStageLogger::logDebug('Missig facade', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
+ BootstrapStageLogger::logDebug('Missing facade', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
return true;
}
if (self::$singletonInstance->inferredSpans === null) {
- BootstrapStageLogger::logDebug('Missig inferred spans instance', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
+ BootstrapStageLogger::logDebug('Missing inferred spans instance', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
return true;
}
self::$singletonInstance->inferredSpans->captureStackTrace($durationMs, $internalFunction);
@@ -171,7 +193,7 @@ private static function buildElasticOTelVersion(string $nativePartVersion): stri
private static function isInDevMode(): bool
{
- $modeIsDevEnvVarVal = getenv('ELASTIC_OTEL_PHP_DEV_INTERNAL_MODE_IS_DEV');
+ $modeIsDevEnvVarVal = getenv(self::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV);
if (is_string($modeIsDevEnvVarVal)) {
/**
* @var string[] $trueStringValues
@@ -194,11 +216,17 @@ private static function prepareEnvForOTelAttributes(string $elasticOTelNativePar
$envVarValue = (is_string($envVarValueOnEntry) && strlen($envVarValueOnEntry) !== 0) ? ($envVarValueOnEntry . ',') : '';
// https://opentelemetry.io/docs/specs/semconv/resource/#telemetry-distribution-experimental
- $envVarValue .= 'telemetry.distro.name=elastic,telemetry.distro.version=' . self::buildElasticOTelVersion($elasticOTelNativePartVersion);
+ $envVarValue .=
+ self::OTEL_ATTR_NAME_TELEMETRY_DISTRO_NAME . '=elastic'
+ . ','
+ . self::OTEL_ATTR_NAME_TELEMETRY_DISTRO_VERSION . '=' . self::buildElasticOTelVersion($elasticOTelNativePartVersion);
self::setEnvVar($envVarName, $envVarValue);
}
+ /**
+ * @param non-empty-string $envVarName
+ */
private static function setEnvVar(string $envVarName, string $envVarValue): void
{
if (!putenv($envVarName . '=' . $envVarValue)) {
@@ -214,7 +242,11 @@ private static function prepareEnvForOTelSdk(string $elasticOTelNativePartVersio
private static function registerAutoloader(): void
{
- $vendorDir = ProdPhpDir::$fullPath . '/vendor' . (self::isInDevMode() ? '' : '_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION);
+ $vendorDir = ProdPhpDir::$fullPath . DIRECTORY_SEPARATOR . (
+ self::isInDevMode()
+ ? ('..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'vendor')
+ : ('vendor_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION)
+ );
$vendorAutoloadPhp = $vendorDir . '/autoload.php';
if (!file_exists($vendorAutoloadPhp)) {
throw new RuntimeException("File $vendorAutoloadPhp does not exist");
@@ -227,18 +259,12 @@ private static function registerAutoloader(): void
private static function registerAsyncTransportFactory(): void
{
- /**
- * elastic_otel_* functions are provided by the extension
- *
- * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection
- */
- if (\elastic_otel_get_config_option_by_name('async_transport') === false) { // @phpstan-ignore function.notFound
+ if (elastic_otel_get_config_option_by_name('async_transport') === false) {
BootstrapStageLogger::logDebug('ELASTIC_OTEL_ASYNC_TRANSPORT set to false', __FILE__, __LINE__, __CLASS__, __FUNCTION__);
return;
}
- /** @noinspection PhpFullyQualifiedNameUsageInspection */
- \OpenTelemetry\SDK\Registry::registerTransportFactory('http', ElasticHttpTransportFactory::class, true);
+ Registry::registerTransportFactory('http', ElasticHttpTransportFactory::class, true);
}
private static function registerOtelLogWriter(): void
@@ -272,7 +298,13 @@ public static function shutdown(): void
self::$singletonInstance = null;
}
- /** @phpstan-ignore-next-line */
+ /**
+ * Called by the extension
+ *
+ * @param array $params
+ *
+ * @noinspection PhpUnused, PhpUnusedParameterInspection
+ */
public static function debugPreHook(mixed $object, array $params, ?string $class, string $function, ?string $filename, ?int $lineno): void
{
if (self::$rootSpanEnded) {
@@ -286,22 +318,28 @@ public static function debugPreHook(mixed $object, array $params, ?string $class
);
$parent = Context::getCurrent();
- /** @phpstan-ignore-next-line */
- $span = $tracer->spanBuilder($class ? $class . "::" . $function : $function)
- ->setSpanKind(SpanKind::KIND_CLIENT)
- ->setParent($parent)
- ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class)
- ->setAttribute(TraceAttributes::CODE_FUNCTION, $function)
- ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename)
- ->setAttribute(TraceAttributes::CODE_LINENO, $lineno)
- ->setAttribute('call.arguments', print_r($params, true))
- ->startSpan();
+ /** @noinspection PhpDeprecationInspection */
+ $span = $tracer->spanBuilder($class ? $class . "::" . $function : $function) // @phpstan-ignore argument.type
+ ->setSpanKind(SpanKind::KIND_CLIENT)
+ ->setParent($parent)
+ ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class)
+ ->setAttribute(TraceAttributes::CODE_FUNCTION, $function)
+ ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename)
+ ->setAttribute(TraceAttributes::CODE_LINENO, $lineno)
+ ->setAttribute('call.arguments', print_r($params, true))
+ ->startSpan();
$context = $span->storeInContext($parent);
Context::storage()->attach($context);
}
- /** @phpstan-ignore-next-line */
+ /**
+ * Called by the extension
+ *
+ * @param array $params
+ *
+ * @noinspection PhpUnused, PhpUnusedParameterInspection
+ */
public static function debugPostHook(mixed $object, array $params, mixed $retval, ?Throwable $exception): void
{
if (self::$rootSpanEnded) {
@@ -318,6 +356,7 @@ public static function debugPostHook(mixed $object, array $params, mixed $retval
$span->setAttribute('call.return_value', print_r($retval, true));
if ($exception) {
+ /** @noinspection PhpDeprecationInspection */
$span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]);
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
}
diff --git a/prod/php/ElasticOTel/Traces/ElasticRootSpan.php b/prod/php/ElasticOTel/Traces/ElasticRootSpan.php
index e94ac27..7e66e79 100644
--- a/prod/php/ElasticOTel/Traces/ElasticRootSpan.php
+++ b/prod/php/ElasticOTel/Traces/ElasticRootSpan.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Traces;
diff --git a/prod/php/ElasticOTel/Util/ArrayUtil.php b/prod/php/ElasticOTel/Util/ArrayUtil.php
index 8076479..0156c15 100644
--- a/prod/php/ElasticOTel/Util/ArrayUtil.php
+++ b/prod/php/ElasticOTel/Util/ArrayUtil.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Util;
@@ -28,17 +30,61 @@ final class ArrayUtil
use StaticClassTrait;
/**
- * @template T
- * @param array $array
- * @param-out T $valOut
+ * @template TKey of array-key
+ * @template TValue
+ *
+ * @phpstan-param TKey $key
+ * @phpstan-param array $array
+ *
+ * @param-out TValue $valueOut
+ *
+ * @phpstan-assert-if-true TValue $valueOut
+ */
+ public static function getValueIfKeyExists(int|string $key, array $array, /* out */ mixed &$valueOut): bool
+ {
+ if (!array_key_exists($key, $array)) {
+ return false;
+ }
+
+ $valueOut = $array[$key];
+ return true;
+ }
+
+ /**
+ * @template TKey of array-key
+ * @template TArrayValue
+ * @template TFallbackValue
+ *
+ * @phpstan-param TKey $key
+ * @phpstan-param array $array
+ * @phpstan-param TFallbackValue $fallbackValue
+ *
+ * @return TArrayValue|TFallbackValue
+ */
+ public static function getValueIfKeyExistsElse(string|int $key, array $array, mixed $fallbackValue): mixed
+ {
+ return array_key_exists($key, $array) ? $array[$key] : $fallbackValue;
+ }
+
+ /**
+ * @template TKey of array-key
+ * @template TValue
+ *
+ * @phpstan-param TKey $key
+ * @phpstan-param array $array
+ *
+ * @param-out TValue $valueOut
+ *
+ * @phpstan-assert-if-true TValue $valueOut
*/
- public static function getValueIfKeyExists(int|string $key, array $array, /* out */ mixed &$valOut): bool
+ public static function removeValue(int|string $key, array $array, /* out */ mixed &$valueOut): bool
{
if (!array_key_exists($key, $array)) {
return false;
}
- $valOut = $array[$key];
+ $valueOut = $array[$key];
+ unset($array[$key]);
return true;
}
}
diff --git a/prod/php/ElasticOTel/Util/EnumUtilTrait.php b/prod/php/ElasticOTel/Util/EnumUtilTrait.php
new file mode 100644
index 0000000..11f9cbe
--- /dev/null
+++ b/prod/php/ElasticOTel/Util/EnumUtilTrait.php
@@ -0,0 +1,49 @@
+ $mapByName */
+ static $mapByName = null;
+
+ if ($mapByName === null) {
+ $mapByName = [];
+ foreach (self::cases() as $enumCase) {
+ $mapByName[$enumCase->name] = $enumCase;
+ }
+ }
+
+ return ArrayUtil::getValueIfKeyExistsElse($enumName, $mapByName, fallbackValue: null);
+ }
+}
diff --git a/prod/php/ElasticOTel/Util/NumericUtil.php b/prod/php/ElasticOTel/Util/NumericUtil.php
index b0e04aa..d8e8e44 100644
--- a/prod/php/ElasticOTel/Util/NumericUtil.php
+++ b/prod/php/ElasticOTel/Util/NumericUtil.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Util;
@@ -34,62 +36,8 @@ final class NumericUtil
*
* @return bool
*/
- public static function isInClosedInterval($intervalLeft, $x, $intervalRight): bool
+ public static function isInClosedInterval(float|int $intervalLeft, float|int $x, float|int $intervalRight): bool
{
return ($intervalLeft <= $x) && ($x <= $intervalRight);
}
-
- /**
- * @param mixed $intervalLeft
- * @param mixed $x
- * @param mixed $intervalRight
- *
- * @return bool
- *
- * @template T
- * @phpstan-param T $intervalLeft
- * @phpstan-param T $x
- * @phpstan-param T $intervalRight
- *
- */
- public static function isInOpenInterval($intervalLeft, $x, $intervalRight): bool
- {
- return ($intervalLeft < $x) && ($x < $intervalRight);
- }
-
- /**
- * @param mixed $intervalLeft
- * @param mixed $x
- * @param mixed $intervalRight
- *
- * @return bool
- *
- * @template T
- * @phpstan-param T $intervalLeft
- * @phpstan-param T $x
- * @phpstan-param T $intervalRight
- *
- */
- public static function isInRightOpenInterval($intervalLeft, $x, $intervalRight): bool
- {
- return ($intervalLeft <= $x) && ($x < $intervalRight);
- }
-
- /**
- * @param mixed $intervalLeft
- * @param mixed $x
- * @param mixed $intervalRight
- *
- * @return bool
- *
- * @template T
- * @phpstan-param T $intervalLeft
- * @phpstan-param T $x
- * @phpstan-param T $intervalRight
- *
- */
- public static function isInLeftOpenInterval($intervalLeft, $x, $intervalRight): bool
- {
- return ($intervalLeft < $x) && ($x <= $intervalRight);
- }
}
diff --git a/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php b/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php
index 9f2eecc..f20e2a0 100644
--- a/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php
+++ b/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Util;
@@ -40,7 +42,7 @@ trait SingletonInstanceTrait
public static function singletonInstance(): self
{
if (self::$singletonInstance === null) {
- self::$singletonInstance = new static(); // @phpstan-ignore-line
+ self::$singletonInstance = new static();
}
return self::$singletonInstance;
}
diff --git a/prod/php/ElasticOTel/Util/TextUtil.php b/prod/php/ElasticOTel/Util/TextUtil.php
index b8edee0..56271a8 100644
--- a/prod/php/ElasticOTel/Util/TextUtil.php
+++ b/prod/php/ElasticOTel/Util/TextUtil.php
@@ -19,6 +19,8 @@
* under the License.
*/
+/** @noinspection PhpIllegalPsrClassPathInspection */
+
declare(strict_types=1);
namespace Elastic\OTel\Util;
@@ -27,6 +29,7 @@ final class TextUtil
{
use StaticClassTrait;
+ /** @noinspection PhpUnused */
public static function ensureMaxLength(string $text, int $maxLength): string
{
if (strlen($text) <= $maxLength) {
@@ -43,6 +46,7 @@ public static function isEmptyString(string $str): bool
return $str === '';
}
+ /** @noinspection PhpUnused */
public static function isNullOrEmptyString(?string $str): bool
{
return $str === null || self::isEmptyString($str);
@@ -184,7 +188,6 @@ public static function isPrefixOf(string $prefix, string $text, bool $isCaseSens
return true;
}
- /** @noinspection PhpStrictComparisonWithOperandsOfDifferentTypesInspection */
return substr_compare(
$text /* <- haystack */,
$prefix /* <- needle */,
@@ -214,9 +217,4 @@ public static function isSuffixOf(string $suffix, string $text, bool $isCaseSens
!$isCaseSensitive /* <- case_insensitivity */
) == 0;
}
-
- public static function contains(string $haystack, string $needle): bool
- {
- return strpos($haystack, $needle) !== false;
- }
}
diff --git a/prod/php/ElasticOTel/Util/WildcardListMatcher.php b/prod/php/ElasticOTel/Util/WildcardListMatcher.php
index 3ec7403..8c8e77a 100644
--- a/prod/php/ElasticOTel/Util/WildcardListMatcher.php
+++ b/prod/php/ElasticOTel/Util/WildcardListMatcher.php
@@ -32,7 +32,7 @@
final class WildcardListMatcher
{
/** @var WildcardMatcher[] */
- private $matchers;
+ private array $matchers;
/**
* @param iterable $wildcardExprs
@@ -55,15 +55,6 @@ public function match(string $text): ?string
return null;
}
- public static function matchNullable(?WildcardListMatcher $nullableMatcher, string $text): ?string
- {
- if ($nullableMatcher === null) {
- return null;
- }
-
- return $nullableMatcher->match($text);
- }
-
public function __toString(): string
{
return implode(', ', $this->matchers);
diff --git a/prod/php/ElasticOTel/Util/WildcardMatcher.php b/prod/php/ElasticOTel/Util/WildcardMatcher.php
index 0c54086..2f54bab 100644
--- a/prod/php/ElasticOTel/Util/WildcardMatcher.php
+++ b/prod/php/ElasticOTel/Util/WildcardMatcher.php
@@ -33,24 +33,15 @@ final class WildcardMatcher
private const CASE_SENSITIVE_PREFIX = '(?-i)';
private const WILDCARD = '*';
- /** @var int */
- private static $wildcardLen;
-
- /** @var bool */
- private $isCaseSensitive;
-
- /** @var bool */
- private $startsWithWildcard;
-
- /** @var bool */
- private $endsWithWildcard;
-
+ private static int $wildcardLen;
+ private bool $isCaseSensitive;
+ private bool $startsWithWildcard;
+ private bool $endsWithWildcard;
/** @var string[] */
- private $literalParts;
+ private array $literalParts;
public function __construct(string $expr)
{
- /** @phpstan-ignore-next-line */
if (!isset(self::$wildcardLen)) {
self::$wildcardLen = strlen(self::WILDCARD);
}
@@ -75,21 +66,13 @@ public function __construct(string $expr)
$lastPartWasWildcard = false;
$literalPartEndPos = ($nextWildcardPos === false) ? $exprLen : $nextWildcardPos;
$literalPartLen = $literalPartEndPos - $exprPos;
- $this->literalParts[] = substr($expr, /* offset */ $exprPos, /* length */ $literalPartLen);
+ $this->literalParts[] = substr($expr, offset: $exprPos, length: $literalPartLen);
$exprPos += $literalPartLen;
}
$this->endsWithWildcard = $lastPartWasWildcard;
}
- /**
- * @param string $haystack
- * @param string $needle
- * @param int $offset
- * @param bool $isCaseSensitive
- *
- * @return false|int
- */
- private static function findSubString(string $haystack, string $needle, int $offset, bool $isCaseSensitive)
+ private static function findSubString(string $haystack, string $needle, int $offset, bool $isCaseSensitive): false|int
{
return $isCaseSensitive ? strpos($haystack, $needle, $offset) : stripos($haystack, $needle, $offset);
}
diff --git a/prod/php/OpenTelemetry/Instrumentation/hook.php b/prod/php/OpenTelemetry/Instrumentation/hook.php
index 955402c..21eac0a 100644
--- a/prod/php/OpenTelemetry/Instrumentation/hook.php
+++ b/prod/php/OpenTelemetry/Instrumentation/hook.php
@@ -29,30 +29,28 @@
use Closure;
use Elastic\OTel\InstrumentationBridge;
+use Throwable;
/**
* Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility.
*
+ * Called by OTel instrumentations
+ *
* @internal
*
- * Called by OTel instrumentations
+ * @phpstan-param ?string $class The hooked function's class. Null for a global/built-in function.
+ * @phpstan-param string $function The hooked function's name.
+ * @phpstan-param ?(Closure(?object $thisObj, array $params, string $class, string $function, ?string $filename, ?int $lineno): (void|array)) $pre
+ * return value is modified parameters
+ * @phpstan-param ?(Closure(?object $thisObj, array $params, mixed $returnValue, ?Throwable $throwable): mixed) $post
+ * return value is modified return value
*
- * @noinspection PhpUnused, PhpInternalEntityUsedInspection
- */
-
-/**
- * @param string|null $class The hooked function's class. Null for a global/built-in function.
- * @param string $function The hooked function's name.
- * @param Closure|null $pre function($class, array $params, string $class, string $function, ?string $filename, ?int $lineno): $params
- * You may optionally return modified parameters.
- * @param Closure|null $post function($class, array $params, $returnValue, ?Throwable $exception): $returnValue
- * You may optionally return modified return value.
* @return bool Whether the observer was successfully added
*
* @see https://github.com/open-telemetry/opentelemetry-php-instrumentation
*/
function hook(
- string|null $class,
+ ?string $class,
string $function,
?Closure $pre = null,
?Closure $post = null,
diff --git a/tests/ElasticOTelTests/BootstrapTests.php b/tests/ElasticOTelTests/BootstrapTests.php
new file mode 100644
index 0000000..c4dcf33
--- /dev/null
+++ b/tests/ElasticOTelTests/BootstrapTests.php
@@ -0,0 +1,72 @@
+validateForComponentTests();
+ }
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php b/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php
new file mode 100644
index 0000000..a5ac3df
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php
@@ -0,0 +1,287 @@
+ $suffixes
+ *
+ * @return array
+ */
+ private static function genHeaders(iterable $suffixes): array
+ {
+ $result = [];
+ foreach ($suffixes as $suffix) {
+ $headerName = self::HTTP_REQUEST_HEADER_NAME_PREFIX . $suffix;
+ $result[$headerName] = 'Value_for_' . $headerName;
+ }
+ return $result;
+ }
+
+ /**
+ * @param array $headers
+ *
+ * @return list
+ */
+ private static function convertHeadersToCurlFormat(array $headers): array
+ {
+ $result = [];
+ foreach ($headers as $headerName => $headerValue) {
+ $result[] = $headerName . ': ' . $headerValue;
+ }
+ return $result;
+ }
+
+ public static function appCodeServer(): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $dbgCtx->add(['$_SERVER' => IterableUtil::toMap(GlobalUnderscoreServer::getAll())]);
+
+ $dbgCtx->add(['php_sapi_name()' => php_sapi_name()]);
+ self::assertNotEquals('cli', php_sapi_name());
+
+ self::assertSame(HttpMethods::GET, GlobalUnderscoreServer::requestMethod());
+
+ $expectedHeaders = self::genHeaders(RangeUtil::generateFromToIncluding(2, 3));
+ foreach ($expectedHeaders as $expectedHeaderName => $expectedHeaderValue) {
+ $dbgCtx->add(compact('expectedHeaderName', 'expectedHeaderValue'));
+ self::assertSame($expectedHeaderValue, GlobalUnderscoreServer::getRequestHeaderValue($expectedHeaderName));
+ }
+
+ echo self::SERVER_RESPONSE_BODY;
+ http_response_code(self::SERVER_RESPONSE_HTTP_STATUS);
+ }
+
+ public static function appCodeClient(MixedMap $appCodeArgs): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ self::assertTrue(extension_loaded('curl'));
+
+ $enableCurlInstrumentationForClient = $appCodeArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY);
+ if ($enableCurlInstrumentationForClient) {
+ self::assertTrue(class_exists(CurlInstrumentation::class, autoload: false));
+ self::assertSame(CurlInstrumentation::NAME, self::CURL_INSTRUMENTATION_NAME); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ }
+
+ $requestParams = $appCodeArgs->getObject(self::HTTP_APP_CODE_REQUEST_PARAMS_FOR_SERVER_KEY, HttpAppCodeRequestParams::class);
+ $resourcesClient = $appCodeArgs->getObject(self::RESOURCES_CLIENT_KEY, ResourcesClient::class);
+
+ $curlHandleRaw = curl_init(UrlUtil::buildFullUrl($requestParams->urlParts));
+ self::assertInstanceOf(CurlHandle::class, $curlHandleRaw);
+ $curlHandle = new CurlHandleForTests($curlHandleRaw, $resourcesClient);
+
+ self::assertTrue($curlHandle->setOpt(CURLOPT_CONNECTTIMEOUT, HttpClientUtilForTests::CONNECT_TIMEOUT_SECONDS));
+ self::assertTrue($curlHandle->setOpt(CURLOPT_TIMEOUT, HttpClientUtilForTests::TIMEOUT_SECONDS));
+
+ $dataPerRequestHeaderName = RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name);
+ $dataPerRequestHeaderValue = PhpSerializationUtil::serializeToString($requestParams->dataPerRequest);
+
+ $notFinalHeaders12 = self::genHeaders([1, 2]);
+ $notFinalHeader2Key = array_key_last($notFinalHeaders12);
+ $notFinalHeaders12[$notFinalHeader2Key] .= '_NOT_FINAL_VALUE';
+ self::assertTrue($curlHandle->setOptArray([CURLOPT_HTTPHEADER => self::convertHeadersToCurlFormat($notFinalHeaders12), CURLOPT_POST => true]));
+
+ $headers = array_merge([$dataPerRequestHeaderName => $dataPerRequestHeaderValue], self::genHeaders([2, 3]));
+ self::assertTrue($curlHandle->setOptArray([CURLOPT_HTTPHEADER => self::convertHeadersToCurlFormat($headers), CURLOPT_HTTPGET => true, CURLOPT_RETURNTRANSFER => true]));
+
+ $execRetVal = $curlHandle->exec();
+ $dbgCtx->add(compact('execRetVal'));
+ if ($execRetVal === false) {
+ self::fail(LoggableToString::convert(['error' => $curlHandle->error(), 'errno' => $curlHandle->errno(), 'verbose output' => $curlHandle->lastVerboseOutput()]));
+ }
+ $dbgCtx->add(['getInfo()' => $curlHandle->getInfo()]);
+
+ self::assertSame(self::SERVER_RESPONSE_HTTP_STATUS, $curlHandle->getResponseStatusCode());
+ self::assertSame(self::SERVER_RESPONSE_BODY, $execRetVal);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestRunAndEscalateLogLevelOnFailure(): iterable
+ {
+ return self::adaptDataProviderForTestBuilderToSmokeToDescToMixedMap(
+ (new DataProviderForTestBuilder())
+ ->addBoolKeyedDimensionAllValuesCombinable(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY)
+ ->addBoolKeyedDimensionAllValuesCombinable(self::ENABLE_CURL_INSTRUMENTATION_FOR_SERVER_KEY)
+ );
+ }
+
+ public function implTestLocalClientServer(MixedMap $testArgs): void
+ {
+ $testCaseHandle = $this->getTestCaseHandle();
+
+ $enableCurlInstrumentationForServer = $testArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_SERVER_KEY);
+ $serverAppCode = $testCaseHandle->ensureAdditionalHttpAppCodeHost(
+ dbgInstanceName: 'server for cUrl request',
+ setParamsFunc: function (AppCodeHostParams $appCodeParams) use ($enableCurlInstrumentationForServer): void {
+ if (!$enableCurlInstrumentationForServer) {
+ $appCodeParams->setProdOptionIfNotNull(OptionForProdName::disabled_instrumentations, self::CURL_INSTRUMENTATION_NAME);
+ }
+ }
+ );
+ $appCodeRequestParamsForServer = $serverAppCode->buildRequestParams(AppCodeTarget::asRouted([__CLASS__, 'appCodeServer']));
+
+ $enableCurlInstrumentationForClient = $testArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY);
+ $clientAppCode = $testCaseHandle->ensureMainAppCodeHost(
+ setParamsFunc: function (AppCodeHostParams $appCodeParams) use ($enableCurlInstrumentationForClient): void {
+ if (!$enableCurlInstrumentationForClient) {
+ $appCodeParams->setProdOptionIfNotNull(OptionForProdName::disabled_instrumentations, self::CURL_INSTRUMENTATION_NAME);
+ }
+ },
+ dbgInstanceName: 'client for cUrl request',
+ );
+ $resourcesClient = $testCaseHandle->getResourcesClient();
+
+ $clientAppCode->execAppCode(
+ AppCodeTarget::asRouted([__CLASS__, 'appCodeClient']),
+ function (AppCodeRequestParams $clientAppCodeReqParams) use ($testArgs, $appCodeRequestParamsForServer, $resourcesClient): void {
+ $clientAppCodeReqParams->setAppCodeArgs(
+ [
+ self::HTTP_APP_CODE_REQUEST_PARAMS_FOR_SERVER_KEY => $appCodeRequestParamsForServer,
+ self::RESOURCES_CLIENT_KEY => $resourcesClient,
+ ]
+ + $testArgs->cloneAsArray()
+ );
+ }
+ );
+
+ //
+ // spans: -> ->
+ // |------------------------------------------------------| |--------------------------------|
+ // client app host server app host
+
+ $curlClientSpanAttributesExpectations = new SpanAttributesExpectations(
+ [
+ self::CURL_FUNC_ATTRIBUTE_NAME => 'curl_exec',
+ TraceAttributes::HTTP_REQUEST_METHOD => HttpMethods::GET,
+ TraceAttributes::HTTP_RESPONSE_STATUS_CODE => self::SERVER_RESPONSE_HTTP_STATUS,
+ TraceAttributes::SERVER_ADDRESS => $appCodeRequestParamsForServer->urlParts->host,
+ TraceAttributes::SERVER_PORT => $appCodeRequestParamsForServer->urlParts->port,
+ TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($appCodeRequestParamsForServer->urlParts),
+ TraceAttributes::URL_SCHEME => $appCodeRequestParamsForServer->urlParts->scheme,
+ ]
+ );
+ $expectationsForCurlClientSpan = new SpanExpectations(HttpMethods::GET, SpanKind::client, $curlClientSpanAttributesExpectations);
+
+ $serverTxSpanAttributesExpectations = new SpanAttributesExpectations(
+ [
+ TraceAttributes::HTTP_REQUEST_METHOD => HttpMethods::GET,
+ TraceAttributes::HTTP_RESPONSE_STATUS_CODE => self::SERVER_RESPONSE_HTTP_STATUS,
+ TraceAttributes::SERVER_ADDRESS => $appCodeRequestParamsForServer->urlParts->host,
+ TraceAttributes::SERVER_PORT => $appCodeRequestParamsForServer->urlParts->port,
+ TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($appCodeRequestParamsForServer->urlParts),
+ TraceAttributes::URL_PATH => $appCodeRequestParamsForServer->urlParts->path,
+ TraceAttributes::URL_SCHEME => $appCodeRequestParamsForServer->urlParts->scheme,
+ ]
+ );
+ $expectedServerTxSpanName = HttpMethods::GET . ' ' . $appCodeRequestParamsForServer->urlParts->path;
+ $expectationsForServerTxSpan = new SpanExpectations($expectedServerTxSpanName, SpanKind::server, $serverTxSpanAttributesExpectations);
+
+ $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans($enableCurlInstrumentationForClient ? 3 : 2));
+
+ //
+ // Assert
+ //
+
+ if ($enableCurlInstrumentationForClient) {
+ $rootSpan = $exportedData->singleRootSpan();
+ foreach ($exportedData->spans as $span) {
+ self::assertSame($rootSpan->traceId, $span->traceId);
+ }
+ $curlClientSpan = $exportedData->singleChildSpan($rootSpan->id);
+ $expectationsForCurlClientSpan->assertMatches($curlClientSpan);
+ $serverTxSpan = $exportedData->singleChildSpan($curlClientSpan->id);
+ } else {
+ $serverTxSpan = IterableUtil::singleValue($exportedData->findSpansWithAttributeValue(TraceAttributes::SERVER_PORT, $appCodeRequestParamsForServer->urlParts->port));
+ self::assertNull($serverTxSpan->parentId);
+ $clientTxSpan = IterableUtil::singleValue(IterableUtil::findByPredicateOnValue($exportedData->spans, fn(Span $span) => $span->parentId === null && $span !== $serverTxSpan));
+ self::assertNotEquals($serverTxSpan->traceId, $clientTxSpan->traceId);
+ }
+
+ $expectationsForServerTxSpan->assertMatches($serverTxSpan);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestRunAndEscalateLogLevelOnFailure
+ */
+ public function testLocalClientServer(MixedMap $testArgs): void
+ {
+ self::runAndEscalateLogLevelOnFailure(
+ self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs),
+ function () use ($testArgs): void {
+ $this->implTestLocalClientServer($testArgs);
+ }
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php b/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php
new file mode 100644
index 0000000..00f20d4
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php
@@ -0,0 +1,214 @@
+
+ */
+ public static function dataProviderForTestFeatureWithVariousEnabledConfigCombos(): iterable
+ {
+ /**
+ * @return iterable>
+ */
+ $generateDataSets = function (): iterable {
+ foreach (BoolUtil::ALL_NULLABLE_VALUES as $transactionSpanEnabled) {
+ foreach (BoolUtil::ALL_NULLABLE_VALUES as $transactionSpanEnabledCli) {
+ $shouldAppCodeCreateDummySpanValues = self::isTransactionSpanEnabled($transactionSpanEnabled, $transactionSpanEnabledCli) ? BoolUtil::ALL_VALUES : [true];
+ foreach ($shouldAppCodeCreateDummySpanValues as $shouldAppCodeCreateDummySpan) {
+ yield [
+ OptionForProdName::transaction_span_enabled->name => $transactionSpanEnabled,
+ OptionForProdName::transaction_span_enabled_cli->name => $transactionSpanEnabledCli,
+ self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY => $shouldAppCodeCreateDummySpan,
+ ];
+ }
+ }
+ }
+ };
+
+ return self::adaptDataSetsGeneratorToSmokeToDescToMixedMap($generateDataSets);
+ }
+
+ public static function appCodeForTestFeatureWithVariousEnabledConfigCombos(MixedMap $appCodeArgs): void
+ {
+ self::appCodeSetsHowFinishedAttributes(
+ $appCodeArgs,
+ function () use ($appCodeArgs): void {
+ self::appCodeCreatesDummySpan($appCodeArgs);
+ }
+ );
+ }
+
+ public function implTestFeatureWithVariousEnabledConfigCombos(MixedMap $testArgs): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $testCaseHandle = $this->getTestCaseHandle();
+ $transactionSpanEnabled = $testArgs->getNullableBool(OptionForProdName::transaction_span_enabled->name);
+ $transactionSpanEnabledCli = $testArgs->getNullableBool(OptionForProdName::transaction_span_enabled_cli->name);
+ $isTransactionSpanEnabled = self::isTransactionSpanEnabled($transactionSpanEnabled, $transactionSpanEnabledCli);
+ $shouldAppCodeCreateDummySpan = $testArgs->getBool(self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY);
+
+ $appCodeHost = $testCaseHandle->ensureMainAppCodeHost(
+ function (AppCodeHostParams $appCodeParams) use ($transactionSpanEnabled, $transactionSpanEnabledCli): void {
+ $appCodeParams->setProdOptionIfNotNull(OptionForProdName::transaction_span_enabled, $transactionSpanEnabled);
+ $appCodeParams->setProdOptionIfNotNull(OptionForProdName::transaction_span_enabled_cli, $transactionSpanEnabledCli);
+ }
+ );
+ $appCodeHost->execAppCode(
+ AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestFeatureWithVariousEnabledConfigCombos']),
+ function (AppCodeRequestParams $appCodeRequestParams) use ($testArgs): void {
+ $appCodeRequestParams->setAppCodeArgs($testArgs);
+ }
+ );
+
+ $expectedSpanCount = 0;
+ if ($isTransactionSpanEnabled) {
+ ++$expectedSpanCount;
+ }
+ if ($shouldAppCodeCreateDummySpan) {
+ ++$expectedSpanCount;
+ }
+ self::assertGreaterThan(0, $expectedSpanCount);
+ /** @var positive-int $expectedSpanCount */
+
+ /** @noinspection PhpIfWithCommonPartsInspection */
+ if (self::isMainAppCodeHostHttp()) {
+ $expectedRootSpanKind = SpanKind::server;
+ /** @var HttpAppCodeHostHandle $appCodeHost */
+ $expectedRootSpanUrlParts = UrlUtil::buildUrlPartsWithDefaults(port: $appCodeHost->httpServerHandle->getMainPort());
+ $rootSpanAttributesExpectations = new SpanAttributesExpectations(
+ [
+ TraceAttributes::HTTP_REQUEST_METHOD => HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_METHOD,
+ TraceAttributes::SERVER_ADDRESS => $expectedRootSpanUrlParts->host,
+ TraceAttributes::SERVER_PORT => $expectedRootSpanUrlParts->port,
+ TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($expectedRootSpanUrlParts),
+ TraceAttributes::URL_PATH => $expectedRootSpanUrlParts->path,
+ TraceAttributes::URL_SCHEME => $expectedRootSpanUrlParts->scheme,
+ self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true,
+ ]
+ );
+ } else {
+ $expectedRootSpanKind = SpanKind::server;
+ $rootSpanAttributesExpectations = new SpanAttributesExpectations(
+ attributes: [
+ self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true,
+ ],
+ notAllowedAttributeNames: [
+ TraceAttributes::HTTP_REQUEST_METHOD,
+ TraceAttributes::HTTP_REQUEST_BODY_SIZE,
+ TraceAttributes::SERVER_ADDRESS,
+ TraceAttributes::URL_FULL,
+ TraceAttributes::URL_PATH,
+ TraceAttributes::URL_SCHEME,
+ TraceAttributes::USER_AGENT_ORIGINAL,
+ ]
+ );
+ }
+ $expectationsForRootSpan = new SpanExpectations(self::getExpectedTransactionSpanName(), $expectedRootSpanKind, $rootSpanAttributesExpectations);
+
+ $expectedDummySpanKind = SpanKind::internal;
+ $expectationsForDummySpan = new SpanExpectations(self::APP_CODE_DUMMY_SPAN_NAME, $expectedDummySpanKind);
+
+ $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans($expectedSpanCount));
+ $dbgCtx->add(compact('exportedData'));
+
+ $rootSpan = null;
+ $dummySpan = null;
+ if ($isTransactionSpanEnabled) {
+ $rootSpans = IterableUtil::toList($exportedData->findRootSpans());
+ self::assertCount(1, $rootSpans);
+ /** @var Span $rootSpan */
+ $rootSpan = ArrayUtilForTests::getFirstValue($rootSpans);
+ if ($shouldAppCodeCreateDummySpan) {
+ $childSpans = IterableUtil::toList($exportedData->findChildSpans($rootSpan->id));
+ self::assertCount(1, $childSpans);
+ /** @var Span $dummySpan */
+ $dummySpan = ArrayUtilForTests::getFirstValue($childSpans);
+ }
+ } else {
+ $dummySpan = $exportedData->singleSpan();
+ }
+ $dbgCtx->add(compact('rootSpan', 'dummySpan'));
+
+ // Assert
+
+ self::assertSame($isTransactionSpanEnabled, $rootSpan !== null);
+ if ($rootSpan !== null) {
+ $expectationsForRootSpan->assertMatches($rootSpan);
+ }
+
+ self::assertSame($shouldAppCodeCreateDummySpan, $dummySpan !== null);
+ if ($dummySpan !== null) {
+ $expectationsForDummySpan->assertMatches($dummySpan);
+ }
+ }
+
+
+ /**
+ * @dataProvider dataProviderForTestFeatureWithVariousEnabledConfigCombos
+ */
+ public function testFeatureWithVariousEnabledConfigCombos(MixedMap $testArgs): void
+ {
+ self::runAndEscalateLogLevelOnFailure(
+ self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs),
+ function () use ($testArgs): void {
+ $this->implTestFeatureWithVariousEnabledConfigCombos($testArgs);
+ }
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php b/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php
new file mode 100644
index 0000000..23a9491
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php
@@ -0,0 +1,36 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done');
+ }
+
+ #[Override]
+ protected function shouldTracingBeEnabled(): bool
+ {
+ return true;
+ }
+
+ #[Override]
+ protected function processConfig(): void
+ {
+ parent::processConfig();
+ AmbientContextForTests::testConfig()->validateForAppCode();
+ }
+
+ abstract protected function runImpl(): void;
+
+ public static function run(): void
+ {
+ self::runSkeleton(
+ function (SpawnedProcessBase $thisObj): void {
+ Assert::assertInstanceOf(self::class, $thisObj);
+ if (!ElasticOTelExtensionUtil::isLoaded()) {
+ throw new ComponentTestsInfraException(
+ 'Environment hosting component tests application code should have '
+ . ElasticOTelExtensionUtil::EXTENSION_NAME . ' extension loaded.'
+ . ' php_ini_loaded_file(): ' . php_ini_loaded_file() . '.'
+ );
+ }
+
+ AmbientContextForTests::testConfig()->validateForAppCodeRequest();
+
+ $thisObj->runImpl();
+ }
+ );
+ }
+
+ #[Override]
+ protected function isThisProcessTestScoped(): bool
+ {
+ return true;
+ }
+
+ protected function callAppCode(): void
+ {
+ $dataPerRequest = AmbientContextForTests::testConfig()->dataPerRequest();
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Calling application code...', compact('dataPerRequest'));
+
+ $msg = LoggableToString::convert(AmbientContextForTests::testConfig());
+ $appCodeTarget = $dataPerRequest->appCodeTarget;
+ Assert::assertNotNull($appCodeTarget, $msg);
+ Assert::assertNotNull($appCodeTarget->appCodeClass, $msg);
+ Assert::assertNotNull($appCodeTarget->appCodeMethod, $msg);
+
+ try {
+ $methodToCall = [$appCodeTarget->appCodeClass, $appCodeTarget->appCodeMethod];
+ Assert::assertIsCallable($methodToCall, $msg);
+ $appCodeArguments = $dataPerRequest->appCodeArguments;
+ if ($appCodeArguments === null) {
+ call_user_func($methodToCall);
+ } else {
+ call_user_func($methodToCall, new MixedMap($appCodeArguments));
+ }
+ } catch (Throwable $throwable) {
+ $loggerProxyDebug && $loggerProxyDebug->logThrowable(__LINE__, $throwable, 'Call to application code exited by exception');
+ throw new WrappedAppCodeException($throwable);
+ }
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Call to application code completed');
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php
new file mode 100644
index 0000000..45b8010
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php
@@ -0,0 +1,65 @@
+getSystemClockCurrentTime();
+ return new AppCodeInvocation($appCodeRequestParams, $timestampBefore);
+ }
+
+ protected function afterAppCodeInvocation(AppCodeInvocation $appCodeInvocation): void
+ {
+ $appCodeInvocation->after();
+ $this->testCaseHandle->addAppCodeInvocation($appCodeInvocation);
+ }
+
+ /**
+ * @return string[]
+ */
+ protected static function propertiesExcludedFromLog(): array
+ {
+ return ['testCaseHandle'];
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php
new file mode 100644
index 0000000..8e7dafa
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php
@@ -0,0 +1,53 @@
+ false,
+ self::builtinHttpServer => true,
+ };
+ }
+
+ #[Override]
+ public function toLog(LogStreamInterface $stream): void
+ {
+ $stream->toLogAs($this->value);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php
new file mode 100644
index 0000000..7c5f96e
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php
@@ -0,0 +1,248 @@
+
+ */
+class AppCodeHostParams implements LoggableInterface
+{
+ use LoggableTrait;
+
+ /** @var OptionsForProdMap */
+ private Map $prodOptions;
+
+ public string $spawnedProcessInternalId;
+
+ public function __construct(
+ public readonly string $dbgProcessName
+ ) {
+ $this->prodOptions = new Map();
+ }
+
+ /**
+ * @param OptionForProdName $optName
+ * @param OptionForProdValue $optVal
+ */
+ public function setProdOption(OptionForProdName $optName, string|int|float|bool $optVal): void
+ {
+ $this->prodOptions[$optName] = $optVal;
+ }
+
+ /**
+ * @param OptionForProdName $optName
+ * @param ?OptionForProdValue $optVal
+ */
+ public function setProdOptionIfNotNull(OptionForProdName $optName, null|string|int|float|bool $optVal): void
+ {
+ if ($optVal !== null) {
+ $this->setProdOption($optName, $optVal);
+ }
+ }
+
+ /**
+ * @param OptionsForProdMap $prodOptions
+ *
+ * @return bool
+ */
+ private static function areAnyProdLogLevelRelatedOptionsSet(Map $prodOptions): bool
+ {
+ return !IterableUtil::isEmpty(IterableUtil::findByPredicateOnValue(IterableUtil::keys($prodOptions), fn($optName) => $optName->isLogLevelRelated()));
+ }
+
+ private static function isProdEnvVarLogRelated(string $envVarName): bool
+ {
+ foreach (OptionForProdName::cases() as $optName) {
+ if ($optName->isLogRelated() && $optName->toEnvVarName() === $envVarName) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @phpstan-param EnvVars $inputEnvVars
+ *
+ * @return EnvVars
+ */
+ private static function removeProdLogLevelRelatedEnvVars(array $inputEnvVars): array
+ {
+ $outputEnvVars = $inputEnvVars;
+ foreach (OptionForProdName::getAllLogLevelRelated() as $optName) {
+ $envVarName = $optName->toEnvVarName();
+ if (array_key_exists($envVarName, $outputEnvVars)) {
+ unset($outputEnvVars[$envVarName]);
+ }
+ }
+
+ return $outputEnvVars;
+ }
+
+ /**
+ * @phpstan-param EnvVars $baseEnvVars
+ * @phpstan-param OptionsForProdMap $prodOptions
+ *
+ * @return EnvVars
+ */
+ private static function filterBaseEnvVars(array $baseEnvVars, Map $prodOptions): array
+ {
+ $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ if ($loggerProxyDebug !== null) {
+ ksort(/* ref */ $baseEnvVars);
+ $loggerProxyDebug->log(__LINE__, 'Entered', compact('baseEnvVars'));
+ }
+
+ $areAnyProdLogLevelRelatedOptionsSet = self::areAnyProdLogLevelRelatedOptionsSet($prodOptions);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Before handling log related options', compact('areAnyProdLogLevelRelatedOptionsSet'));
+ $envVars = $baseEnvVars;
+ if ($areAnyProdLogLevelRelatedOptionsSet) {
+ $envVars = self::removeProdLogLevelRelatedEnvVars($envVars);
+ }
+ if ($loggerProxyDebug !== null) {
+ ksort(/* ref */ $envVars);
+ $loggerProxyDebug->log(__LINE__, 'After handling log related options', compact('envVars'));
+ }
+
+ $result = array_filter(
+ $envVars,
+ function (string $envVarName): bool {
+ // Return false for entries to be removed
+
+ // Keep environment variables related to testing infrastructure
+ if (TextUtil::isPrefixOfIgnoreCase(OptionForTestsName::ENV_VAR_NAME_PREFIX, $envVarName)) {
+ return true;
+ }
+
+ // Keep environment variable 'is dev mode'
+ if ($envVarName === PhpPartFacade::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV) {
+ return true;
+ }
+
+ // Keep environment variables related to production code logging
+ if (self::isProdEnvVarLogRelated($envVarName)) {
+ return true;
+ }
+
+ // Keep environment variables explicitly configured to be passed through
+ if (AmbientContextForTests::testConfig()->isEnvVarToPassThrough($envVarName)) {
+ return true;
+ }
+
+ // Drop any other environment variables related to either vanilla or Elastic OTel
+ foreach (OptionForProdName::getEnvVarNamePrefixes() as $envVarPrefix) {
+ if (TextUtil::isPrefixOfIgnoreCase($envVarPrefix, $envVarName)) {
+ return false;
+ }
+ }
+
+ // Keep the rest
+ return true;
+ },
+ ARRAY_FILTER_USE_KEY
+ );
+
+ if ($loggerProxyDebug !== null) {
+ ksort(/* ref */ $result);
+ $loggerProxyDebug->log(__LINE__, 'Exiting', compact('result'));
+ }
+ return $result;
+ }
+
+ /**
+ * @phpstan-param EnvVars $inheritedEnvVars
+ * @phpstan-param OptionsForProdMap $prodOptions
+ *
+ * @return EnvVars
+ */
+ public static function buildEnvVarsForAppCodeProcessImpl(array $inheritedEnvVars, Map $prodOptions): array
+ {
+ $result = self::filterBaseEnvVars($inheritedEnvVars, $prodOptions);
+
+ foreach ($prodOptions as $optName => $optVal) {
+ $result[$optName->toEnvVarName()] = ConfigUtilForTests::optionValueToString($optVal);
+ }
+
+ $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('', compact('result'));
+ return $result;
+ }
+
+ /**
+ * @return EnvVars
+ */
+ public function buildEnvVarsForAppCodeProcess(): array
+ {
+ return self::buildEnvVarsForAppCodeProcessImpl(EnvVarUtilForTests::getAll(), $this->prodOptions);
+ }
+
+ public function buildProdConfig(): ConfigSnapshotForProd
+ {
+ $envVarsToInheritSource = new MockConfigRawSnapshotSource();
+ $envVars = $this->buildEnvVarsForAppCodeProcess();
+ $allOptsMeta = OptionsForProdMetadata::get();
+ foreach (IterableUtil::keys($allOptsMeta) as $optName) {
+ $envVarName = OptionForProdName::findByName($optName)->toEnvVarName();
+ if (array_key_exists($envVarName, $envVars)) {
+ $envVarsToInheritSource->set($optName, $envVars[$envVarName]);
+ }
+ }
+
+ $explicitlySetOptionsSource = new MockConfigRawSnapshotSource();
+ foreach ($this->prodOptions as $optName => $optVal) {
+ $explicitlySetOptionsSource->set($optName->name, ConfigUtilForTests::optionValueToString($optVal));
+ }
+ $rawSnapshotSource = new CompositeRawSnapshotSource([$explicitlySetOptionsSource, $envVarsToInheritSource]);
+ $rawSnapshot = $rawSnapshotSource->currentSnapshot($allOptsMeta);
+
+ // Set log level above ERROR to hide potential errors when parsing the provided test configuration snapshot
+ $logBackend = AmbientContextForTests::loggerFactory()->getBackend()->clone();
+ $logBackend->setMaxEnabledLevel(LogLevel::critical);
+ $loggerFactory = new LoggerFactory($logBackend);
+ $parser = new ConfigParser($loggerFactory);
+ return new ConfigSnapshotForProd($parser->parse($allOptsMeta, $rawSnapshot));
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php
new file mode 100644
index 0000000..314614e
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php
@@ -0,0 +1,45 @@
+timestampAfter = AmbientContextForTests::clock()->getSystemClockCurrentTime();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php
new file mode 100644
index 0000000..3a5f455
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php
@@ -0,0 +1,48 @@
+dataPerRequest = new TestInfraDataPerRequest(spawnedProcessInternalId: $spawnedProcessInternalId, appCodeTarget: $appCodeTarget);
+ }
+
+ /**
+ * @param MixedMap|array $appCodeArgs
+ */
+ public function setAppCodeArgs(MixedMap|array $appCodeArgs): void
+ {
+ $this->dataPerRequest->appCodeArguments = $appCodeArgs instanceof MixedMap ? $appCodeArgs->cloneAsArray() : $appCodeArgs;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php
new file mode 100644
index 0000000..f37d560
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php
@@ -0,0 +1,47 @@
+appCodeClass = $appCodeClassMethod[0];
+ $thisObj->appCodeMethod = $appCodeClassMethod[1];
+ return $thisObj;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php
new file mode 100644
index 0000000..9ef6818
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php
@@ -0,0 +1,110 @@
+|(ArrayReadInterface & Countable)
+ */
+class ArrayExpectations implements ExpectationsInterface
+{
+ use ExpectationsTrait;
+
+ /**
+ * @param array $expectedArray
+ */
+ public function __construct(
+ private readonly array $expectedArray,
+ private readonly bool $allowOtherKeysInActual = true,
+ ) {
+ }
+
+ #[Override]
+ public function assertMatchesMixed(mixed $actual): void
+ {
+ Assert::assertTrue(is_array($actual) || $actual instanceof ArrayReadInterface);
+ /** @var ArrayLike $actual */
+ $this->assertMatches($actual);
+ }
+
+ /**
+ * @param ArrayLike $actual
+ */
+ public function assertMatches(array|ArrayReadInterface $actual): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ if (!$this->allowOtherKeysInActual) {
+ Assert::assertCount(count($this->expectedArray), $actual);
+ }
+ foreach ($this->expectedArray as $expectedKey => $expectedValue) {
+ $dbgCtx->add(compact('expectedKey', 'expectedValue'));
+ self::keyExists($expectedKey, $this->expectedArray);
+ $actualValue = self::getValue($expectedKey, $actual);
+ $dbgCtx->add(compact('actualValue'));
+ $this->assertValueMatches($expectedKey, $expectedValue, $actualValue);
+ }
+ }
+
+ /**
+ * @phpstan-param TKey $key
+ * @phpstan-param ArrayLike $array
+ */
+ private static function keyExists(string|int $key, array|ArrayReadInterface $array): bool
+ {
+ return is_array($array) ? array_key_exists($key, $array) : $array->keyExists($key);
+ }
+
+ /**
+ * @phpstan-param TKey $key
+ * @phpstan-param ArrayLike $array
+ *
+ * @return TValue
+ */
+ private static function getValue(string|int $key, array|ArrayReadInterface $array): mixed
+ {
+ if (is_array($array)) {
+ Assert::assertArrayHasKey($key, $array);
+ return $array[$key];
+ }
+ return $array->getValue($key);
+ }
+
+ /**
+ * @phpstan-param TKey $key
+ * @phpstan-param TValue $expectedValue
+ * @phpstan-param TValue $actualValue
+ */
+ protected function assertValueMatches(string|int $key, mixed $expectedValue, mixed $actualValue): void
+ {
+ Assert::assertSame($expectedValue, $actualValue);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php
new file mode 100644
index 0000000..ef7a2c9
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php
@@ -0,0 +1,99 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Received request', ['URI' => GlobalUnderscoreServer::requestUri(), 'method' => GlobalUnderscoreServer::requestMethod()]);
+ }
+
+ protected static function isStatusCheck(): bool
+ {
+ return GlobalUnderscoreServer::requestUri() === HttpServerHandle::STATUS_CHECK_URI_PATH;
+ }
+
+ #[Override]
+ protected function shouldRegisterThisProcessWithResourcesCleaner(): bool
+ {
+ // We should register with ResourcesCleaner only on the status-check request
+ return self::isStatusCheck();
+ }
+
+ #[Override]
+ protected function processConfig(): void
+ {
+ Assert::assertCount(1, AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts);
+
+ parent::processConfig();
+
+ AmbientContextForTests::reconfigure(new RequestHeadersRawSnapshotSource(fn(string $headerName) => GlobalUnderscoreServer::getRequestHeaderValue($headerName)));
+ }
+
+ #[Override]
+ protected function runImpl(): void
+ {
+ $dataPerRequest = AmbientContextForTests::testConfig()->dataPerRequest();
+ if (($response = self::verifySpawnedProcessInternalId($dataPerRequest->spawnedProcessInternalId)) !== null) {
+ self::sendResponse($response);
+ return;
+ }
+ if (self::isStatusCheck()) {
+ self::sendResponse(self::buildResponseWithPid());
+ return;
+ }
+
+ $this->callAppCode();
+ }
+
+ private static function sendResponse(ResponseInterface $response): void
+ {
+ $localLogger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $httpResponseStatusCode = $response->getStatusCode();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sending response ...', compact('httpResponseStatusCode', 'response'));
+
+ http_response_code($httpResponseStatusCode);
+ echo $response->getBody();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php
new file mode 100644
index 0000000..3a2c96f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php
@@ -0,0 +1,46 @@
+spawnedProcessInternalId = $httpServerHandle->spawnedProcessInternalId;
+
+ parent::__construct($testCaseHandle, $appCodeHostParams, $httpServerHandle);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php
new file mode 100644
index 0000000..dd76830
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php
@@ -0,0 +1,79 @@
+dbgProcessName);
+ }
+
+ /**
+ * @param int[] $portsInUse
+ */
+ public static function startBuiltinHttpServerAppCodeHost(HttpAppCodeHostParams $appCodeHostParams, ResourcesCleanerHandle $resourcesCleaner, array $portsInUse): HttpServerHandle
+ {
+ return (new self($appCodeHostParams, $resourcesCleaner))->startHttpServer($portsInUse);
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function buildCommandLine(array $ports): string
+ {
+ Assert::assertCount(1, $ports);
+ $routerScriptNameFullPath = FileUtil::listToPath([__DIR__, self::APP_CODE_HOST_ROUTER_SCRIPT]);
+ if (!file_exists($routerScriptNameFullPath)) {
+ throw new ConfigException(ExceptionUtil::buildMessage('Router script does not exist', compact('routerScriptNameFullPath')));
+ }
+
+ return InfraUtilForTests::buildAppCodePhpCmd()
+ . ' -S ' . HttpServerHandle::SERVER_LOCALHOST_ADDRESS . ':' . $ports[0]
+ . ' "' . $routerScriptNameFullPath . '"';
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array
+ {
+ Assert::assertCount(1, $ports);
+ return InfraUtilForTests::addTestInfraDataPerProcessToEnvVars(
+ $this->appCodeHostParams->buildEnvVarsForAppCodeProcess(),
+ $spawnedProcessInternalId,
+ $ports,
+ $this->resourcesCleaner,
+ $this->appCodeHostParams->dbgProcessName
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php
new file mode 100644
index 0000000..309d740
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php
@@ -0,0 +1,37 @@
+callAppCode();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php
new file mode 100644
index 0000000..af07e32
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php
@@ -0,0 +1,104 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $dbgProcessName = ClassNameUtil::fqToShort(CliScriptAppCodeHost::class) . '(' . $dbgInstanceName . ')';
+ $appCodeHostParams = new AppCodeHostParams($dbgProcessName);
+ $appCodeHostParams->spawnedProcessInternalId = InfraUtilForTests::generateSpawnedProcessInternalId();
+ $setParamsFunc($appCodeHostParams);
+
+ parent::__construct($testCaseHandle, $appCodeHostParams);
+
+ $this->logger->addAllContext(compact('this'));
+ }
+
+ public static function getRunScriptNameFullPath(): string
+ {
+ return FileUtil::listToPath([__DIR__, CliScriptAppCodeHost::SCRIPT_TO_RUN_APP_CODE_HOST]);
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function execAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void
+ {
+ $localLogger = $this->logger->inherit()->addAllContext(compact('appCodeTarget'));
+ $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $requestParams = new AppCodeRequestParams($this->appCodeHostParams->spawnedProcessInternalId, $appCodeTarget);
+ if ($setParamsFunc !== null) {
+ $setParamsFunc($requestParams);
+ }
+ $localLogger->addAllContext(compact('requestParams'));
+
+ $runScriptNameFullPath = self::getRunScriptNameFullPath();
+ if (!file_exists($runScriptNameFullPath)) {
+ throw new ConfigException(ExceptionUtil::buildMessage('Run script does not exist', compact('runScriptNameFullPath')));
+ }
+
+ $cmdLine = InfraUtilForTests::buildAppCodePhpCmd() . ' "' . $runScriptNameFullPath . '"';
+
+ $envVars = InfraUtilForTests::addTestInfraDataPerProcessToEnvVars(
+ $this->appCodeHostParams->buildEnvVarsForAppCodeProcess(),
+ $this->appCodeHostParams->spawnedProcessInternalId,
+ [] /* <- targetServerPorts */,
+ $this->resourcesCleaner,
+ $this->appCodeHostParams->dbgProcessName
+ );
+ $envVars[OptionForTestsName::data_per_request->toEnvVarName()] = PhpSerializationUtil::serializeToString($requestParams->dataPerRequest);
+ ksort(/* ref */ $envVars);
+ $localLogger->addAllContext(compact('envVars'));
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Executing app code ...');
+
+ $appCodeInvocation = $this->beforeAppCodeInvocation($requestParams);
+ ProcessUtil::startProcessAndWaitUntilExit($cmdLine, $envVars);
+ $this->afterAppCodeInvocation($appCodeInvocation);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Executed app code');
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php
new file mode 100644
index 0000000..a77a4a3
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php
@@ -0,0 +1,485 @@
+testCaseHandle !== null) {
+ return $this->testCaseHandle;
+ }
+ $this->testCaseHandle = new TestCaseHandle($escalatedLogLevelForProdCode);
+ return $this->testCaseHandle;
+ }
+
+ protected function getTestCaseHandle(): TestCaseHandle
+ {
+ return $this->initTestCaseHandle();
+ }
+
+ #[Override]
+ public function tearDown(): void
+ {
+ if ($this->testCaseHandle !== null) {
+ $this->testCaseHandle->tearDown();
+ $this->testCaseHandle = null;
+ }
+
+ parent::tearDown();
+ }
+
+ public static function appCodeEmpty(): void
+ {
+ }
+
+ /**
+ * @param callable(): void $appCodeImpl
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ public static function appCodeSetsHowFinishedAttributes(MixedMap $appCodeArgs, callable $appCodeImpl): void
+ {
+ $shouldSetAttributes = $appCodeArgs->isBoolIsNotSetOrSetToTrue(self::SHOULD_APP_CODE_SET_HOW_FINISHED_ATTRIBUTES_KEY);
+
+ $logger = self::getLoggerStatic(__NAMESPACE__, __CLASS__, __FILE__);
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $logger->addAllContext(compact('shouldSetAttributes', 'appCodeArgs'));
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Calling $appCodeImpl() ...');
+ try {
+ $appCodeImpl();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Call to $appCodeImpl() finished successfully');
+ if ($shouldSetAttributes) {
+ OTelUtil::setActiveSpanAttributes([self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true]);
+ }
+ } catch (Throwable $throwable) {
+ $loggerProxyDebug && $loggerProxyDebug->logThrowable(__LINE__, $throwable, 'Call to $appCodeImpl() thrown');
+ if ($shouldSetAttributes) {
+ OTelUtil::setActiveSpanAttributes([self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => false, self::THROWABLE_FROM_APP_CODE_KEY => LoggableToString::convert($throwable)]);
+ }
+ throw $throwable;
+ }
+ }
+
+ public static function appCodeCreatesDummySpan(MixedMap $appCodeArgs): void
+ {
+ if ($appCodeArgs->isBoolIsNotSetOrSetToTrue(self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY)) {
+ OTelUtil::startEndSpan(OTelUtil::getTracer(), self::APP_CODE_DUMMY_SPAN_NAME);
+ }
+ }
+
+ protected static function buildResourcesClientForAppCode(): ResourcesClient
+ {
+ $resCleanerId = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId;
+ Assert::assertNotNull($resCleanerId);
+ $resCleanerPort = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort;
+ Assert::assertNotNull($resCleanerPort);
+ return new ResourcesClient($resCleanerId, $resCleanerPort);
+ }
+
+ public static function isSmoke(): bool
+ {
+ return AmbientContextForTests::testConfig()->isSmoke();
+ }
+
+ public static function isMainAppCodeHostHttp(): bool
+ {
+ return AmbientContextForTests::testConfig()->appCodeHostKind()->isHttp();
+ }
+
+ protected function skipIfMainAppCodeHostIsNotCliScript(): bool
+ {
+ if (self::isMainAppCodeHostHttp()) {
+ self::dummyAssert();
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function skipIfMainAppCodeHostIsNotHttp(): bool
+ {
+ if (!self::isMainAppCodeHostHttp()) {
+ self::dummyAssert();
+ return true;
+ }
+
+ return false;
+ }
+
+ protected static function waitForOneSpan(TestCaseHandle $testCaseHandle): Span
+ {
+ $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans(1));
+ return $exportedData->singleSpan();
+ }
+
+ /**
+ * @template T
+ *
+ * @param iterable $variants
+ *
+ * @return iterable
+ */
+ public static function adaptToSmoke(iterable $variants): iterable
+ {
+ if (!self::isSmoke()) {
+ return $variants;
+ }
+ foreach ($variants as $key => $value) {
+ return [$key => $value];
+ }
+ return [];
+ }
+
+ /**
+ * @template TKey of array-key
+ * @template TValue
+ *
+ * @param iterable $variants
+ *
+ * @return iterable
+ */
+ public function adaptKeyValueToSmoke(iterable $variants): iterable
+ {
+ if (!self::isSmoke()) {
+ return $variants;
+ }
+ foreach ($variants as $key => $value) {
+ return [$key => $value];
+ }
+ return [];
+ }
+
+ /**
+ * @return callable(iterable): iterable
+ */
+ public static function adaptToSmokeAsCallable(): callable
+ {
+ /**
+ * @template T
+ *
+ * @param iterable $dataSets
+ *
+ * @return iterable
+ */
+ return function (iterable $dataSets): iterable {
+ return self::adaptToSmoke($dataSets);
+ };
+ }
+
+ /**
+ * @param callable(): iterable> $dataSetsGenerator
+ *
+ * @return iterable
+ */
+ public static function adaptDataSetsGeneratorToSmokeToDescToMixedMap(callable $dataSetsGenerator): iterable
+ {
+ return DataProviderForTestBuilder::convertEachDataSetToMixedMapAndAddDesc(fn() => self::adaptToSmoke($dataSetsGenerator()));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function adaptDataProviderForTestBuilderToSmokeToDescToMixedMap(DataProviderForTestBuilder $dataProviderForTestBuilder): iterable
+ {
+ return self::adaptDataSetsGeneratorToSmokeToDescToMixedMap(fn() => $dataProviderForTestBuilder->buildWithoutDataSetName()); // @phpstan-ignore argument.type
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderOneBoolArgAdaptedToSmoke(): iterable
+ {
+ return self::adaptToSmoke(self::dataProviderOneBoolArg());
+ }
+
+ /**
+ * @param callable(): void $testCall
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ protected function runAndEscalateLogLevelOnFailure(string $dbgTestDesc, callable $testCall): void
+ {
+ $logLevelForTestCodeToRestore = AmbientContextForTests::testConfig()->logLevel;
+ try {
+ $this->runAndEscalateLogLevelOnFailureImpl($dbgTestDesc, $testCall);
+ } finally {
+ AmbientContextForTests::resetLogLevel($logLevelForTestCodeToRestore);
+ }
+ }
+
+ /**
+ * @param callable(): void $testCall
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ private function runAndEscalateLogLevelOnFailureImpl(string $dbgTestDesc, callable $testCall): void
+ {
+ try {
+ $testCall();
+ return;
+ } catch (Throwable $ex) {
+ $initiallyFailedTestException = $ex;
+ }
+
+ $logger = self::getLoggerStatic(__NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('dbgTestDesc'));
+ $loggerProxyOutsideIt = $logger->ifCriticalLevelEnabledNoLine(__FUNCTION__);
+ if ($this->testCaseHandle === null) {
+ $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'Test failed but $this->testCaseHandle is null - NOT re-running the test with escalated log levels');
+ throw $initiallyFailedTestException;
+ }
+ $initiallyFailedTestLogLevels = $this->getCurrentLogLevels($this->testCaseHandle);
+ $logger->addAllContext(compact('initiallyFailedTestLogLevels', 'initiallyFailedTestException'));
+ $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'Test failed');
+
+ $escalatedLogLevelsSeq = self::generateLevelsForRunAndEscalateLogLevelOnFailure($initiallyFailedTestLogLevels, AmbientContextForTests::testConfig()->escalatedRerunsMaxCount);
+ $rerunCount = 0;
+ foreach ($escalatedLogLevelsSeq as $escalatedLogLevels) {
+ $this->tearDown();
+
+ ++$rerunCount;
+ $loggerPerIt = $logger->inherit()->addAllContext(compact('rerunCount', 'escalatedLogLevels'));
+ $loggerProxyPerIt = $loggerPerIt->ifCriticalLevelEnabledNoLine(__FUNCTION__);
+
+ $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-running failed test with escalated log levels...');
+
+ AmbientContextForTests::resetLogLevel($escalatedLogLevels[self::LOG_LEVEL_FOR_TEST_CODE_KEY]);
+ $this->initTestCaseHandle($escalatedLogLevels[self::LOG_LEVEL_FOR_PROD_CODE_KEY]);
+
+ try {
+ $testCall();
+ $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-run of failed test with escalated log levels did NOT fail (which is bad :(');
+ } catch (Throwable $ex) {
+ $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-run of failed test with escalated log levels failed (which is good :)', compact('ex'));
+ throw $ex;
+ }
+ }
+
+ if ($rerunCount === 0) {
+ $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'There were no test re-runs with escalated log levels - re-throwing original test failure exception');
+ } else {
+ $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'All test re-runs with escalated log levels did NOT fail (which is bad :( - re-throwing original test failure exception');
+ }
+ throw $initiallyFailedTestException;
+ }
+
+ /**
+ * @param class-string $testClass
+ */
+ protected static function buildDbgDescForTest(string $testClass, string $testFunc): string
+ {
+ return ClassNameUtil::fqToShort($testClass) . '::' . $testFunc;
+ }
+
+ /**
+ * @param class-string $testClass
+ */
+ protected static function buildDbgDescForTestWithArgs(string $testClass, string $testFunc, MixedMap $testArgs): string
+ {
+ return ClassNameUtil::fqToShort($testClass) . '::' . $testFunc . '(' . LoggableToString::convert($testArgs) . ')';
+ }
+
+ /**
+ * @param TestCaseHandle $testCaseHandle
+ *
+ * @return array
+ */
+ private function getCurrentLogLevels(TestCaseHandle $testCaseHandle): array
+ {
+ /** @var array $result */
+ $result = [];
+ $prodCodeLogLevels = $testCaseHandle->getProdCodeLogLevels();
+ Assert::assertNotEmpty($prodCodeLogLevels);
+ $result[self::LOG_LEVEL_FOR_PROD_CODE_KEY] = self::findLogLevelByValue(min(array_map(fn(LogLevel $logLevel) => $logLevel->value, $prodCodeLogLevels)));
+ $result[self::LOG_LEVEL_FOR_TEST_CODE_KEY] = AmbientContextForTests::testConfig()->logLevel;
+ return $result;
+ }
+
+ public static function findLogLevelByValue(int $logLevelValue): LogLevel
+ {
+ $logLevel = LogLevel::tryFrom($logLevelValue);
+ Assert::assertNotNull($logLevel);
+ return $logLevel;
+ }
+
+ /**
+ * @param array $initialLevels
+ *
+ * @return iterable>
+ */
+ public static function generateEscalatedLogLevels(array $initialLevels): iterable
+ {
+ Assert::assertNotEmpty($initialLevels);
+
+ /**
+ * @param array $currentLevels
+ */
+ $haveCurrentLevelsReachedInitial = function (array $currentLevels) use ($initialLevels): bool {
+ foreach ($initialLevels as $levelTypeKey => $initialLevel) {
+ /** @var LogLevel $initialLevel */
+ /** @var array $currentLevels */
+ if ($initialLevel->value < $currentLevels[$levelTypeKey]->value) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ /** @var int $minInitialLevel */
+ $minInitialLevel = min(array_map(fn(LogLevel $logLevel) => $logLevel->value, $initialLevels));
+ $maxDelta = 0;
+ foreach ($initialLevels as $initialLevel) {
+ $maxDelta = max($maxDelta, LogLevelUtil::getHighest()->value - $initialLevel->value);
+ }
+ foreach (RangeUtil::generateDown(LogLevelUtil::getHighest()->value, $minInitialLevel) as $baseLevelAsInt) {
+ Assert::assertGreaterThan(LogLevel::off->value, $baseLevelAsInt);
+ /** @var array $currentLevels */
+ $currentLevels = [];
+ foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) {
+ $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt);
+ }
+ yield $currentLevels;
+
+ foreach (RangeUtil::generate(1, $maxDelta + 1) as $delta) {
+ foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) {
+ if ($baseLevelAsInt < $initialLevels[$levelTypeKey]->value + $delta) {
+ continue;
+ }
+ $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt - $delta);
+ if (!$haveCurrentLevelsReachedInitial($currentLevels)) {
+ yield $currentLevels;
+ }
+ $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param array $initialLevels
+ *
+ * @return iterable>
+ */
+ protected static function generateLevelsForRunAndEscalateLogLevelOnFailure(array $initialLevels, int $eachLevelsSetMaxCount): iterable
+ {
+ $result = self::generateEscalatedLogLevels($initialLevels);
+ $result = IterableUtil::concat($result, [$initialLevels]);
+ /** @noinspection PhpUnnecessaryLocalVariableInspection */
+ $result = IterableUtil::duplicateEachElement($result, $eachLevelsSetMaxCount);
+ return $result;
+ }
+
+ /**
+ * @template TKey of array-key
+ * @template TValue
+ *
+ * @param array $array
+ * @param TKey $key
+ *
+ * @return TValue
+ */
+ public static function &assertAndGetFromArrayByKey(array $array, $key)
+ {
+ Assert::assertArrayHasKey($key, $array);
+ return $array[$key];
+ }
+
+ protected static function getTracerFromAppCode(): TracerInterface
+ {
+ return Globals::tracerProvider()->getTracer('co.elastic.edot.php.ComponentTests', null, Version::VERSION_1_25_0->url());
+ }
+
+ protected static function buildProdConfigFromAppCode(): ConfigSnapshotForProd
+ {
+ /** @var ?array $envVarPrefixToOptNames */
+ static $envVarPrefixToOptNames = null;
+
+ $allOptsMeta = OptionsForProdMetadata::get();
+
+ if ($envVarPrefixToOptNames === null) {
+ $envVarPrefixToOptNames = [];
+ foreach (OptionForProdName::cases() as $optName) {
+ $envVarNamePrefix = $optName->getEnvVarNamePrefix();
+ if (!array_key_exists($envVarNamePrefix, $envVarPrefixToOptNames)) {
+ $envVarPrefixToOptNames[$envVarNamePrefix] = [];
+ }
+ $envVarPrefixToOptNames[$envVarNamePrefix][] = $optName->name;
+ }
+ }
+
+ $envVarsSnapSources = [];
+ foreach ($envVarPrefixToOptNames as $currentEnvVarsPrefix => $optNames) {
+ $envVarsSnapSources[] = new EnvVarsRawSnapshotSource($currentEnvVarsPrefix, $optNames);
+ }
+ $rawSnapshotSource = new CompositeRawSnapshotSource($envVarsSnapSources);
+
+ $parser = new ConfigParser(AmbientContextForTests::loggerFactory());
+ $rawSnapshot = $rawSnapshotSource->currentSnapshot($allOptsMeta);
+ return new ConfigSnapshotForProd($parser->parse($allOptsMeta, $rawSnapshot));
+ }
+
+ protected static function getExpectedTransactionSpanName(): string
+ {
+ return self::isMainAppCodeHostHttp()
+ ? HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_METHOD . ' ' . HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_URL_PATH
+ : CliScriptAppCodeHostHandle::getRunScriptNameFullPath();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php
new file mode 100644
index 0000000..a59cd9d
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php
@@ -0,0 +1,30 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $this->logger->addContext('appCodeHostKind', AmbientContextForTests::testConfig()->appCodeHostKind());
+
+ try {
+ // We spin off test infrastructure servers here and not on demand
+ // in self::getGlobalTestInfra() because PHPUnit might fork to run individual tests
+ // and ResourcesCleaner would track the PHPUnit child process as its master which would be wrong
+ self::$globalTestInfra = new GlobalTestInfra();
+ } catch (Throwable $throwable) {
+ ($loggerProxy = $this->logger->ifCriticalLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->logThrowable($throwable, 'Throwable escaped from GlobalTestInfra constructor');
+ throw $throwable;
+ }
+ }
+
+ public function __destruct()
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Destroying...');
+
+ self::$globalTestInfra?->getResourcesCleaner()->signalAndWaitForItToExit();
+ }
+
+ public static function getGlobalTestInfra(): GlobalTestInfra
+ {
+ Assert::assertNotNull(self::$globalTestInfra);
+ return self::$globalTestInfra;
+ }
+
+ #[Override]
+ protected function logLevelForEnvInfo(): LogLevel
+ {
+ return LogLevel::info;
+ }
+
+ #[Override]
+ protected function logLevelForBeforeTestCaseIsRun(): LogLevel
+ {
+ return LogLevel::info;
+ }
+
+ #[Override]
+ protected function logLevelForAfterTestCasePassed(): LogLevel
+ {
+ return LogLevel::info;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php
new file mode 100644
index 0000000..20dfcc6
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php
@@ -0,0 +1,91 @@
+parse($allOptsMeta, $configSource->currentSnapshot($allOptsMeta));
+ return new ConfigSnapshotForTests($optNameToParsedValue);
+ }
+
+ public static function verifyTracingIsDisabled(): void
+ {
+ if (!ElasticOTelExtensionUtil::isLoaded()) {
+ return;
+ }
+
+ $envVarName = OptionForProdName::enabled->toEnvVarName();
+ $envVarValue = EnvVarUtilForTests::get($envVarName);
+ if ($envVarValue !== 'false') {
+ throw new TestsInfraException(
+ 'Environment variable ' . $envVarName . ' should be set to `false\'.'
+ . ' Instead it is ' . ($envVarValue === null ? 'not set' : 'set to `' . $envVarValue . '\'')
+ );
+ }
+
+ $msgPrefix = 'Component tests auxiliary processes should not be recorded';
+ // elastic_otel_is_enabled is provided by the extension
+ if (function_exists('elastic_otel_is_enabled') && elastic_otel_is_enabled()) {
+ throw new ComponentTestsInfraException($msgPrefix . '; elastic_otel_is_enabled() returned true');
+ }
+ if (PhpPartFacade::$wasBootstrapCalled) {
+ throw new ComponentTestsInfraException($msgPrefix . '; PhpPartFacade::$wasBootstrapCalled is true');
+ }
+ }
+
+ public static function optionValueToString(string|int|float|bool $optVal): string
+ {
+ if (is_string($optVal)) {
+ return $optVal;
+ }
+
+ if (is_bool($optVal)) {
+ return BoolUtil::toString($optVal);
+ }
+
+ return strval($optVal);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php
new file mode 100644
index 0000000..cdd16d5
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php
@@ -0,0 +1,134 @@
+curlHandle = $curlHandle;
+ }
+
+ public function setOpt(int $option, mixed $value): bool
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_setopt($this->curlHandle, $option, $value);
+ }
+
+ /**
+ * @param array $options
+ */
+ public function setOptArray(array $options): bool
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_setopt_array($this->curlHandle, $options);
+ }
+
+ public function exec(): string|bool
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ Assert::assertNotNull($this->curlHandle);
+
+ $verboseOutputFilePath = $this->resourcesClient->createTempFile('curl verbose output');
+ $dbgCtx->add(compact('verboseOutputFilePath'));
+ /** @var null|resource|false $verboseOutputFile */
+ $verboseOutputFile = null;
+ $isAfterCurlExec = false;
+ try {
+ $verboseOutputFile = fopen($verboseOutputFilePath, 'w'); // open file for write
+ Assert::assertIsResource($verboseOutputFile, 'Failed to open temp file for curl verbose output; ' . LoggableToString::convert(compact('verboseOutputFilePath')));
+ Assert::assertTrue($this->setOpt(CURLOPT_VERBOSE, true));
+ Assert::assertTrue($this->setOpt(CURLOPT_STDERR, $verboseOutputFile));
+ $retVal = curl_exec($this->curlHandle);
+ $isAfterCurlExec = true;
+ } finally {
+ if (is_resource($verboseOutputFile)) {
+ Assert::assertTrue(fflush($verboseOutputFile));
+ Assert::assertTrue(fclose($verboseOutputFile));
+ if ($isAfterCurlExec) {
+ $verboseOutput = file_get_contents($verboseOutputFilePath);
+ Assert::assertIsString($verboseOutput);
+ $this->lastVerboseOutput = $verboseOutput;
+ }
+ Assert::assertTrue(unlink($verboseOutputFilePath));
+ $verboseOutputFile = null;
+ }
+ }
+
+ return $retVal;
+ }
+
+ public function error(): string
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_error($this->curlHandle);
+ }
+
+ public function errno(): int
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_errno($this->curlHandle);
+ }
+
+ public function lastVerboseOutput(): ?string
+ {
+ return $this->lastVerboseOutput;
+ }
+
+ /**
+ * @return array
+ */
+ public function getInfo(): array
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_getinfo($this->curlHandle);
+ }
+
+ public function getResponseStatusCode(): mixed
+ {
+ Assert::assertNotNull($this->curlHandle);
+ return curl_getinfo($this->curlHandle, CURLINFO_RESPONSE_CODE);
+ }
+
+ public function close(): void
+ {
+ Assert::assertNotNull($this->curlHandle);
+ curl_close($this->curlHandle);
+ $this->curlHandle = null;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php
new file mode 100644
index 0000000..d1de872
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php
@@ -0,0 +1,74 @@
+assertObjectMatchesTraitImpl($this, $actual);
+ }
+
+ protected static function assertMatchesValue(mixed $expected, mixed $actual): void
+ {
+ if ($expected === null) {
+ return;
+ }
+
+ if (is_object($expected)) {
+ self::assertObjectMatches($expected, $actual);
+ return;
+ }
+
+ Assert::assertSame($expected, $actual);
+ }
+
+ protected static function assertObjectMatches(object $expected, mixed $actual): void
+ {
+ if ($expected instanceof ExpectationsInterface) {
+ $expected->assertMatchesMixed($actual);
+ return;
+ }
+
+ static::assertObjectMatchesTraitImpl($expected, $actual);
+ }
+
+ protected static function assertObjectMatchesTraitImpl(object $expected, mixed $actual): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ Assert::assertIsObject($actual);
+
+ /** @var string $propName */
+ foreach ($expected as $propName => $expectationsPropValue) { // @phpstan-ignore foreach.nonIterable
+ $dbgCtx->add(compact('propName', 'expectationsPropValue'));
+ Assert::assertTrue(property_exists($actual, $propName));
+ static::assertMatchesValue($expectationsPropValue, $actual->$propName);
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php
new file mode 100644
index 0000000..f3e196e
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php
@@ -0,0 +1,37 @@
+addIntakeApiRequest($event);
+ } elseif ($event instanceof AgentToOTelCollectorConnectionStarted) {
+ $this->addNewConnection($event);
+ }
+ }
+ }
+
+ private function addNewConnection(AgentToOTelCollectorConnectionStarted $event): void
+ {
+ if ($this->openIntakeApiConnection === null) {
+ Assert::assertCount(0, $this->openIntakeApiConnectionRequests);
+ } else {
+ $this->closedIntakeApiConnections[] = new IntakeApiConnection($this->openIntakeApiConnection, $this->openIntakeApiConnectionRequests);
+ $this->openIntakeApiConnectionRequests = [];
+ }
+
+ $this->openIntakeApiConnection = $event;
+ }
+
+ private function addIntakeApiRequest(IntakeApiRequest $intakeApiRequest): void
+ {
+ $this->openIntakeApiConnectionRequests[] = $intakeApiRequest;
+
+ $newDataParsed = IntakeApiRequestDeserializer::deserialize($intakeApiRequest);
+ ArrayUtilForTests::append(from: $newDataParsed->spans, to: $this->spans);
+ }
+
+ public function isEnough(IsEnoughExportedDataInterface $isEnoughExportedData): bool
+ {
+ return $isEnoughExportedData->isEnough($this->spans);
+ }
+
+ public function getAccumulatedData(): ExportedData
+ {
+ $intakeApiConnections = $this->closedIntakeApiConnections;
+ if ($this->openIntakeApiConnection !== null) {
+ $intakeApiConnections[] = new IntakeApiConnection($this->openIntakeApiConnection, $this->openIntakeApiConnectionRequests);
+ }
+ return new ExportedData(new RawExportedData($intakeApiConnections), $this->spans);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php b/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php
new file mode 100644
index 0000000..298cbf4
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php
@@ -0,0 +1,115 @@
+resourcesCleaner = $this->startResourcesCleaner();
+ $this->mockOTelCollector = $this->startMockOTelCollector($this->resourcesCleaner);
+ }
+
+ public function onTestStart(): void
+ {
+ $this->cleanTestScoped();
+ }
+
+ public function onTestEnd(): void
+ {
+ $this->cleanTestScoped();
+ }
+
+ private function cleanTestScoped(): void
+ {
+ $this->mockOTelCollector->cleanTestScoped();
+ $this->resourcesCleaner->cleanTestScoped();
+ }
+
+ public function getResourcesCleaner(): ResourcesCleanerHandle
+ {
+ return $this->resourcesCleaner;
+ }
+
+ public function getMockOTelCollector(): MockOTelCollectorHandle
+ {
+ return $this->mockOTelCollector;
+ }
+
+ /**
+ * @return int[]
+ */
+ public function getPortsInUse(): array
+ {
+ return $this->portsInUse;
+ }
+
+ /**
+ * @param int[] $ports
+ *
+ * @return void
+ */
+ private function addPortsInUse(array $ports): void
+ {
+ foreach ($ports as $port) {
+ Assert::assertNotContains($port, $this->portsInUse);
+ $this->portsInUse[] = $port;
+ }
+ }
+
+ private function startResourcesCleaner(): ResourcesCleanerHandle
+ {
+ $httpServerHandle = TestInfraHttpServerStarter::startTestInfraHttpServer(
+ dbgServerDesc: ClassNameUtil::fqToShort(ResourcesCleaner::class),
+ runScriptName: 'runResourcesCleaner.php',
+ portsInUse: $this->portsInUse,
+ portsToAllocateCount: 1,
+ resourcesCleaner: null,
+ );
+ $this->addPortsInUse($httpServerHandle->ports);
+ return new ResourcesCleanerHandle($httpServerHandle);
+ }
+
+ private function startMockOTelCollector(ResourcesCleanerHandle $resourcesCleaner): MockOTelCollectorHandle
+ {
+ $httpServerHandle = TestInfraHttpServerStarter::startTestInfraHttpServer(
+ dbgServerDesc: ClassNameUtil::fqToShort(MockOTelCollector::class),
+ runScriptName: 'runMockOTelCollector.php',
+ portsInUse: $this->portsInUse,
+ portsToAllocateCount: 2,
+ resourcesCleaner: $resourcesCleaner,
+ );
+ $this->addPortsInUse($httpServerHandle->ports);
+ return new MockOTelCollectorHandle($httpServerHandle);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php
new file mode 100644
index 0000000..30ff9b4
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php
@@ -0,0 +1,73 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function execAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Sending HTTP request to app code ...', compact('appCodeTarget'));
+ $this->sendHttpRequestToAppCode($appCodeTarget, $setParamsFunc);
+ }
+
+ /**
+ * @param null|Closure(HttpAppCodeRequestParams): void $setParamsFunc
+ */
+ private function sendHttpRequestToAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void
+ {
+ $requestParams = $this->buildRequestParams($appCodeTarget);
+ if ($setParamsFunc !== null) {
+ $setParamsFunc($requestParams);
+ }
+
+ $appCodeInvocation = $this->beforeAppCodeInvocation($requestParams);
+ HttpClientUtilForTests::sendRequestToAppCode($requestParams);
+ $this->afterAppCodeInvocation($appCodeInvocation);
+ }
+
+ public function buildRequestParams(AppCodeTarget $appCodeTarget): HttpAppCodeRequestParams
+ {
+ return new HttpAppCodeRequestParams($this->httpServerHandle, $appCodeTarget);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php
new file mode 100644
index 0000000..8bf877c
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php
@@ -0,0 +1,28 @@
+spawnedProcessInternalId, $appCodeTarget);
+
+ $this->urlParts = new UrlParts(scheme: HttpSchemes::HTTP, host: HttpServerHandle::CLIENT_LOCALHOST_ADDRESS, port: $httpServerHandle->getMainPort(), path: self::DEFAULT_HTTP_REQUEST_URL_PATH);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php
new file mode 100644
index 0000000..3617e3b
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php
@@ -0,0 +1,159 @@
+loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ }
+
+ return self::$logger;
+ }
+
+ public static function sendRequestToAppCode(HttpAppCodeRequestParams $requestParams): ResponseInterface
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $localLogger = self::getLogger()->inherit()->addAllContext(compact('requestParams'));
+ $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sending HTTP request to app code...');
+
+ $response = HttpClientUtilForTests::sendRequest($requestParams->httpRequestMethod, $requestParams->urlParts, $requestParams->dataPerRequest);
+ $actualResponseStatusCode = $response->getStatusCode();
+ $localLogger->addAllContext(compact('actualResponseStatusCode'));
+ $dbgCtx->add(compact('actualResponseStatusCode'));
+
+ if ($requestParams->expectedHttpResponseStatusCode !== null) {
+ TestCase::assertSame($requestParams->expectedHttpResponseStatusCode, $actualResponseStatusCode);
+ }
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Successfully sent HTTP request to app code');
+ return $response;
+ }
+
+ /**
+ * @param array $headers
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ public static function sendRequest(string $httpMethod, UrlParts $urlParts, TestInfraDataPerRequest $dataPerRequest, array $headers = []): ResponseInterface
+ {
+ $localLogger = self::getLogger()->inherit()->addAllContext(compact('httpMethod', 'urlParts', 'dataPerRequest', 'headers'));
+ ($loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__));
+
+ $baseUrl = UrlUtil::buildRequestBaseUrl($urlParts);
+ $urlRelPart = UrlUtil::buildRequestMethodArg($urlParts);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, "Sending HTTP request...", compact('baseUrl', 'urlRelPart'));
+
+ $client = new Client(['base_uri' => $baseUrl]);
+ $response = $client->request(
+ $httpMethod,
+ $urlRelPart,
+ [
+ RequestOptions::HEADERS =>
+ $headers
+ + [RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name) => PhpSerializationUtil::serializeToString($dataPerRequest)],
+ /*
+ * http://docs.guzzlephp.org/en/stable/request-options.html#http-errors
+ *
+ * http_errors
+ *
+ * Set to false to disable throwing exceptions on an HTTP protocol errors (i.e., 4xx and 5xx responses).
+ * Exceptions are thrown by default when HTTP protocol errors are encountered.
+ */
+ RequestOptions::HTTP_ERRORS => false,
+ /*
+ * https://docs.guzzlephp.org/en/stable/request-options.html#connect-timeout
+ *
+ * connect-timeout
+ *
+ * Float describing the number of seconds to wait while trying to connect to a server.
+ * Use 0 to wait indefinitely (the default behavior).
+ */
+ RequestOptions::CONNECT_TIMEOUT => self::CONNECT_TIMEOUT_SECONDS,
+ /*
+ * https://docs.guzzlephp.org/en/stable/request-options.html#timeout
+ *
+ * timeout
+ *
+ * Float describing the total timeout of the request in seconds.
+ * Use 0 to wait indefinitely (the default behavior).
+ */
+ RequestOptions::TIMEOUT => self::TIMEOUT_SECONDS,
+ ]
+ );
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sent HTTP request', ['response status code' => $response->getStatusCode()]);
+ return $response;
+ }
+
+ /** @noinspection PhpUnused */
+ public static function createCurlHandleToSendRequestToAppCode(UrlParts $urlParts, TestInfraDataPerRequest $dataPerRequest, ResourcesClient $resourcesClient): CurlHandleForTests
+ {
+ $curlInitRetVal = curl_init(UrlUtil::buildFullUrl($urlParts));
+ Assert::assertInstanceOf(CurlHandle::class, $curlInitRetVal);
+ $curlHandle = new CurlHandleForTests($curlInitRetVal, $resourcesClient);
+ $dataPerRequestHeaderName = RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name);
+ $dataPerRequestHeaderVal = PhpSerializationUtil::serializeToString($dataPerRequest);
+ $curlHandle->setOpt(CURLOPT_HTTPHEADER, [$dataPerRequestHeaderName . ': ' . $dataPerRequestHeaderVal]);
+ return $curlHandle;
+ }
+
+ /**
+ * @param array> $headers
+ */
+ public static function getSingleHeaderValue(string $headerName, array $headers): string
+ {
+ Assert::assertTrue(ArrayUtil::getValueIfKeyExists($headerName, $headers, /* out */ $values));
+ return ArrayUtilForTests::getSingleValue($values);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php
new file mode 100644
index 0000000..f538d5f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php
@@ -0,0 +1,84 @@
+ports);
+ return $this->ports[0];
+ }
+
+ /**
+ * @param array $headers
+ */
+ public function sendRequest(string $httpMethod, string $path, array $headers = []): ResponseInterface
+ {
+ return HttpClientUtilForTests::sendRequest(
+ $httpMethod,
+ new UrlParts(port: $this->getMainPort(), path: $path),
+ new TestInfraDataPerRequest(spawnedProcessInternalId: $this->spawnedProcessInternalId),
+ $headers
+ );
+ }
+
+ public function signalAndWaitForItToExit(): void
+ {
+ $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::EXIT_URI_PATH);
+ Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode());
+
+ $hasExited = ProcessUtil::waitForProcessToExit(
+ $this->dbgServerDesc,
+ $this->spawnedProcessOsId,
+ 10 * 1000 * 1000 /* <- maxWaitTimeInMicroseconds - 10 seconds */
+ );
+ Assert::assertTrue($hasExited);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php
new file mode 100644
index 0000000..d8a5e1f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php
@@ -0,0 +1,72 @@
+dataPerProcess()->thisSpawnedProcessInternalId;
+ if ($expectedSpawnedProcessInternalId !== $receivedSpawnedProcessInternalId) {
+ return self::buildErrorResponse(
+ HttpStatusCodes::BAD_REQUEST,
+ 'Received server ID does not match the expected one.'
+ . ' Expected: ' . $expectedSpawnedProcessInternalId
+ . ', received: ' . $receivedSpawnedProcessInternalId
+ );
+ }
+
+ return null;
+ }
+
+ protected static function buildErrorResponse(int $status, string $message): ResponseInterface
+ {
+ return new Response(
+ $status,
+ // headers:
+ [
+ 'Content-Type' => 'application/json',
+ ],
+ // body:
+ JsonUtil::encode(['message' => $message], /* prettyPrint: */ true)
+ );
+ }
+
+ protected static function buildDefaultResponse(): ResponseInterface
+ {
+ return new Response();
+ }
+
+ protected static function buildResponseWithPid(): ResponseInterface
+ {
+ return Response::json([HttpServerHandle::PID_KEY => getmypid()]);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php
new file mode 100644
index 0000000..b4559bd
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php
@@ -0,0 +1,243 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ /**
+ * @param int[] $ports
+ */
+ abstract protected function buildCommandLine(array $ports): string;
+
+ /**
+ * @param int[] $ports
+ *
+ * @return EnvVars
+ */
+ abstract protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array;
+
+ /**
+ * @param int[] $portsInUse
+ * @param int $portsToAllocateCount
+ *
+ * @return HttpServerHandle
+ */
+ protected function startHttpServer(array $portsInUse, int $portsToAllocateCount = 1): HttpServerHandle
+ {
+ Assert::assertGreaterThanOrEqual(1, $portsToAllocateCount);
+ /** @var ?int $lastTriedPort */
+ $lastTriedPort = ArrayUtilForTests::isEmpty($portsInUse) ? null : ArrayUtilForTests::getLastValue($portsInUse);
+ for ($tryCount = 0; $tryCount < self::MAX_TRIES_TO_START_SERVER; ++$tryCount) {
+ /** @var int[] $currentTryPorts */
+ $currentTryPorts = [];
+ self::findFreePortsToListen($portsInUse, $portsToAllocateCount, $lastTriedPort, /* out */ $currentTryPorts);
+ Assert::assertSame($portsToAllocateCount, count($currentTryPorts));
+ /**
+ * We repeat $currentTryPorts type to fix PHPStan's
+ * "Unable to resolve the template type T in call to method static method" error
+ *
+ * @var int[] $currentTryPorts
+ * @noinspection PhpRedundantVariableDocTypeInspection
+ */
+ $lastTriedPort = ArrayUtilForTests::getLastValue($currentTryPorts);
+ $currentTrySpawnedProcessInternalId = InfraUtilForTests::generateSpawnedProcessInternalId();
+ $cmdLine = $this->buildCommandLine($currentTryPorts);
+ $envVars = $this->buildEnvVarsForSpawnedProcess($currentTrySpawnedProcessInternalId, $currentTryPorts);
+
+ $logger = $this->logger->inherit()->addAllContext(
+ array_merge(
+ ['dbgServerDesc' => $this->dbgServerDesc, 'maxTries' => self::MAX_TRIES_TO_START_SERVER],
+ compact('tryCount', 'currentTryPorts', 'currentTrySpawnedProcessInternalId', 'cmdLine', 'envVars')
+ )
+ );
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting HTTP server...');
+ ProcessUtil::startBackgroundProcess($cmdLine, $envVars);
+
+ $pid = -1;
+ if ($this->isHttpServerRunning($currentTrySpawnedProcessInternalId, $currentTryPorts[0], $logger, /* ref */ $pid)) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Started HTTP server', compact('pid'));
+ return new HttpServerHandle($this->dbgServerDesc, $pid, $currentTrySpawnedProcessInternalId, $currentTryPorts);
+ }
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Failed to start HTTP server');
+ }
+
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Failed to start HTTP server', ['dbgServerDesc' => $this->dbgServerDesc]));
+ }
+
+ /**
+ * @param int[] $portsInUse
+ * @param ?int $lastTriedPort
+ * @param int $portsToFindCount
+ * @param int[] &$result
+ *
+ * @return void
+ */
+ private static function findFreePortsToListen(
+ array $portsInUse,
+ int $portsToFindCount,
+ ?int $lastTriedPort,
+ array &$result
+ ): void {
+ $result = [];
+ $lastTriedPortLocal = $lastTriedPort;
+ foreach (RangeUtil::generateUpTo($portsToFindCount) as $ignored) {
+ $foundPort = self::findFreePortToListen($portsInUse, $lastTriedPortLocal);
+ $result[] = $foundPort;
+ $lastTriedPortLocal = $foundPort;
+ }
+ }
+
+ /**
+ * @param int[] $portsInUse
+ * @param ?int $lastTriedPort
+ *
+ * @return int
+ */
+ private static function findFreePortToListen(array $portsInUse, ?int $lastTriedPort): int
+ {
+ $calcNextInCircularPortRange = function (int $port): int {
+ return $port === (self::PORTS_RANGE_END - 1) ? self::PORTS_RANGE_BEGIN : ($port + 1);
+ };
+
+ $portToStartSearchFrom = $lastTriedPort === null
+ ? RandomUtil::generateIntInRange(self::PORTS_RANGE_BEGIN, self::PORTS_RANGE_END - 1)
+ : $calcNextInCircularPortRange($lastTriedPort);
+ $candidate = $portToStartSearchFrom;
+ while (true) {
+ if (!in_array($candidate, $portsInUse)) {
+ break;
+ }
+ $candidate = $calcNextInCircularPortRange($candidate);
+ if ($candidate === $portToStartSearchFrom) {
+ TestCase::fail(
+ 'Could not find a free port'
+ . LoggableToString::convert(
+ [
+ 'portsInUse' => $portsInUse,
+ 'portToStartSearchFrom' => $portToStartSearchFrom,
+ ]
+ )
+ );
+ }
+ }
+ return $candidate;
+ }
+
+ private function isHttpServerRunning(string $spawnedProcessInternalId, int $port, Logger $logger, int &$pid): bool
+ {
+ /** @var ?Throwable $lastThrown */
+ $lastThrown = null;
+ $dataPerRequest = new TestInfraDataPerRequest(spawnedProcessInternalId: $spawnedProcessInternalId);
+ $checkResult = (new PollingCheck(
+ $this->dbgServerDesc . ' started',
+ self::MAX_WAIT_SERVER_START_MICROSECONDS
+ ))->run(
+ function () use ($port, $dataPerRequest, $logger, &$lastThrown, &$pid) {
+ try {
+ $response = HttpClientUtilForTests::sendRequest(
+ HttpMethods::GET,
+ new UrlParts(host: HttpServerHandle::CLIENT_LOCALHOST_ADDRESS, port: $port, path: HttpServerHandle::STATUS_CHECK_URI_PATH),
+ $dataPerRequest
+ );
+ } catch (Throwable $throwable) {
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->logThrowable($throwable, 'Caught while checking if HTTP server is running');
+ $lastThrown = $throwable;
+ return false;
+ }
+
+ if ($response->getStatusCode() !== HttpStatusCodes::OK) {
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Received non-OK status code in response to status check',
+ ['receivedStatusCode' => $response->getStatusCode()]
+ );
+ return false;
+ }
+
+ /** @var array $decodedBody */
+ $decodedBody = JsonUtil::decode($response->getBody()->getContents(), asAssocArray: true);
+ TestCase::assertArrayHasKey(HttpServerHandle::PID_KEY, $decodedBody);
+ $receivedPid = $decodedBody[HttpServerHandle::PID_KEY];
+ TestCase::assertIsInt($receivedPid, LoggableToString::convert(['$decodedBody' => $decodedBody]));
+ $pid = $receivedPid;
+
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('HTTP server status is OK', ['PID' => $pid]);
+ return true;
+ }
+ );
+
+ if (!$checkResult) {
+ if ($lastThrown === null) {
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Failed to send request to check HTTP server status');
+ } else {
+ ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->logThrowable($lastThrown, 'Failed to send request to check HTTP server status');
+ }
+ }
+
+ return $checkResult;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php b/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php
new file mode 100644
index 0000000..f3d13cc
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php
@@ -0,0 +1,65 @@
+ $binaryId
+ */
+ public static function convertBinaryIdToString(array $binaryId): string
+ {
+ $result = '';
+ for ($i = 0; $i < count($binaryId); ++$i) {
+ $result .= sprintf('%02x', $binaryId[$i]);
+ }
+ return $result;
+ }
+
+ /**
+ * @return array
+ */
+ private static function generateBinaryId(int $idLengthInBytes): array
+ {
+ $result = [];
+ for ($i = 0; $i < $idLengthInBytes; ++$i) {
+ $result[] = mt_rand(0, 255);
+ }
+ return $result;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php
new file mode 100644
index 0000000..869c9f5
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php
@@ -0,0 +1,51 @@
+spawnedProcessInternalId,
+ resourcesCleanerPort: $resourcesCleaner?->getMainPort(),
+ thisSpawnedProcessInternalId: $targetSpawnedProcessInternalId,
+ thisServerPorts: $targetServerPorts
+ );
+ }
+
+ /**
+ * @phpstan-param EnvVars $baseEnvVars
+ * @phpstan-param int[] $targetServerPorts
+ *
+ * @return EnvVars
+ */
+ public static function addTestInfraDataPerProcessToEnvVars(
+ array $baseEnvVars,
+ string $targetSpawnedProcessInternalId,
+ array $targetServerPorts,
+ ?ResourcesCleanerHandle $resourcesCleaner,
+ string $dbgProcessName
+ ): array {
+ $dataPerProcessEnvVarName = OptionForTestsName::data_per_process->toEnvVarName();
+ $dataPerProcess = self::buildTestInfraDataPerProcess($targetSpawnedProcessInternalId, $targetServerPorts, $resourcesCleaner);
+ $result = $baseEnvVars;
+ $additionalEnvVars = [
+ SpawnedProcessBase::DBG_PROCESS_NAME_ENV_VAR_NAME => $dbgProcessName,
+ $dataPerProcessEnvVarName => PhpSerializationUtil::serializeToString($dataPerProcess),
+ ];
+ ArrayUtilForTests::append(from: $additionalEnvVars, to: $result);
+ ksort(/* ref */ $result);
+ return $result;
+ }
+
+ public static function buildAppCodePhpCmd(): string
+ {
+ $result = AmbientContextForTests::testConfig()->appCodePhpExe ?? 'php';
+
+ if (($extBinary = AmbientContextForTests::testConfig()->appCodeExtBinary) !== null) {
+ $result .= ' -d "extension=' . $extBinary . '"';
+ }
+
+ if (($bootstrapPhpPartFile = AmbientContextForTests::testConfig()->appCodeBootstrapPhpPartFile) !== null) {
+ $bootstrapPhpPartFileIniOptName = IniRawSnapshotSource::DEFAULT_PREFIX . OptionForProdName::bootstrap_php_part_file->name;
+ $result .= ' -d "' . $bootstrapPhpPartFileIniOptName . '=' . $bootstrapPhpPartFile . '"';
+ }
+
+ return $result;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php
new file mode 100644
index 0000000..882d662
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php
@@ -0,0 +1,36 @@
+ $headers
+ */
+ public function __construct(
+ MonotonicTime $monotonicTime,
+ SystemTime $systemTime,
+ public readonly array $headers,
+ public readonly string $bodyBase64Encoded,
+ ) {
+ parent::__construct($monotonicTime, $systemTime);
+ }
+
+ /**
+ * @return string[]
+ */
+ protected static function propertiesExcludedFromLog(): array
+ {
+ return ['bodyBase64Encoded'];
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php
new file mode 100644
index 0000000..c1dde7a
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php
@@ -0,0 +1,142 @@
+loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('intakeApiRequest'));
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Processing intake API request');
+
+ $body = base64_decode($intakeApiRequest->bodyBase64Encoded);
+ Assert::assertIsString($body); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Body size: ' . strlen($body));
+
+ $contentLength = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_LENGTH, $intakeApiRequest->headers);
+ AssertEx::stringSameAsInt(strlen($body), $contentLength);
+
+ $contentType = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_TYPE, $intakeApiRequest->headers);
+ Assert::assertSame(HttpContentTypes::PROTOBUF, $contentType);
+
+ $serializer = ProtobufSerializer::getDefault();
+ $exportTraceServiceRequest = new ExportTraceServiceRequest();
+ $serializer->hydrate($exportTraceServiceRequest, $body);
+
+ return new ParsedExportedData(self::deserializeSpans($exportTraceServiceRequest, $logger));
+ }
+
+ /**
+ * @param array $repeatedFieldNameToObj
+ *
+ * @return array
+ */
+ private static function buildLogContextForRepeatedField(array $repeatedFieldNameToObj): array
+ {
+ $repeatedFieldName = array_key_first($repeatedFieldNameToObj);
+ return ['count(' . $repeatedFieldName . ')' => count($repeatedFieldNameToObj[$repeatedFieldName])];
+ }
+
+ /**
+ * @return Span[]
+ */
+ private static function deserializeSpans(ExportTraceServiceRequest $exportTraceServiceRequest, Logger $logger): array
+ {
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $resourceSpansRepeatedField = $exportTraceServiceRequest->getResourceSpans();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('resourceSpansRepeatedField')));
+
+ $result = [];
+ foreach ($resourceSpansRepeatedField as $resourceSpans) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('resourceSpans'));
+ Assert::assertInstanceOf(ResourceSpans::class, $resourceSpans);
+ $scopeSpansRepeatedField = $resourceSpans->getScopeSpans();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('scopeSpansRepeatedField')));
+ foreach ($scopeSpansRepeatedField as $scopeSpans) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('scopeSpans'));
+ Assert::assertInstanceOf(ScopeSpans::class, $scopeSpans);
+ $spansRepeatedField = $scopeSpans->getSpans();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('spansRepeatedField')));
+ foreach ($spansRepeatedField as $otelProtoSpan) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('otelProtoSpan'));
+ Assert::assertInstanceOf(OTelProtoSpan::class, $otelProtoSpan);
+ $span = new Span($otelProtoSpan);
+ if (($reason = self::reasonToDiscard($span)) !== null) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Span discarded', compact('reason', 'span'));
+ continue;
+ }
+ $result[] = $span;
+ }
+ }
+ }
+ return $result;
+ }
+
+ private static function reasonToDiscard(Span $span): ?string
+ {
+ /** @var string[] $attributesToCheckForTestsInfraUrlSubPath */
+ static $attributesToCheckForTestsInfraUrlSubPath = [TraceAttributes::URL_PATH, TraceAttributes::URL_FULL, TraceAttributes::URL_ORIGINAL];
+ foreach ($attributesToCheckForTestsInfraUrlSubPath as $attributeName) {
+ if (($reason = self::reasonToDiscardIfOptionalAttributeContainsString($span->attributes, $attributeName, TestInfraHttpServerProcessBase::BASE_URI_PATH)) !== null) {
+ return $reason;
+ }
+ }
+
+ return null;
+ }
+
+ /** @noinspection PhpSameParameterValueInspection */
+ private static function reasonToDiscardIfOptionalAttributeContainsString(SpanAttributes $attributes, string $attributeName, string $subString): ?string
+ {
+ if (
+ (($attributeValue = $attributes->tryToGetString($attributeName)) !== null)
+ &&
+ str_contains($attributeValue, $subString)
+ ) {
+ return 'Attribute (key: `' . $attributeName . '\', value: `' . $attributeValue . '\') contains `' . $subString . '\'';
+ }
+
+ return null;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php b/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php
new file mode 100644
index 0000000..eac829c
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php
@@ -0,0 +1,32 @@
+ */
+ private array $pendingDataRequests = [];
+ private readonly Logger $logger;
+ private Clock $clock;
+
+ public function __construct()
+ {
+ $this->clock = new Clock(AmbientContextForTests::loggerFactory());
+ $this->cleanTestScoped();
+
+ /** @noinspection PhpUnhandledExceptionInspection */
+ parent::__construct();
+
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ protected function expectedPortsCount(): int
+ {
+ return 2;
+ }
+
+ #[Override]
+ protected function onNewConnection(int $socketIndex, ConnectionInterface $connection): void
+ {
+ parent::onNewConnection($socketIndex, $connection);
+ Assert::assertCount(2, $this->serverSockets);
+ Assert::assertLessThan(count($this->serverSockets), $socketIndex);
+
+ // $socketIndex 0 is used for test infrastructure communication
+ // $socketIndex 1 is used for APM Agent <-> Server communication
+ if ($socketIndex == 1) {
+ $newEvent = new AgentToOTelCollectorConnectionStarted(
+ $this->clock->getMonotonicClockCurrentTime(),
+ $this->clock->getSystemClockCurrentTime(),
+ );
+ $this->addAgentToOTeCollectorEvent($newEvent);
+ }
+ }
+
+ private function addAgentToOTeCollectorEvent(AgentToOTeCollectorEvent $event): void
+ {
+ Assert::assertNotNull($this->reactLoop);
+ $this->agentToOTeCollectorEvents[] = $event;
+
+ foreach ($this->pendingDataRequests as $pendingDataRequest) {
+ $this->reactLoop->cancelTimer($pendingDataRequest->timer);
+ ($pendingDataRequest->callToSendResponse)($this->fulfillDataRequest($pendingDataRequest->fromIndex));
+ }
+ $this->pendingDataRequests = [];
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function processRequest(ServerRequestInterface $request): null|ResponseInterface|Promise
+ {
+ if ($request->getUri()->getPath() === self::INTAKE_API_URI) {
+ return $this->processIntakeApiRequest($request);
+ }
+
+ if (TextUtil::isPrefixOf(self::MOCK_API_URI_PREFIX, $request->getUri()->getPath())) {
+ return $this->processMockApiRequest($request);
+ }
+
+ if ($request->getUri()->getPath() === TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH) {
+ $this->cleanTestScoped();
+ return new Response(/* status: */ 200);
+ }
+
+ return null;
+ }
+
+ #[Override]
+ protected function shouldRequestHaveSpawnedProcessInternalId(ServerRequestInterface $request): bool
+ {
+ return $request->getUri()->getPath() !== self::INTAKE_API_URI;
+ }
+
+ private function processIntakeApiRequest(ServerRequestInterface $request): ResponseInterface
+ {
+ Assert::assertNotNull($this->reactLoop);
+
+ if ($request->getBody()->getSize() === 0) {
+ return $this->buildIntakeApiErrorResponse(/* status */ HttpStatusCodes::BAD_REQUEST, 'Intake API request should not have empty body');
+ }
+
+ $newRequest = new IntakeApiRequest(
+ AmbientContextForTests::clock()->getMonotonicClockCurrentTime(),
+ AmbientContextForTests::clock()->getSystemClockCurrentTime(),
+ $request->getHeaders(), // @phpstan-ignore argument.type
+ base64_encode($request->getBody()->getContents()),
+ );
+
+ ($loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__))
+ && $loggerProxyDebug->log(__LINE__, 'Received request for Intake API', ['newRequest' => $newRequest]);
+
+ if ($loggerProxyDebug !== null) {
+ $exportedData = IntakeApiRequestDeserializer::deserialize($newRequest);
+ if ($exportedData->isEmpty()) {
+ $loggerProxyDebug->log(__LINE__, 'All of the contents has been discarded');
+ } else {
+ $loggerProxyDebug->log(__LINE__, 'Contents', compact('exportedData'));
+ }
+ }
+
+ $this->addAgentToOTeCollectorEvent($newRequest);
+
+ return new Response(/* status: */ 202);
+ }
+
+ /**
+ * @return ResponseInterface|Promise
+ */
+ private function processMockApiRequest(ServerRequestInterface $request): Promise|ResponseInterface
+ {
+ return match ($command = substr($request->getUri()->getPath(), strlen(self::MOCK_API_URI_PREFIX))) {
+ self::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH => $this->getIntakeApiRequests($request),
+ default => $this->buildErrorResponse(HttpStatusCodes::BAD_REQUEST, 'Unknown Mock API command `' . $command . '\''),
+ };
+ }
+
+ /**
+ * @return ResponseInterface|Promise
+ */
+ private function getIntakeApiRequests(ServerRequestInterface $request): Promise|ResponseInterface
+ {
+ $fromIndex = intval(self::getRequiredRequestHeader($request, self::FROM_INDEX_HEADER_NAME));
+ $shouldWait = BoolUtil::fromString(self::getRequiredRequestHeader($request, self::SHOULD_WAIT_HEADER_NAME));
+ if (!NumericUtil::isInClosedInterval(0, $fromIndex, count($this->agentToOTeCollectorEvents))) {
+ return $this->buildErrorResponse(
+ HttpStatusCodes::BAD_REQUEST /* status */,
+ 'Invalid `' . self::FROM_INDEX_HEADER_NAME . '\' HTTP request header value: ' . $fromIndex
+ . ' (should be in range[0, ' . count($this->agentToOTeCollectorEvents) . '])'
+ );
+ }
+
+ if ($this->hasNewDataFromAgentRequest($fromIndex) || !$shouldWait) {
+ return $this->fulfillDataRequest($fromIndex);
+ }
+
+ /** @var Promise $promise */
+ $promise = new Promise(
+ /**
+ * @param callable(ResponseInterface): void $callToSendResponse
+ */
+ function (callable $callToSendResponse) use ($fromIndex): void {
+ $pendingDataRequestId = $this->pendingDataRequestNextId++;
+ Assert::assertNotNull($this->reactLoop);
+ $timer = $this->reactLoop->addTimer(
+ HttpClientUtilForTests::MAX_WAIT_TIME_SECONDS,
+ function () use ($pendingDataRequestId) {
+ $this->fulfillTimedOutPendingDataRequest($pendingDataRequestId);
+ }
+ );
+ ArrayUtilForTests::addAssertingKeyNew($pendingDataRequestId, new MockOTelCollectorPendingDataRequest($fromIndex, $callToSendResponse, $timer), /* n,out */ $this->pendingDataRequests);
+ }
+ );
+ return $promise;
+ }
+
+ private function hasNewDataFromAgentRequest(int $fromIndex): bool
+ {
+ return count($this->agentToOTeCollectorEvents) > $fromIndex;
+ }
+
+ private function fulfillDataRequest(int $fromIndex): ResponseInterface
+ {
+ $newEvents = $this->hasNewDataFromAgentRequest($fromIndex) ? array_slice($this->agentToOTeCollectorEvents, $fromIndex) : [];
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Sending response ...', ['fromIndex' => $fromIndex, 'newEvents count' => count($newEvents)]);
+
+ return self::encodeResponse($newEvents);
+ }
+
+ /**
+ * @param AgentToOTeCollectorEvent[] $events
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ private static function encodeResponse(array $events): ResponseInterface
+ {
+ $eventsWrapped = new AgentToOTeCollectorEvents($events);
+ return new Response(
+ status: HttpStatusCodes::OK,
+ headers: [HttpHeaderNames::CONTENT_TYPE => HttpContentTypes::JSON],
+ body: JsonUtil::encode([AgentToOTeCollectorEvents::class => PhpSerializationUtil::serializeToString($eventsWrapped)])
+ );
+ }
+
+ /**
+ * @return AgentToOTeCollectorEvent[]
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ public static function decodeResponse(ResponseInterface $response): array
+ {
+ $responseBody = $response->getBody()->getContents();
+ $contentType = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_TYPE, $response->getHeaders()); // @phpstan-ignore argument.type
+ $dbgCtx = ['expected status code' => HttpStatusCodes::OK, 'actual status code' => $response->getStatusCode()];
+ ArrayUtilForTests::append(['expected content type' => HttpContentTypes::JSON], to: $dbgCtx);
+ ArrayUtilForTests::append(compact('contentType', 'responseBody'), to: $dbgCtx);
+ if ($response->getStatusCode() !== HttpStatusCodes::OK) {
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Unexpected status code', $dbgCtx));
+ }
+ if ($contentType !== HttpContentTypes::JSON) {
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Unexpected content type', $dbgCtx));
+ }
+
+ $responseBodyDecodedJson = JsonUtil::decode($responseBody, asAssocArray: true);
+ Assert::assertIsArray($responseBodyDecodedJson);
+ Assert::assertTrue(ArrayUtil::getValueIfKeyExists(AgentToOTeCollectorEvents::class, $responseBodyDecodedJson, /* out */ $newEventsWrappedSerialized));
+ Assert::assertIsString($newEventsWrappedSerialized);
+ return PhpSerializationUtil::unserializeFromStringAssertType($newEventsWrappedSerialized, AgentToOTeCollectorEvents::class)->events;
+ }
+
+ private function fulfillTimedOutPendingDataRequest(int $pendingDataRequestId): void
+ {
+
+ if (!ArrayUtil::removeValue($pendingDataRequestId, $this->pendingDataRequests, /* out */ $pendingDataRequest)) {
+ // If request is already fulfilled then just return
+ return;
+ }
+
+ ($loggerProxy = $this->logger->ifWarningLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Timed out while waiting for ' . self::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH . ' to be fulfilled - returning empty data set...', compact('pendingDataRequestId'));
+
+ ($pendingDataRequest->callToSendResponse)($this->fulfillDataRequest($pendingDataRequest->fromIndex));
+ }
+
+ protected function buildIntakeApiErrorResponse(int $status, string $message): ResponseInterface
+ {
+ return new Response(status: $status, headers: [HttpHeaderNames::CONTENT_TYPE => HttpContentTypes::TEXT], body: $message);
+ }
+
+ private function cleanTestScoped(): void
+ {
+ $this->agentToOTeCollectorEvents = [];
+ $this->pendingDataRequestNextId = 1;
+ $this->pendingDataRequests = [];
+ }
+
+ #[Override]
+ protected function exit(): void
+ {
+ Assert::assertNotNull($this->reactLoop);
+
+ foreach ($this->pendingDataRequests as $pendingDataRequest) {
+ $this->reactLoop->cancelTimer($pendingDataRequest->timer);
+ }
+
+ parent::exit();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php
new file mode 100644
index 0000000..2779f9f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php
@@ -0,0 +1,96 @@
+spawnedProcessOsId,
+ $httpSpawnedProcessHandle->spawnedProcessInternalId,
+ $httpSpawnedProcessHandle->ports
+ );
+
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ public function getPortForAgent(): int
+ {
+ Assert::assertCount(2, $this->ports);
+ return $this->ports[1];
+ }
+
+ /**
+ * @return AgentToOTeCollectorEvent[]
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+ public function fetchNewData(bool $shouldWait): array
+ {
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting...');
+
+ $response = $this->sendRequest(
+ HttpMethods::GET,
+ MockOTelCollector::MOCK_API_URI_PREFIX . MockOTelCollector::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH,
+ [
+ MockOTelCollector::FROM_INDEX_HEADER_NAME => strval($this->nextIntakeApiRequestIndexToFetch),
+ MockOTelCollector::SHOULD_WAIT_HEADER_NAME => BoolUtil::toString($shouldWait),
+ ]
+ );
+
+ $newEvents = MockOTelCollector::decodeResponse($response);
+
+ if (ArrayUtilForTests::isEmpty($newEvents)) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Fetched NO new data from agent receiver events');
+ } else {
+ $this->nextIntakeApiRequestIndexToFetch += count($newEvents);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Fetched new data from agent receiver events', ['count(newEvents)' => count($newEvents)]);
+ }
+ return $newEvents;
+ }
+
+ public function cleanTestScoped(): void
+ {
+ $this->nextIntakeApiRequestIndexToFetch = 0;
+
+ $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH);
+ Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode());
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php
new file mode 100644
index 0000000..aedce42
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php
@@ -0,0 +1,40 @@
+
+ * @phpstan-type OTelAttributesMapIterable iterable
+ * @phpstan-type IntLimitedToOTelSpanKind OTelSpanKind::KIND_*
+ */
+class OTelUtil
+{
+ public static function getTracer(): TracerInterface
+ {
+ return Globals::tracerProvider()->getTracer(name: 'co.elastic.php.elastic.component-tests', version: Version::VERSION_1_27_0->value);
+ }
+
+ /**
+ * @phpstan-param non-empty-string $spanName
+ * @phpstan-param IntLimitedToOTelSpanKind $spanKind
+ * @phpstan-param OTelAttributesMapIterable $attributes
+ */
+ public static function startSpan(TracerInterface $tracer, string $spanName, int $spanKind = OTelSpanKind::KIND_INTERNAL, iterable $attributes = []): OTelApiSpanInterface
+ {
+ $parentCtx = Context::getCurrent();
+ $newSpanBuilder = $tracer->spanBuilder($spanName)->setParent($parentCtx)->setSpanKind($spanKind)->setAttributes($attributes);
+ $newSpan = $newSpanBuilder->startSpan();
+ $newSpanCtx = $newSpan->storeInContext($parentCtx);
+ Context::storage()->attach($newSpanCtx);
+ return $newSpan;
+ }
+
+ /**
+ * @param OTelAttributesMapIterable $attributes
+ */
+ public static function endActiveSpan(?Throwable $throwable = null, ?string $errorStatus = null, iterable $attributes = []): void
+ {
+ $scope = Context::storage()->scope();
+ if ($scope === null) {
+ return;
+ }
+ $scope->detach();
+ $span = OTelApiSpan::fromContext($scope->context());
+
+ $span->setAttributes($attributes);
+
+ if ($errorStatus !== null) {
+ $span->setAttribute(TraceAttributes::EXCEPTION_MESSAGE, $errorStatus);
+ $span->setStatus(StatusCode::STATUS_ERROR, $errorStatus);
+ }
+
+ if ($throwable) {
+ $span->recordException($throwable);
+ $span->setStatus(StatusCode::STATUS_ERROR, $throwable->getMessage());
+ }
+
+ $span->end();
+ }
+
+ /**
+ * @phpstan-param non-empty-string $spanName
+ * @phpstan-param IntLimitedToOTelSpanKind $spanKind
+ * @phpstan-param OTelAttributesMapIterable $attributes
+ */
+ public static function startEndSpan(
+ TracerInterface $tracer,
+ string $spanName,
+ int $spanKind = OTelSpanKind::KIND_INTERNAL,
+ iterable $attributes = [],
+ ?Throwable $throwable = null,
+ ?string $errorStatus = null
+ ): void {
+ self::startSpan($tracer, $spanName, $spanKind, $attributes);
+ self::endActiveSpan($throwable, $errorStatus);
+ }
+
+ /**
+ * @param iterable $attributeKeys
+ *
+ * @return array
+ */
+ public static function dbgDescForSpan(OTelApiSpanInterface $span, iterable $attributeKeys): array
+ {
+ $result = ['class' => get_class($span), 'isRecording' => $span->isRecording()];
+ if (method_exists($span, 'getName')) {
+ $result['name'] = $span->getName();
+ }
+ if (method_exists($span, 'getAttribute')) {
+ $attributes = [];
+ foreach ($attributeKeys as $attributeKey) {
+ $attributes[$attributeKey] = $span->getAttribute($attributeKey);
+ }
+ $result['attributes'] = $attributes;
+ }
+ return $result;
+ }
+
+ /**
+ * @param OTelAttributesMapIterable $attributes
+ */
+ public static function setActiveSpanAttributes(iterable $attributes): void
+ {
+ $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $logger->addAllContext(compact('attributes'));
+
+ $currentCtx = Context::getCurrent();
+ $span = OTelApiSpan::fromContext($currentCtx);
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Before setting attributes', ['span' => self::dbgDescForSpan($span, IterableUtil::keys($attributes))]);
+ $span->setAttributes($attributes);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'After setting attributes', ['span' => self::dbgDescForSpan($span, IterableUtil::keys($attributes))]);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php
new file mode 100644
index 0000000..b5650df
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php
@@ -0,0 +1,146 @@
+spans);
+ }
+
+ /**
+ * @return Span[]
+ */
+ public function findSpansByName(string $name): array
+ {
+ $result = [];
+ foreach ($this->spans as $span) {
+ if ($span->name === $name) {
+ $result[] = $span;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * @noinspection PhpUnused
+ */
+ public function singleSpanByName(string $name): Span
+ {
+ $spans = $this->findSpansByName($name);
+ Assert::assertCount(1, $spans);
+ return $spans[0];
+ }
+
+ /**
+ * @return iterable
+ *
+ * @noinspection PhpUnused
+ */
+ public function findChildSpans(string $parentId): iterable
+ {
+ foreach ($this->spans as $span) {
+ if ($span->parentId === $parentId) {
+ yield $span;
+ }
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public function findRootSpans(): iterable
+ {
+ foreach ($this->spans as $span) {
+ if ($span->parentId === null) {
+ yield $span;
+ }
+ }
+ }
+
+ public function singleRootSpan(): Span
+ {
+ return IterableUtil::singleValue($this->findRootSpans());
+ }
+
+ public function singleChildSpan(string $parentId): Span
+ {
+ return IterableUtil::singleValue($this->findChildSpans($parentId));
+ }
+
+ /**
+ * @param non-empty-string $attributeName
+ * @param SpanAttributeValue $attributeValueToFind
+ *
+ * @return iterable
+ */
+ public function findSpansWithAttributeValue(string $attributeName, array|bool|float|int|null|string $attributeValueToFind): iterable
+ {
+ foreach ($this->spans as $span) {
+ if ($span->attributes->tryToGetValue($attributeName, /* out */ $actualAttributeValue) && $actualAttributeValue === $attributeValueToFind) {
+ yield $span;
+ }
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php
new file mode 100644
index 0000000..a671530
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php
@@ -0,0 +1,80 @@
+ $checksum, self::DATA_KEY => $data]);
+ }
+
+ public static function unserializeFromString(string $serialized): mixed
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $decodedJson = JsonUtil::decode($serialized, asAssocArray: true);
+ $dbgCtx->add(compact('decodedJson'));
+ Assert::assertIsArray($decodedJson);
+ Assert::assertTrue(ArrayUtil::getValueIfKeyExists(self::CHECKSUM_KEY, $decodedJson, /* out */ $receivedChecksum));
+ $dbgCtx->add(compact('receivedChecksum'));
+ Assert::assertTrue(ArrayUtil::getValueIfKeyExists(self::DATA_KEY, $decodedJson, /* out */ $data));
+ $dbgCtx->add(compact('data'));
+ Assert::assertIsString($data);
+ Assert::assertSame($receivedChecksum, crc32($data));
+ Assert::assertNotFalse($compressed = base64_decode($data, strict: true));
+ Assert::assertNotFalse($serialized = gzuncompress($compressed));
+ return unserialize($serialized);
+ }
+
+ /**
+ * @template T of object
+ *
+ * @param class-string $className
+ *
+ * @phpstan-return T
+ */
+ public static function unserializeFromStringAssertType(string $serialized, string $className): object
+ {
+ Assert::assertTrue(class_exists($className));
+ $obj = self::unserializeFromString($serialized);
+ Assert::assertInstanceOf($className, $obj);
+ return $obj;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php b/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php
new file mode 100644
index 0000000..9e3d877
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php
@@ -0,0 +1,115 @@
+dbgDesc = $dbgDesc;
+ $this->maxWaitTimeInMicroseconds = $maxWaitTimeInMicroseconds;
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ }
+
+ /**
+ * @param Closure(): bool $check
+ */
+ public function run(Closure $check): bool
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Starting to check if ' . $this->dbgDesc . '...',
+ ['maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds)]
+ );
+
+ $numberOfAttempts = 0;
+ $sinceStarted = new Stopwatch();
+ $sinceLastReport = new Stopwatch();
+ while (true) {
+ ++$numberOfAttempts;
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Starting attempt ' . $numberOfAttempts . ' to check if ' . $this->dbgDesc . '...');
+ /** @noinspection PhpIfWithCommonPartsInspection */
+ if ($check()) {
+ $elapsedTime = $sinceStarted->elapsedInMicroseconds();
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Successfully completed checking if ' . $this->dbgDesc,
+ ['elapsedTime' => TimeUtil::formatDurationInMicroseconds($elapsedTime)]
+ );
+ return true;
+ }
+
+ $elapsedTime = $sinceStarted->elapsedInMicroseconds();
+ if ($elapsedTime >= $this->maxWaitTimeInMicroseconds) {
+ break;
+ }
+
+ if ($sinceLastReport->elapsedInMicroseconds() >= $this->reportIntervalInMicroseconds) {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Still checking if ' . $this->dbgDesc . '...',
+ [
+ 'elapsedTime' => TimeUtil::formatDurationInMicroseconds($elapsedTime),
+ 'numberOfAttempts' => $numberOfAttempts,
+ 'maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds),
+ ]
+ );
+ $sinceLastReport->restart();
+ }
+
+ ($loggerProxy = $this->logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Sleeping ' . TimeUtil::formatDurationInMicroseconds($this->sleepTimeInMicroseconds)
+ . ' before checking again if ' . $this->dbgDesc
+ . ' (numberOfAttempts: ' . $numberOfAttempts . ')'
+ . '...'
+ );
+ usleep($this->sleepTimeInMicroseconds);
+ }
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'Reached max wait time while checking if ' . $this->dbgDesc,
+ [
+ 'elapsedTime' => TimeUtil::formatDurationInMicroseconds($sinceStarted->elapsedInMicroseconds()),
+ 'numberOfAttempts' => $numberOfAttempts,
+ 'maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds),
+ ]
+ );
+ return false;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php
new file mode 100644
index 0000000..708f689
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php
@@ -0,0 +1,163 @@
+run(
+ function () use ($pid) {
+ return !self::doesProcessExist($pid);
+ }
+ );
+ }
+
+ public static function terminateProcess(int $pid): bool
+ {
+ exec("kill $pid > /dev/null", /* ref */ $cmdOutput, /* ref */ $cmdExitCode);
+ return $cmdExitCode === 0;
+ }
+
+ /**
+ * @phpstan-param EnvVars $envVars
+ */
+ public static function startBackgroundProcess(string $cmd, array $envVars): void
+ {
+ self::startProcessImpl("$cmd > /dev/null &", $envVars, descriptorSpec: [], isBackground: true);
+ }
+
+ /**
+ * @phpstan-param EnvVars $envVars
+ */
+ public static function startProcessAndWaitUntilExit(string $cmd, array $envVars, bool $shouldCaptureStdOutErr = false, ?int $expectedExitCode = null): int
+ {
+ $descriptorSpec = [];
+ $tempOutputFilePath = '';
+ if ($shouldCaptureStdOutErr) {
+ $tempOutputFilePath = FileUtil::createTempFile(dbgTempFilePurpose: 'spawn process stdout and stderr');
+ $descriptorSpec[1] = [self::PROC_OPEN_DESCRIPTOR_FILE_TYPE, $tempOutputFilePath, "w"]; // 1 - stdout
+ $descriptorSpec[2] = [self::PROC_OPEN_DESCRIPTOR_FILE_TYPE, $tempOutputFilePath, "w"]; // 2 - stderr
+ }
+
+ $hasReturnedExitCode = false;
+ $exitCode = -1;
+ try {
+ $exitCode = self::startProcessImpl($cmd, $envVars, $descriptorSpec, isBackground: false);
+ $hasReturnedExitCode = true;
+ } finally {
+ $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $logLevel = $hasReturnedExitCode ? LogLevel::debug : LogLevel::error;
+ $logCtx = [];
+ if ($hasReturnedExitCode) {
+ $logCtx['exit code'] = $exitCode;
+ }
+ if ($shouldCaptureStdOutErr) {
+ $logCtx['file for stdout + stderr'] = $tempOutputFilePath;
+ if (file_exists($tempOutputFilePath)) {
+ $logCtx['stdout + stderr'] = file_get_contents($tempOutputFilePath);
+ Assert::assertTrue(unlink($tempOutputFilePath));
+ }
+ }
+
+ ($loggerProxy = $logger->ifLevelEnabled($logLevel, __LINE__, __FUNCTION__))
+ && $loggerProxy->log($cmd . ' exited', $logCtx);
+
+ if ($expectedExitCode !== null && $hasReturnedExitCode) {
+ TestCase::assertSame($expectedExitCode, $exitCode, LoggableToString::convert($logCtx));
+ }
+ }
+
+ return $exitCode;
+ }
+
+ /**
+ * @phpstan-param EnvVars $envVars
+ * @phpstan-param array $descriptorSpec
+ */
+ private static function startProcessImpl(string $adaptedCmd, array $envVars, array $descriptorSpec, bool $isBackground): int
+ {
+ $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ $logger->addAllContext(compact('adaptedCmd', 'envVars', 'isBackground'));
+
+ $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting process...');
+
+ $pipes = [];
+ $openedProc = proc_open($adaptedCmd, $descriptorSpec, /* ref */ $pipes, /* cwd */ null, $envVars);
+ if ($openedProc === false) {
+ ($loggerProxyError = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxyError->log('Failed to start process');
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Failed to start process', compact('adaptedCmd', 'envVars')));
+ }
+
+ $newProcessInfo = proc_get_status($openedProc);
+ $pid = ArrayUtil::getValueIfKeyExistsElse('pid', $newProcessInfo, null);
+ $logger->addAllContext(compact('pid', 'newProcessInfo'));
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Process started');
+
+ if ($isBackground) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Waiting for process to exit...');
+ }
+ $exitCode = proc_close($openedProc);
+ $logger->addAllContext(compact('exitCode'));
+ if ($exitCode === SpawnedProcessBase::FAILURE_PROCESS_EXIT_CODE) {
+ ($loggerProxyError = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxyError->log('Process exited with the failure exit code');
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Process exited with the failure exit code', compact('exitCode', 'newProcessInfo', 'adaptedCmd', 'envVars')));
+ }
+
+ if ($isBackground) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Process to exited');
+ }
+
+ return $exitCode;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php
new file mode 100644
index 0000000..e725eac
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php
@@ -0,0 +1,47 @@
+
+ *
+ * @noinspection PhpUnused
+ */
+ public function getAllIntakeApiRequests(): iterable
+ {
+ foreach ($this->intakeApiConnections as $intakeApiConnection) {
+ yield from $intakeApiConnection->requests;
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php b/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php
new file mode 100644
index 0000000..995120d
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php
@@ -0,0 +1,71 @@
+getHeaderValue = $getHeaderValue;
+ }
+
+ public static function optionNameToHeaderName(string $optName): string
+ {
+ return self::HEADER_NAMES_PREFIX . strtoupper($optName);
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface
+ {
+ /** @var array $optionNameToHeaderValue */
+ $optionNameToHeaderValue = [];
+
+ foreach ($optionNameToMeta as $optionName => $optionMeta) {
+ $headerValue = ($this->getHeaderValue)(self::optionNameToHeaderName($optionName));
+ if ($headerValue !== null) {
+ $optionNameToHeaderValue[$optionName] = $headerValue;
+ }
+ }
+
+ return new RawSnapshotFromArray($optionNameToHeaderValue);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php
new file mode 100644
index 0000000..f2eeec9
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php
@@ -0,0 +1,226 @@
+ */
+ private Set $globalFilesToDeletePaths;
+
+ /** @var Set */
+ private Set $testScopedFilesToDeletePaths;
+
+ /** @var Set */
+ private Set $globalProcessesToTerminateIds;
+
+ /** @var Set */
+ private Set $testScopedProcessesToTerminateIds;
+
+ private ?TimerInterface $parentProcessTrackingTimer = null;
+
+ private Logger $logger;
+
+ public function __construct()
+ {
+ $this->globalFilesToDeletePaths = new Set();
+ $this->testScopedFilesToDeletePaths = new Set();
+
+ $this->globalProcessesToTerminateIds = new Set();
+ $this->testScopedProcessesToTerminateIds = new Set();
+
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+
+ parent::__construct();
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done');
+ }
+
+ #[Override]
+ protected function beforeLoopRun(): void
+ {
+ parent::beforeLoopRun();
+
+ Assert::assertNotNull($this->reactLoop);
+ $this->parentProcessTrackingTimer = $this->reactLoop->addPeriodicTimer(
+ 1 /* interval in seconds */,
+ function () {
+ $rootProcessId = AmbientContextForTests::testConfig()->dataPerProcess()->rootProcessId;
+ if (!ProcessUtil::doesProcessExist($rootProcessId)) {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Detected that parent process does not exist');
+ $this->exit();
+ }
+ }
+ );
+ }
+
+ #[Override]
+ protected function exit(): void
+ {
+ $this->cleanSpawnedProcesses(isTestScopedOnly: false);
+ $this->cleanFiles(isTestScopedOnly: false);
+
+ Assert::assertNotNull($this->reactLoop);
+ Assert::assertNotNull($this->parentProcessTrackingTimer);
+ $this->reactLoop->cancelTimer($this->parentProcessTrackingTimer);
+
+ parent::exit();
+ }
+
+ private function cleanSpawnedProcesses(bool $isTestScopedOnly): void
+ {
+ $this->cleanSpawnedProcessesFrom(/* dbgFilesSetDesc */ 'test scoped', $this->testScopedProcessesToTerminateIds);
+ if (!$isTestScopedOnly) {
+ $this->cleanSpawnedProcessesFrom(/* dbgFilesSetDesc */ 'global', $this->globalProcessesToTerminateIds);
+ }
+ }
+
+ private function cleanTestScoped(): void
+ {
+ $this->cleanSpawnedProcesses(isTestScopedOnly: true);
+ $this->cleanFiles(isTestScopedOnly: true);
+ }
+
+ /**
+ * @param Set $processesToTerminateIds
+ */
+ private function cleanSpawnedProcessesFrom(string $dbgProcessSetDesc, Set $processesToTerminateIds): void
+ {
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $processesToTerminateIdsCount = $processesToTerminateIds->count();
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Terminating spawned processes ()...', compact('dbgProcessSetDesc', 'processesToTerminateIdsCount'));
+
+ foreach ($processesToTerminateIds as $spawnedProcessesId) {
+ if (!ProcessUtil::doesProcessExist($spawnedProcessesId)) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Spawned process does not exist anymore - no need to terminate', compact('spawnedProcessesId'));
+ continue;
+ }
+ $hasExitedNormally = ProcessUtil::terminateProcess($spawnedProcessesId);
+ $hasExited = ProcessUtil::waitForProcessToExit(/* dbgProcessDesc: */ 'Spawned', $spawnedProcessesId, /* maxWaitTimeInMicroseconds = 10 seconds */ 10 * 1000 * 1000);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Issued command to terminate spawned process', compact('spawnedProcessesId', 'hasExited', 'hasExitedNormally'));
+ }
+
+ $processesToTerminateIds->clear();
+ }
+
+ private function cleanFiles(bool $isTestScopedOnly): void
+ {
+ $this->cleanFilesFrom(/* dbgFilesSetDesc */ 'test scoped', $this->testScopedFilesToDeletePaths);
+ if (!$isTestScopedOnly) {
+ $this->cleanFilesFrom(/* dbgFilesSetDesc */ 'global', $this->globalFilesToDeletePaths);
+ }
+ }
+
+ /**
+ * @param Set $filesToDeletePaths
+ */
+ private function cleanFilesFrom(string $dbgFilesSetDesc, Set $filesToDeletePaths): void
+ {
+ $filesToDeletePathsCount = $filesToDeletePaths->count();
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Deleting files...', compact('dbgFilesSetDesc', 'filesToDeletePathsCount'));
+
+ foreach ($filesToDeletePaths as $fileToDeletePath) {
+ if (!file_exists($fileToDeletePath)) {
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'File does not exist - so there is nothing to delete', compact('fileToDeletePath'));
+ continue;
+ }
+
+ $unlinkRetVal = unlink($fileToDeletePath);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Called unlink() to delete file', compact('fileToDeletePath', 'unlinkRetVal'));
+ }
+
+ $filesToDeletePaths->clear();
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function processRequest(ServerRequestInterface $request): ?ResponseInterface
+ {
+ switch ($request->getUri()->getPath()) {
+ case self::REGISTER_PROCESS_TO_TERMINATE_URI_PATH:
+ $this->registerProcessToTerminate($request);
+ break;
+ case self::REGISTER_FILE_TO_DELETE_URI_PATH:
+ $this->registerFileToDelete($request);
+ break;
+ case self::CLEAN_TEST_SCOPED_URI_PATH:
+ $this->cleanTestScoped();
+ break;
+ default:
+ return null;
+ }
+ return self::buildDefaultResponse();
+ }
+
+ protected function registerProcessToTerminate(ServerRequestInterface $request): void
+ {
+ $pid = intval(self::getRequiredRequestHeader($request, self::PID_QUERY_HEADER_NAME));
+ $isTestScopedAsString = self::getRequiredRequestHeader($request, self::IS_TEST_SCOPED_QUERY_HEADER_NAME);
+ $isTestScoped = JsonUtil::decode($isTestScopedAsString, asAssocArray: true);
+ $processesToTerminateIds = $isTestScoped ? $this->testScopedProcessesToTerminateIds : $this->globalProcessesToTerminateIds;
+ $processesToTerminateIds->add($pid);
+ $processesToTerminateIdsCount = $processesToTerminateIds->count();
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Successfully registered process to terminate', compact('pid', 'isTestScoped', 'processesToTerminateIdsCount'));
+ }
+
+ protected function registerFileToDelete(ServerRequestInterface $request): void
+ {
+ $path = self::getRequiredRequestHeader($request, self::PATH_QUERY_HEADER_NAME);
+ $isTestScopedAsString = self::getRequiredRequestHeader($request, self::IS_TEST_SCOPED_QUERY_HEADER_NAME);
+ $isTestScoped = JsonUtil::decode($isTestScopedAsString, asAssocArray: true);
+ $filesToDeletePaths = $isTestScoped ? $this->testScopedFilesToDeletePaths : $this->globalFilesToDeletePaths;
+ $filesToDeletePaths->add($path);
+ $filesToDeletePathsCount = $filesToDeletePaths->count();
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Successfully registered file to delete', compact('path', 'isTestScoped', 'filesToDeletePathsCount'));
+ }
+
+ #[Override]
+ protected function shouldRegisterThisProcessWithResourcesCleaner(): bool
+ {
+ return false;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php
new file mode 100644
index 0000000..b60771c
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php
@@ -0,0 +1,57 @@
+spawnedProcessOsId,
+ $httpSpawnedProcessHandle->spawnedProcessInternalId,
+ $httpSpawnedProcessHandle->ports
+ );
+
+ $this->resourcesClient = new ResourcesClient($this->spawnedProcessInternalId, $this->getMainPort());
+ }
+
+ public function getClient(): ResourcesClient
+ {
+ return $this->resourcesClient;
+ }
+
+ public function cleanTestScoped(): void
+ {
+ $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH);
+ Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode());
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php
new file mode 100644
index 0000000..e535e62
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php
@@ -0,0 +1,100 @@
+logger = $this->buildLogger();
+ }
+
+ public function buildLogger(): Logger
+ {
+ return AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ /**
+ * @return list
+ */
+ public function __sleep(): array
+ {
+ $result = [];
+ /** @var string $propName */
+ foreach ($this as $propName => $_) { // @phpstan-ignore foreach.nonIterable
+ if ($propName === 'logger') {
+ continue;
+ }
+ $result[] = $propName;
+ }
+ return $result;
+ }
+
+ public function __wakeup(): void
+ {
+ $this->logger = $this->buildLogger();
+ }
+
+ /** @noinspection PhpSameParameterValueInspection */
+ private function registerFileToDelete(string $fullPath, bool $isTestScoped): void
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Registering file to delete with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class), compact('fullPath'));
+
+ $response = HttpClientUtilForTests::sendRequest(
+ HttpMethods::POST,
+ new UrlParts(port: $this->resourcesCleanerPort, path: ResourcesCleaner::REGISTER_FILE_TO_DELETE_URI_PATH),
+ new TestInfraDataPerRequest(spawnedProcessInternalId: $this->resourcesCleanerSpawnedProcessInternalId),
+ [ResourcesCleaner::PATH_QUERY_HEADER_NAME => $fullPath, ResourcesCleaner::IS_TEST_SCOPED_QUERY_HEADER_NAME => BoolUtil::toString($isTestScoped)] /* <- headers */
+ );
+ if ($response->getStatusCode() !== HttpStatusCodes::OK) {
+ throw new ComponentTestsInfraException('Failed to register with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class));
+ }
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Successfully registered file to delete with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class), compact('fullPath'));
+ }
+
+ public function createTempFile(?string $dbgTempFilePurpose = null, bool $shouldBeDeletedOnTestExit = true): string
+ {
+ $tempFileFullPath = FileUtil::createTempFile($dbgTempFilePurpose);
+ if ($shouldBeDeletedOnTestExit) {
+ $this->registerFileToDelete($tempFileFullPath, isTestScoped: true);
+ }
+ return $tempFileFullPath;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/Span.php b/tests/ElasticOTelTests/ComponentTests/Util/Span.php
new file mode 100644
index 0000000..83682e2
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/Span.php
@@ -0,0 +1,84 @@
+attributes = new SpanAttributes($protoSpan->getAttributes());
+ $this->id = self::convertId($protoSpan->getSpanId());
+ $this->kind = SpanKind::fromOTelProtoSpanKind($protoSpan->getKind());
+ $this->name = $protoSpan->getName();
+ $this->parentId = self::convertNullableId($protoSpan->getParentSpanId());
+ $this->traceId = self::convertId($protoSpan->getTraceId());
+ }
+
+ private static function convertNullableId(string $binaryId): ?string
+ {
+ return TextUtil::isEmptyString($binaryId) ? null : self::convertId($binaryId);
+ }
+
+ private static function convertId(string $binaryId): string
+ {
+ Assert::assertFalse(TextUtil::isEmptyString($binaryId));
+
+ /** @var int[] $idAsBytesSeq */
+ $idAsBytesSeq = [];
+ foreach (TextUtilForTests::iterateOverChars($binaryId) as $binaryIdCharAsInt) {
+ $idAsBytesSeq[] = $binaryIdCharAsInt;
+ }
+
+ return IdGenerator::convertBinaryIdToString($idAsBytesSeq);
+ }
+
+ public function toLog(LogStreamInterface $stream): void
+ {
+ /** @var array $asMap */
+ $asMap = [];
+ $asMap['name'] = $this->name;
+ $asMap['kind'] = $this->kind->name;
+ $asMap['id'] = $this->id;
+ $asMap['traceId'] = $this->traceId;
+ if ($this->parentId !== null) {
+ $asMap['parentId'] = $this->parentId;
+ }
+ $asMap['attributes'] = $this->attributes;
+ $stream->toLogAs($asMap);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php
new file mode 100644
index 0000000..2815a4f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php
@@ -0,0 +1,229 @@
+|array|bool|float|int|null|string
+ *
+ * @implements ArrayReadInterface
+ */
+final class SpanAttributes implements ArrayReadInterface, Countable, LoggableInterface
+{
+ /** @var array $keyToValueMap */
+ private readonly array $keyToValueMap;
+
+ public function __construct(ProtobufRepeatedField $protobufRepeatedField)
+ {
+ $this->keyToValueMap = self::convertProtobufRepeatedFieldToMap($protobufRepeatedField);
+ }
+
+ /**
+ * @return AttributeValue
+ */
+ private static function extractValue(OTelProtoKeyValue $keyValue): array|bool|float|int|null|string
+ {
+ if (!$keyValue->hasValue()) {
+ return null;
+ }
+
+ $anyValue = $keyValue->getValue();
+ if ($anyValue === null) {
+ return null;
+ }
+
+ if ($anyValue->hasArrayValue()) {
+ $arrayValue = $anyValue->getArrayValue();
+ if ($arrayValue === null) {
+ return null;
+ }
+ $result = [];
+ foreach ($arrayValue->getValues() as $repeatedFieldSubValue) {
+ $result[] = $repeatedFieldSubValue;
+ }
+ return $result;
+ }
+
+ if ($anyValue->hasBoolValue()) {
+ return $anyValue->getBoolValue();
+ }
+
+ if ($anyValue->hasBytesValue()) {
+ return IterableUtil::toList(TextUtilForTests::iterateOverChars($anyValue->getBytesValue()));
+ }
+
+ if ($anyValue->hasDoubleValue()) {
+ return $anyValue->getDoubleValue();
+ }
+
+ if ($anyValue->hasIntValue()) {
+ $value = $anyValue->getIntValue();
+ if (is_int($value)) {
+ return $value;
+ }
+ return AssertEx::stringIsInt($value);
+ }
+
+ if ($anyValue->hasKvlistValue()) {
+ $kvListValue = $anyValue->getKvlistValue();
+ if ($kvListValue === null) {
+ return null;
+ }
+ $result = [];
+ foreach ($kvListValue->getValues() as $repeatedFieldSubKey => $repeatedFieldSubValue) {
+ Assert::assertTrue(is_int($repeatedFieldSubKey) || is_string($repeatedFieldSubKey));
+ Assert::assertArrayNotHasKey($repeatedFieldSubKey, $result);
+ $result[$repeatedFieldSubKey] = $repeatedFieldSubValue;
+ }
+ return $result;
+ }
+
+ if ($anyValue->hasStringValue()) {
+ return $anyValue->getStringValue();
+ }
+
+ Assert::fail('Unknown value type; ' . LoggableToString::convert(compact('keyValue')));
+ }
+
+ /**
+ * @param ProtobufRepeatedField $protobufRepeatedField
+ *
+ * @return array
+ */
+ private static function convertProtobufRepeatedFieldToMap(ProtobufRepeatedField $protobufRepeatedField): array
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $result = [];
+ foreach ($protobufRepeatedField as $keyValue) {
+ $dbgCtx->add(compact('keyValue'));
+ Assert::assertInstanceOf(OTelProtoKeyValue::class, $keyValue);
+ Assert::assertArrayNotHasKey($keyValue->getKey(), $result);
+ $result[$keyValue->getKey()] = self::extractValue($keyValue);
+ }
+
+ return $result;
+ }
+
+ #[Override]
+ public function keyExists(int|string $key): bool
+ {
+ return array_key_exists($key, $this->keyToValueMap);
+ }
+
+ #[Override]
+ public function getValue(int|string $key): mixed
+ {
+ Assert::assertIsString($key);
+ Assert::assertTrue(ArrayUtil::getValueIfKeyExists($key, $this->keyToValueMap, /* out */ $attributeValue));
+ return $attributeValue;
+ }
+
+ #[Override]
+ public function count(): int
+ {
+ return count($this->keyToValueMap);
+ }
+
+ /**
+ * @param AttributeValue &$attributeValueOut
+ *
+ * @param-out AttributeValue $attributeValueOut
+ */
+ public function tryToGetValue(string $attributeName, array|bool|float|int|null|string &$attributeValueOut): bool
+ {
+ return ArrayUtil::getValueIfKeyExists($attributeName, $this->keyToValueMap, /* out */ $attributeValueOut); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ }
+
+ public function tryToGetBool(string $attributeName): ?bool
+ {
+ if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) {
+ return null;
+ }
+ return AssertEx::isBool($attributeValue);
+ }
+
+ public function tryToGetFloat(string $attributeName): ?float
+ {
+ if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) {
+ return null;
+ }
+ return AssertEx::isFloat($attributeValue);
+ }
+
+ public function tryToGetInt(string $attributeName): ?int
+ {
+ if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) {
+ return null;
+ }
+ return AssertEx::isInt($attributeValue);
+ }
+
+ public function tryToGetString(string $attributeName): ?string
+ {
+ if (!ArrayUtil::getValueIfKeyExists($attributeName, $this->keyToValueMap, /* out */ $attributeValue)) {
+ return null;
+ }
+ return AssertEx::isString($attributeValue);
+ }
+
+ public function getBool(string $attributeName): bool
+ {
+ return AssertEx::notNull($this->tryToGetBool($attributeName));
+ }
+
+ public function getFloat(string $attributeName): float
+ {
+ return AssertEx::notNull($this->tryToGetFloat($attributeName));
+ }
+
+ public function getInt(string $attributeName): int
+ {
+ return AssertEx::notNull($this->tryToGetInt($attributeName));
+ }
+
+ public function getString(string $attributeName): string
+ {
+ return AssertEx::notNull($this->tryToGetString($attributeName));
+ }
+
+ public function toLog(LogStreamInterface $stream): void
+ {
+ $stream->toLogAs($this->keyToValueMap);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php
new file mode 100644
index 0000000..0db1888
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php
@@ -0,0 +1,46 @@
+
+ */
+final class SpanAttributesArrayExpectations extends ArrayExpectations
+{
+ #[Override]
+ protected function assertValueMatches(string|int $key, mixed $expectedValue, mixed $actualValue): void
+ {
+ if ($key === TraceAttributes::URL_SCHEME) {
+ Assert::assertEqualsIgnoringCase($expectedValue, $actualValue);
+ } else {
+ Assert::assertSame($expectedValue, $actualValue);
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php
new file mode 100644
index 0000000..8a6ed75
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php
@@ -0,0 +1,65 @@
+ $attributes
+ * @param array $notAllowedAttributeNames
+ */
+ public function __construct(
+ array $attributes,
+ bool $allowOtherKeysInActual = true,
+ private readonly array $notAllowedAttributeNames = []
+ ) {
+ $this->arrayExpectations = new SpanAttributesArrayExpectations($attributes, $allowOtherKeysInActual);
+ }
+
+ #[Override]
+ public function assertMatchesMixed(mixed $actual): void
+ {
+ Assert::assertInstanceOf(SpanAttributes::class, $actual);
+ $this->assertMatches($actual);
+ }
+
+ public function assertMatches(SpanAttributes $actual): void
+ {
+ $this->arrayExpectations->assertMatches($actual);
+
+ foreach ($this->notAllowedAttributeNames as $notAllowedAttributeName) {
+ Assert::assertFalse($actual->keyExists($notAllowedAttributeName));
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php
new file mode 100644
index 0000000..404c6d8
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php
@@ -0,0 +1,46 @@
+assertMatchesMixed($actual);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php
new file mode 100644
index 0000000..c479da3
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php
@@ -0,0 +1,58 @@
+ self::unspecified,
+ OTelProtoSpanKind::SPAN_KIND_INTERNAL => self::internal,
+ OTelProtoSpanKind::SPAN_KIND_CLIENT => self::client,
+ OTelProtoSpanKind::SPAN_KIND_SERVER => self::server,
+ OTelProtoSpanKind::SPAN_KIND_PRODUCER => self::producer,
+ OTelProtoSpanKind::SPAN_KIND_CONSUMER => self::consumer,
+ ];
+
+ public static function fromOTelProtoSpanKind(int $otelProtoSpanKind): self
+ {
+ if (ArrayUtil::getValueIfKeyExists($otelProtoSpanKind, self::FROM_OTEL_PROTO_SPAN_KIND, /* out */ $result)) {
+ return $result;
+ }
+ Assert::fail('Unexpected span kind: ' . $otelProtoSpanKind);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php b/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php
new file mode 100644
index 0000000..27800df
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php
@@ -0,0 +1,167 @@
+logger = self::buildLogger()->addAllContext(compact('this'));
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Done', ['AmbientContext::testConfig()' => AmbientContextForTests::testConfig(), 'Environment variables' => EnvVarUtilForTests::getAll()]);
+ }
+
+ private static function buildLogger(): Logger
+ {
+ return AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__);
+ }
+
+ protected function processConfig(): void
+ {
+ AmbientContextForTests::testConfig()->validateForSpawnedProcess();
+
+ if ($this->shouldRegisterThisProcessWithResourcesCleaner()) {
+ TestCase::assertNotNull(
+ AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId,
+ LoggableToString::convert(AmbientContextForTests::testConfig())
+ );
+ TestCase::assertNotNull(
+ AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort,
+ LoggableToString::convert(AmbientContextForTests::testConfig())
+ );
+ }
+ }
+
+ /**
+ * @param Closure(SpawnedProcessBase): void $runImpl
+ *
+ * @throws Throwable
+ */
+ protected static function runSkeleton(Closure $runImpl): void
+ {
+ LoggingSubsystem::$isInTestingContext = true;
+
+ try {
+ $dbgProcessName = EnvVarUtilForTests::get(self::DBG_PROCESS_NAME_ENV_VAR_NAME);
+ TestCase::assertIsString($dbgProcessName);
+
+ AmbientContextForTests::init($dbgProcessName);
+
+ $thisObj = new static(); // @phpstan-ignore new.static
+
+ if (!$thisObj->shouldTracingBeEnabled()) {
+ ConfigUtilForTests::verifyTracingIsDisabled();
+ }
+
+ $thisObj->processConfig();
+
+ if ($thisObj->shouldRegisterThisProcessWithResourcesCleaner()) {
+ $thisObj->registerWithResourcesCleaner();
+ }
+
+ $runImpl($thisObj);
+ } catch (Throwable $throwable) {
+ $level = LogLevel::critical;
+ $isFromAppCode = false;
+ $throwableToLog = $throwable;
+ if ($throwable instanceof WrappedAppCodeException) {
+ $isFromAppCode = true;
+ $level = LogLevel::info;
+ $throwableToLog = $throwable->wrappedException();
+ }
+ $logger = isset($thisObj) ? $thisObj->logger : self::buildLogger();
+ ($loggerProxy = $logger->ifLevelEnabled($level, __LINE__, __FUNCTION__))
+ && $loggerProxy->logThrowable($throwableToLog, 'Throwable escaped to the top of the script', compact('isFromAppCode'));
+ if ($isFromAppCode) {
+ /** @noinspection PhpUnhandledExceptionInspection */
+ throw $throwableToLog;
+ } else {
+ exit(self::FAILURE_PROCESS_EXIT_CODE);
+ }
+ }
+ }
+
+ protected function shouldTracingBeEnabled(): bool
+ {
+ return false;
+ }
+
+ protected function shouldRegisterThisProcessWithResourcesCleaner(): bool
+ {
+ return true;
+ }
+
+ protected function isThisProcessTestScoped(): bool
+ {
+ return false;
+ }
+
+ protected function registerWithResourcesCleaner(): void
+ {
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Registering with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class) . '...');
+
+ TestCase::assertNotNull(AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort);
+ $resCleanerId = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId;
+ TestCase::assertNotNull($resCleanerId);
+ $response = HttpClientUtilForTests::sendRequest(
+ HttpMethods::POST,
+ new UrlParts(port: AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort, path: ResourcesCleaner::REGISTER_PROCESS_TO_TERMINATE_URI_PATH),
+ new TestInfraDataPerRequest(spawnedProcessInternalId: $resCleanerId),
+ [
+ ResourcesCleaner::PID_QUERY_HEADER_NAME => strval(getmypid()),
+ ResourcesCleaner::IS_TEST_SCOPED_QUERY_HEADER_NAME => BoolUtil::toString($this->isThisProcessTestScoped()),
+ ]
+ );
+ if ($response->getStatusCode() !== HttpStatusCodes::OK) {
+ throw new ComponentTestsInfraException('Failed to register with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class));
+ }
+
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Successfully registered with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class));
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php b/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php
new file mode 100644
index 0000000..373a975
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php
@@ -0,0 +1,51 @@
+clock = AmbientContextForTests::clock();
+ $this->restart();
+ }
+
+ public function elapsedInMicroseconds(): float
+ {
+ return TimeUtil::calcDurationInMicrosecondsClampNegativeToZero($this->timeStarted, $this->clock->getMonotonicClockCurrentTime());
+ }
+
+ public function restart(): void
+ {
+ $this->timeStarted = $this->clock->getMonotonicClockCurrentTime();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php
new file mode 100644
index 0000000..d149add
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php
@@ -0,0 +1,215 @@
+logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+
+ $globalTestInfra = ComponentTestsPHPUnitExtension::getGlobalTestInfra();
+ $globalTestInfra->onTestStart();
+ $this->resourcesCleaner = $globalTestInfra->getResourcesCleaner();
+ $this->MockOTelCollector = $globalTestInfra->getMockOTelCollector();
+ $this->portsInUse = $globalTestInfra->getPortsInUse();
+ }
+
+ /**
+ * @param null|Closure(AppCodeHostParams): void $setParamsFunc
+ */
+ public function ensureMainAppCodeHost(?Closure $setParamsFunc = null, string $dbgInstanceName = 'main'): AppCodeHostHandle
+ {
+ if ($this->mainAppCodeHost === null) {
+ $this->mainAppCodeHost = $this->startAppCodeHost(
+ function (AppCodeHostParams $params) use ($setParamsFunc): void {
+ $this->autoSetProdOptions($params);
+ if ($setParamsFunc !== null) {
+ $setParamsFunc($params);
+ }
+ },
+ $dbgInstanceName
+ );
+ }
+ return $this->mainAppCodeHost;
+ }
+
+ /**
+ * @param null|Closure(HttpAppCodeHostParams): void $setParamsFunc
+ *
+ * @noinspection PhpUnused
+ */
+ public function ensureAdditionalHttpAppCodeHost(string $dbgInstanceName, ?Closure $setParamsFunc = null): HttpAppCodeHostHandle
+ {
+ if ($this->additionalHttpAppCodeHost === null) {
+ $this->additionalHttpAppCodeHost = $this->startBuiltinHttpServerAppCodeHost(
+ function (HttpAppCodeHostParams $appCodeHostParams) use ($setParamsFunc): void {
+ $this->autoSetProdOptions($appCodeHostParams);
+ if ($setParamsFunc !== null) {
+ $setParamsFunc($appCodeHostParams);
+ }
+ },
+ $dbgInstanceName
+ );
+ }
+ return $this->additionalHttpAppCodeHost;
+ }
+
+ public function waitForEnoughExportedData(IsEnoughExportedDataInterface $isEnoughExportedData): ExportedData
+ {
+ Assert::assertNotEmpty($this->appCodeInvocations);
+ $dataAccumulator = new ExportedDataAccumulator();
+ $hasPassed = (new PollingCheck(__FUNCTION__ . ' passes', intval(TimeUtil::secondsToMicroseconds(self::MAX_WAIT_TIME_DATA_FROM_AGENT_SECONDS))))->run(
+ function () use ($isEnoughExportedData, $dataAccumulator) {
+ $dataAccumulator->addAgentToOTeCollectorEvents($this->MockOTelCollector->fetchNewData(shouldWait: true));
+ return $dataAccumulator->isEnough($isEnoughExportedData);
+ }
+ );
+ Assert::assertTrue($hasPassed, 'The expected data from agent has not arrived. ' . LoggableToString::convert(compact('isEnoughExportedData', 'dataAccumulator')));
+
+ return $dataAccumulator->getAccumulatedData();
+ }
+
+ private function autoSetProdOptions(AppCodeHostParams $params): void
+ {
+ if ($this->escalatedLogLevelForProdCode !== null) {
+ $escalatedLogLevelForProdCodeAsString = $this->escalatedLogLevelForProdCode->name;
+ $params->setProdOption(AmbientContextForTests::testConfig()->escalatedRerunsProdCodeLogLevelOptionName() ?? OptionForProdName::log_level_syslog, $escalatedLogLevelForProdCodeAsString);
+ }
+ /** @noinspection HttpUrlsUsage */
+ $params->setProdOption(OptionForProdName::exporter_otlp_endpoint, 'http://' . HttpServerHandle::CLIENT_LOCALHOST_ADDRESS . ':' . $this->MockOTelCollector->getPortForAgent());
+ }
+
+ public function addAppCodeInvocation(AppCodeInvocation $appCodeInvocation): void
+ {
+ $appCodeInvocation->appCodeHostsParams = [];
+ if ($this->mainAppCodeHost !== null) {
+ $appCodeInvocation->appCodeHostsParams[] = $this->mainAppCodeHost->appCodeHostParams;
+ }
+ if ($this->additionalHttpAppCodeHost !== null) {
+ $appCodeInvocation->appCodeHostsParams[] = $this->additionalHttpAppCodeHost->appCodeHostParams;
+ }
+ $this->appCodeInvocations[] = $appCodeInvocation;
+ }
+
+ /**
+ * @return array
+ */
+ public function getProdCodeLogLevels(): array
+ {
+ $result = [];
+ /** @var ?AppCodeHostHandle $appCodeHost */
+ foreach ([$this->mainAppCodeHost, $this->additionalHttpAppCodeHost] as $appCodeHost) {
+ if ($appCodeHost !== null) {
+ $result[] = $appCodeHost->appCodeHostParams->buildProdConfig()->effectiveLogLevel();
+ }
+ }
+ return $result;
+ }
+
+ public function tearDown(): void
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Tearing down...');
+
+ ComponentTestsPHPUnitExtension::getGlobalTestInfra()->onTestEnd();
+ }
+
+ /**
+ * @param int[] $ports
+ *
+ * @return void
+ */
+ private function addPortsInUse(array $ports): void
+ {
+ foreach ($ports as $port) {
+ Assert::assertNotContains($port, $this->portsInUse);
+ $this->portsInUse[] = $port;
+ }
+ }
+
+ private function startBuiltinHttpServerAppCodeHost(Closure $setParamsFunc, string $dbgInstanceName): BuiltinHttpServerAppCodeHostHandle
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Starting built-in HTTP server to host app code ...', compact('dbgInstanceName'));
+
+ $result = new BuiltinHttpServerAppCodeHostHandle($this, $setParamsFunc, $this->resourcesCleaner, $this->portsInUse, $dbgInstanceName);
+ $this->addPortsInUse($result->httpServerHandle->ports);
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Started built-in HTTP server to host app code', ['ports' => $result->httpServerHandle->ports]);
+
+ return $result;
+ }
+
+ /**
+ * @param Closure(AppCodeHostParams): void $setParamsFunc
+ */
+ private function startAppCodeHost(Closure $setParamsFunc, string $dbgInstanceName): AppCodeHostHandle
+ {
+ return match (AmbientContextForTests::testConfig()->appCodeHostKind()) {
+ AppCodeHostKind::cliScript => new CliScriptAppCodeHostHandle($this, $setParamsFunc, $this->resourcesCleaner, $dbgInstanceName),
+ AppCodeHostKind::builtinHttpServer => $this->startBuiltinHttpServerAppCodeHost($setParamsFunc, $dbgInstanceName),
+ };
+ }
+
+ /** @noinspection PhpUnused */
+ public function getResourcesClient(): ResourcesClient
+ {
+ return $this->resourcesCleaner->getClient();
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php
new file mode 100644
index 0000000..a20a1e5
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php
@@ -0,0 +1,39 @@
+ $appCodeArguments
+ */
+ public function __construct(
+ public readonly string $spawnedProcessInternalId,
+ public readonly ?AppCodeTarget $appCodeTarget = null,
+ public ?array $appCodeArguments = null,
+ ) {
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php
new file mode 100644
index 0000000..8bf5c84
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php
@@ -0,0 +1,288 @@
+ $type, 'source code location' => $srcFile . ':' . $srcLine]),
+ code: 0,
+ severity: $type,
+ filename: $srcFile,
+ line: $srcLine
+ );
+ }
+ );
+
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+
+ parent::__construct();
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done');
+ }
+
+ #[Override]
+ protected function processConfig(): void
+ {
+ parent::processConfig();
+
+ Assert::assertCount(
+ $this->expectedPortsCount(),
+ AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts,
+ LoggableToString::convert(AmbientContextForTests::testConfig())
+ );
+
+ // At this point request is not parsed and applied to config yet
+ TestCase::assertNull(AmbientContextForTests::testConfig()->getOptionValueByName(OptionForTestsName::data_per_request));
+ }
+
+ /**
+ * @return int
+ */
+ protected function expectedPortsCount(): int
+ {
+ return 1;
+ }
+
+ protected function onNewConnection(int $socketIndex, ConnectionInterface $connection): void
+ {
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log(
+ 'New connection',
+ [
+ 'socketIndex' => $socketIndex,
+ 'connection addresses' => [
+ 'remote' => $connection->getRemoteAddress(),
+ 'local' => $connection->getLocalAddress(),
+ ]
+ ]
+ );
+ }
+
+ /**
+ * @return null|ResponseInterface|Promise
+ */
+ abstract protected function processRequest(ServerRequestInterface $request): null|ResponseInterface|Promise;
+
+ public static function run(): void
+ {
+ self::runSkeleton(
+ function (SpawnedProcessBase $thisObj): void {
+ /** @var self $thisObj */
+ $thisObj->runImpl();
+ }
+ );
+ }
+
+ public function runImpl(): void
+ {
+ $this->runHttpService();
+ }
+
+ private function runHttpService(): void
+ {
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $ports = AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts;
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Running HTTP service...', compact('ports'));
+
+ $this->reactLoop = Loop::get();
+ TestCase::assertNotEmpty($ports);
+ foreach ($ports as $port) {
+ $uri = HttpServerHandle::SERVER_LOCALHOST_ADDRESS . ':' . $port;
+ $serverSocket = new SocketServer($uri, /* context */ [], $this->reactLoop);
+ $socketIndex = count($this->serverSockets);
+ $this->serverSockets[] = $serverSocket;
+ $serverSocket->on(
+ 'connection' /* <- event */,
+ function (ConnectionInterface $connection) use ($socketIndex): void {
+ $this->onNewConnection($socketIndex, $connection);
+ }
+ );
+ $httpServer = new HttpServer(
+ /**
+ * @return ResponseInterface|Promise
+ */
+ function (ServerRequestInterface $request): ResponseInterface|Promise {
+ return $this->processRequestWrapper($request);
+ }
+ );
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Listening for incoming requests...', ['serverSocket address' => $serverSocket->getAddress()]);
+ $httpServer->listen($serverSocket);
+ }
+
+ $this->beforeLoopRun();
+
+ Assert::assertNotNull($this->reactLoop);
+ $this->reactLoop->run();
+ }
+
+ protected function beforeLoopRun(): void
+ {
+ }
+
+ protected function shouldRequestHaveSpawnedProcessInternalId(ServerRequestInterface $request): bool
+ {
+ return true;
+ }
+
+ /**
+ * @return ResponseInterface|Promise
+ */
+ private function processRequestWrapper(ServerRequestInterface $request): Promise|ResponseInterface
+ {
+ $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Received request', ['URI' => $request->getUri(), 'method' => $request->getMethod(), 'target' => $request->getRequestTarget()]);
+
+ try {
+ $response = $this->processRequestWrapperImpl($request);
+
+ if ($response instanceof ResponseInterface) {
+ $loggerProxyDebug && $loggerProxyDebug->log(
+ __LINE__,
+ 'Sending response ...',
+ ['statusCode' => $response->getStatusCode(), 'reasonPhrase' => $response->getReasonPhrase(), 'body' => $response->getBody()]
+ );
+ } else {
+ Assert::assertInstanceOf(Promise::class, $response); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Promise returned - response will be returned later...');
+ }
+
+ return $response;
+ } catch (Throwable $throwable) {
+ ($loggerProxy = $this->logger->ifCriticalLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('processRequest() exited by exception - terminating this process', compact('throwable'));
+ exit(self::FAILURE_PROCESS_EXIT_CODE);
+ }
+ }
+
+ /**
+ * @return ResponseInterface|Promise
+ */
+ private function processRequestWrapperImpl(ServerRequestInterface $request): Promise|ResponseInterface
+ {
+ if ($this->shouldRequestHaveSpawnedProcessInternalId($request)) {
+ $testConfigForRequest = ConfigUtilForTests::read(
+ new RequestHeadersRawSnapshotSource(
+ function (string $headerName) use ($request): ?string {
+ return self::getRequestHeader($request, $headerName);
+ }
+ ),
+ AmbientContextForTests::loggerFactory()
+ );
+
+ $verifySpawnedProcessInternalIdResponse = self::verifySpawnedProcessInternalId(
+ $testConfigForRequest->dataPerRequest()->spawnedProcessInternalId
+ );
+ if ($verifySpawnedProcessInternalIdResponse !== null) {
+ return $verifySpawnedProcessInternalIdResponse;
+ }
+ }
+
+ if ($request->getUri()->getPath() === HttpServerHandle::STATUS_CHECK_URI_PATH) {
+ return self::buildResponseWithPid();
+ } elseif ($request->getUri()->getPath() === self::EXIT_URI_PATH) {
+ $this->exit();
+ return self::buildDefaultResponse();
+ }
+
+ if (($response = $this->processRequest($request)) !== null) {
+ return $response;
+ }
+
+ return self::buildErrorResponse(
+ HttpStatusCodes::BAD_REQUEST,
+ 'Unknown URI path: `' . $request->getRequestTarget() . '\''
+ );
+ }
+
+ protected function exit(): void
+ {
+ foreach ($this->serverSockets as $serverSocket) {
+ $serverSocket->close();
+ }
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Exiting...');
+ }
+
+ protected static function getRequestHeader(ServerRequestInterface $request, string $headerName): ?string
+ {
+ $headerValues = $request->getHeader($headerName);
+ if (ArrayUtilForTests::isEmpty($headerValues)) {
+ return null;
+ }
+ if (count($headerValues) !== 1) {
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('The header should not have more than one value', compact('headerName', 'headerValues')));
+ }
+ return $headerValues[0];
+ }
+
+ protected static function getRequiredRequestHeader(ServerRequestInterface $request, string $headerName): string
+ {
+ $headerValue = self::getRequestHeader($request, $headerName);
+ if ($headerValue === null) {
+ throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Missing required HTTP request header', compact('headerName')));
+ }
+ return $headerValue;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php
new file mode 100644
index 0000000..edbf439
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php
@@ -0,0 +1,92 @@
+startHttpServer($portsInUse, $portsToAllocateCount);
+ }
+
+ private function __construct(string $dbgServerDesc, string $runScriptName, ?ResourcesCleanerHandle $resourcesCleaner)
+ {
+ parent::__construct($dbgServerDesc);
+
+ $this->runScriptName = $runScriptName;
+ $this->resourcesCleaner = $resourcesCleaner;
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function buildCommandLine(array $ports): string
+ {
+ $runScriptNameFullPath = FileUtil::listToPath([__DIR__, $this->runScriptName]);
+ if (!file_exists($runScriptNameFullPath)) {
+ throw new ConfigException(ExceptionUtil::buildMessage('Run script does not exist', array_merge(['runScriptName' => $this->runScriptName], compact('runScriptNameFullPath'))));
+ }
+
+ return 'php ' . '"' . FileUtil::listToPath([__DIR__, $this->runScriptName]) . '"';
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array
+ {
+ $baseEnvVars = EnvVarUtilForTests::getAll();
+ $additionalEnvVars = [
+ OptionForProdName::autoload_enabled->toEnvVarName() => BoolUtil::toString(false),
+ OptionForProdName::disabled_instrumentations->toEnvVarName() => ConfigUtilForTests::PROD_DISABLED_INSTRUMENTATIONS_ALL,
+ OptionForProdName::enabled->toEnvVarName() => BoolUtil::toString(false),
+ ];
+ ArrayUtilForTests::append(from: $additionalEnvVars, to: $baseEnvVars);
+
+ return InfraUtilForTests::addTestInfraDataPerProcessToEnvVars(
+ $baseEnvVars,
+ $spawnedProcessInternalId,
+ $ports,
+ $this->resourcesCleaner,
+ $this->dbgServerDesc
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php b/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php
new file mode 100644
index 0000000..ceb4eba
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php
@@ -0,0 +1,55 @@
+path
+ . ', '
+ . 'query: ' . $this->query
+ . '}';
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php
new file mode 100644
index 0000000..f6d32c8
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php
@@ -0,0 +1,187 @@
+scheme ?? 'http') . '://';
+ $result .= $urlParts->host ?? 'localhost';
+ if ($urlParts->port !== null) {
+ $result .= ':' . $urlParts->port;
+ }
+ return $result;
+ }
+
+ public static function normalizeUrlPath(string $urlPath): string
+ {
+ return TextUtil::isPrefixOf('/', $urlPath) ? $urlPath : ('/' . $urlPath);
+ }
+
+ public static function buildRequestMethodArg(UrlParts $urlParts): string
+ {
+ $result = $urlParts->path === null
+ ? '/'
+ : self::normalizeUrlPath($urlParts->path);
+
+ if ($urlParts->query !== null) {
+ $result .= '?' . $urlParts->query;
+ }
+
+ return $result;
+ }
+
+ public static function buildFullUrl(UrlParts $urlParts): string
+ {
+ return self::buildRequestBaseUrl($urlParts) . self::buildRequestMethodArg($urlParts);
+ }
+
+ public static function buildUrlPartsWithDefaults(
+ string $scheme = HttpSchemes::HTTP,
+ string $host = HttpServerHandle::CLIENT_LOCALHOST_ADDRESS,
+ ?int $port = null,
+ string $path = HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_URL_PATH,
+ ?string $query = null,
+ ): UrlParts {
+ return new UrlParts(scheme: $scheme, host: $host, port: $port, path: $path, query: $query);
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php b/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php
new file mode 100644
index 0000000..aaaa18f
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php
@@ -0,0 +1,77 @@
+minSpanCount = $min;
+ $result->maxSpanCount = $max ?? $min;
+
+ return $result;
+ }
+
+ private function __construct()
+ {
+ $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this'));
+ }
+
+ public function isEnough(array $spans): bool
+ {
+ $spansCount = count($spans);
+ Assert::assertLessThanOrEqual($this->maxSpanCount, $spansCount);
+
+ $result = $spansCount >= $this->minSpanCount;
+
+ ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+ && $loggerProxy->log('Checked if exported data events counts reached the waited for values', compact('result', 'spansCount', 'this'));
+
+ return $result;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php b/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php
new file mode 100644
index 0000000..5a53ecc
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php
@@ -0,0 +1,42 @@
+wrappedException = $wrappedException;
+ }
+
+ public function wrappedException(): Throwable
+ {
+ return$this->wrappedException;
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php
new file mode 100644
index 0000000..205b434
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php
@@ -0,0 +1,28 @@
+
+ */
+ public static function dataProviderForTestRunAndEscalateLogLevelOnFailure(): iterable
+ {
+ $initialLogLevels = [LogLevel::info, LogLevel::trace, LogLevel::debug];
+
+ return self::adaptDataProviderForTestBuilderToSmokeToDescToMixedMap(
+ (new DataProviderForTestBuilder())
+ ->addKeyedDimensionOnlyFirstValueCombinable(self::LOG_LEVEL_FOR_PROD_CODE_KEY, $initialLogLevels)
+ ->addKeyedDimensionOnlyFirstValueCombinable(self::LOG_LEVEL_FOR_TEST_CODE_KEY, $initialLogLevels)
+ ->addKeyedDimensionOnlyFirstValueCombinable(self::FAIL_ON_RERUN_COUNT_KEY, [1, 2, 3])
+ ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_FAIL_KEY)
+ ->addKeyedDimensionOnlyFirstValueCombinable(OptionForTestsName::escalated_reruns_max_count->name, [2, 0])
+ );
+ }
+
+ private static function buildFailMessage(int $runCount): string
+ {
+ return 'Dummy failed; run count: ' . $runCount;
+ }
+
+ public static function appCodeForTestRunAndEscalateLogLevelOnFailure(MixedMap $appCodeArgs): void
+ {
+ self::appCodeSetsHowFinishedAttributes(
+ $appCodeArgs,
+ function () use ($appCodeArgs): void {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $dbgCtx->add(compact('appCodeArgs'));
+ $dbgCtx->add(['testConfig' => AmbientContextForTests::testConfig()]);
+ $expectedLogLevelForProdCode = $appCodeArgs->getLogLevel(self::LOG_LEVEL_FOR_PROD_CODE_KEY);
+ $dbgCtx->add(compact('expectedLogLevelForProdCode'));
+ $prodConfig = self::buildProdConfigFromAppCode();
+ $dbgCtx->add(compact('prodConfig'));
+ $actualLogLevelForProdCode = $prodConfig->effectiveLogLevel();
+ $dbgCtx->add(compact('actualLogLevelForProdCode'));
+ self::assertSame($expectedLogLevelForProdCode, $actualLogLevelForProdCode);
+ $expectedLogLevelForTestCode = $appCodeArgs->getLogLevel(self::LOG_LEVEL_FOR_TEST_CODE_KEY);
+ $dbgCtx->add(compact('expectedLogLevelForTestCode'));
+ $actualLogLevelForTestCode = AmbientContextForTests::testConfig()->logLevel;
+ $dbgCtx->add(compact('actualLogLevelForTestCode'));
+ self::assertSame($expectedLogLevelForTestCode, $actualLogLevelForTestCode);
+ }
+ );
+ }
+
+ public function test0WithoutEscalation(): void
+ {
+ $testCaseHandle = $this->getTestCaseHandle();
+ $appCodeHost = $testCaseHandle->ensureMainAppCodeHost();
+ $appCodeHost->execAppCode(
+ AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestRunAndEscalateLogLevelOnFailure']),
+ function (AppCodeRequestParams $appCodeRequestParams) use ($testCaseHandle): void {
+ /** @var array $appCodeArgs */
+ $appCodeArgs = [
+ self::LOG_LEVEL_FOR_PROD_CODE_KEY => ArrayUtilForTests::getSingleValue($testCaseHandle->getProdCodeLogLevels()),
+ self::LOG_LEVEL_FOR_TEST_CODE_KEY => AmbientContextForTests::testConfig()->logLevel,
+ ];
+ $appCodeRequestParams->setAppCodeArgs($appCodeArgs);
+ }
+ );
+
+ $span = $this->waitForOneSpan($testCaseHandle);
+ self::assertTrue($span->attributes->getBool(self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY));
+ }
+
+ /**
+ * @return array
+ */
+ private static function unsetLogLevelRelatedEnvVars(): array
+ {
+ $envVars = EnvVarUtilForTests::getAll();
+ $logLevelRelatedEnvVarsToRestore = [];
+ foreach (OptionForProdName::getAllLogLevelRelated() as $optName) {
+ $envVarName = $optName->toEnvVarName();
+ if (array_key_exists($envVarName, $envVars)) {
+ $logLevelRelatedEnvVarsToRestore[$envVarName] = $envVars[$envVarName];
+ EnvVarUtilForTests::unset($envVarName);
+ } else {
+ $logLevelRelatedEnvVarsToRestore[$envVarName] = null;
+ }
+
+ self::assertNull(EnvVarUtilForTests::get($envVarName));
+ }
+ return $logLevelRelatedEnvVarsToRestore;
+ }
+
+ /**
+ * @dataProvider dataProviderForTestRunAndEscalateLogLevelOnFailure
+ */
+ public function testRunAndEscalateLogLevelOnFailure(MixedMap $testArgs): void
+ {
+ $logLevelRelatedEnvVarsToRestore = self::unsetLogLevelRelatedEnvVars();
+ $prodCodeSyslogLevelEnvVarName = OptionForProdName::log_level_syslog->toEnvVarName();
+ $initialLogLevelForProdCode = $testArgs->getLogLevel(self::LOG_LEVEL_FOR_PROD_CODE_KEY);
+ EnvVarUtilForTests::set($prodCodeSyslogLevelEnvVarName, $initialLogLevelForProdCode->name);
+
+ $logLevelForTestCodeToRestore = AmbientContextForTests::testConfig()->logLevel;
+ $initialLogLevelForTestCode = $testArgs->getLogLevel(self::LOG_LEVEL_FOR_TEST_CODE_KEY);
+ AmbientContextForTests::resetLogLevel($initialLogLevelForTestCode);
+
+ $rerunsMaxCountToRestore = AmbientContextForTests::testConfig()->escalatedRerunsMaxCount;
+ $rerunsMaxCount = $testArgs->getInt(OptionForTestsName::escalated_reruns_max_count->name);
+ AmbientContextForTests::resetEscalatedRerunsMaxCount($rerunsMaxCount);
+
+ $initialLevels = [];
+ foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) {
+ $initialLevels[$levelTypeKey] = $testArgs->getLogLevel($levelTypeKey);
+ }
+ $testArgs[self::INITIAL_LOG_LEVELS_KEY] = $initialLevels;
+ $expectedEscalatedLevelsSeqCount = IterableUtil::count(self::generateLevelsForRunAndEscalateLogLevelOnFailure($initialLevels, $rerunsMaxCount));
+ if ($rerunsMaxCount === 0) {
+ self::assertSame(0, $expectedEscalatedLevelsSeqCount);
+ }
+ $failOnRerunCountArg = $testArgs->getInt(self::FAIL_ON_RERUN_COUNT_KEY);
+ $expectedFailOnRunCount = $failOnRerunCountArg <= $expectedEscalatedLevelsSeqCount ? ($failOnRerunCountArg + 1) : 1;
+ $expectedMessage = self::buildFailMessage($expectedFailOnRunCount);
+ $shouldFail = $testArgs->getBool(self::SHOULD_FAIL_KEY);
+
+ $nextRunCount = 1;
+ try {
+ self::runAndEscalateLogLevelOnFailure(
+ self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs),
+ function () use ($testArgs, &$nextRunCount): void {
+ $testArgs['currentRunCount'] = $nextRunCount++;
+ $this->implTestRunAndEscalateLogLevelOnFailure($testArgs);
+ }
+ );
+ $runAndEscalateLogLevelOnFailureExitedNormally = true;
+ } catch (PHPUnitFrameworkException $ex) {
+ $runAndEscalateLogLevelOnFailureExitedNormally = false;
+ self::assertStringContainsString($expectedMessage, $ex->getMessage());
+ }
+ self::assertSame(!$shouldFail, $runAndEscalateLogLevelOnFailureExitedNormally);
+
+ self::assertSame($rerunsMaxCount, AmbientContextForTests::testConfig()->escalatedRerunsMaxCount);
+ AmbientContextForTests::resetEscalatedRerunsMaxCount($rerunsMaxCountToRestore);
+
+ self::assertSame($initialLogLevelForTestCode, AmbientContextForTests::testConfig()->logLevel);
+ AmbientContextForTests::resetLogLevel($logLevelForTestCodeToRestore);
+
+ self::assertSame($initialLogLevelForProdCode->name, EnvVarUtilForTests::get($prodCodeSyslogLevelEnvVarName));
+ foreach ($logLevelRelatedEnvVarsToRestore as $envVarName => $envVarValue) {
+ EnvVarUtilForTests::setOrUnset($envVarName, $envVarValue);
+ }
+ }
+
+ private function implTestRunAndEscalateLogLevelOnFailure(MixedMap $testArgs): void
+ {
+ $currentRunCount = $testArgs->getInt('currentRunCount');
+ self::assertGreaterThanOrEqual(1, $currentRunCount);
+ $currentReRunCount = $currentRunCount === 1 ? 0 : ($currentRunCount - 1);
+ $shouldFail = $testArgs->getBool(self::SHOULD_FAIL_KEY);
+ $failOnRerunCountArg = $testArgs->getInt(self::FAIL_ON_RERUN_COUNT_KEY);
+ /** @var array $initialLevels */
+ $initialLevels = $testArgs->getArray(self::INITIAL_LOG_LEVELS_KEY);
+ $shouldCurrentRunFail = $shouldFail && ($currentRunCount === 1 || $currentReRunCount === $failOnRerunCountArg);
+ if ($currentRunCount === 1) {
+ $expectedLevels = $initialLevels;
+ } else {
+ $rerunsMaxCount = $testArgs->getInt(OptionForTestsName::escalated_reruns_max_count->name);
+ self::assertTrue(
+ IterableUtil::getNthValue(
+ self::generateLevelsForRunAndEscalateLogLevelOnFailure($initialLevels, $rerunsMaxCount),
+ $currentReRunCount - 1,
+ $expectedLevels /* <- out */
+ )
+ );
+ }
+ /** @var array $expectedLevels */
+
+ $testCaseHandle = $this->getTestCaseHandle();
+ $appCodeHost = $testCaseHandle->ensureMainAppCodeHost();
+ $appCodeHost->execAppCode(
+ AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestRunAndEscalateLogLevelOnFailure']),
+ function (AppCodeRequestParams $appCodeRequestParams) use ($expectedLevels): void {
+ /** @var array $appCodeArgs */
+ $appCodeArgs = [];
+ foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) {
+ $appCodeArgs[$levelTypeKey] = $expectedLevels[$levelTypeKey];
+ }
+ $appCodeRequestParams->setAppCodeArgs($appCodeArgs);
+ }
+ );
+
+ $span = $this->waitForOneSpan($testCaseHandle);
+ self::assertTrue($span->attributes->getBool(self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY));
+
+ if ($shouldCurrentRunFail) {
+ self::fail(self::buildFailMessage($currentRunCount));
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php b/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php
new file mode 100644
index 0000000..f70f3ee
--- /dev/null
+++ b/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php
@@ -0,0 +1,30 @@
+ */
+ private array $optNameToRawValue = [];
+
+ public function set(string $optName, string $optVal): self
+ {
+ $this->optNameToRawValue[$optName] = $optVal;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface
+ {
+ return new RawSnapshotFromArray($this->optNameToRawValue);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php
new file mode 100644
index 0000000..c192b10
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php
@@ -0,0 +1,53 @@
+consumed[] = new MockLogPreformattedSinkStatement(
+ $statementLevel,
+ $category,
+ $srcCodeFile,
+ $srcCodeLine,
+ $srcCodeFunc,
+ $messageWithContext
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php
new file mode 100644
index 0000000..a5e7fa8
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php
@@ -0,0 +1,39 @@
+ $context
+ */
+ public function __construct(
+ public LogLevel $statementLevel,
+ public string $message,
+ public array $context,
+ public string $category,
+ public string $srcCodeFile,
+ public int $srcCodeLine,
+ public string $srcCodeFunc,
+ public ?bool $includeStacktrace,
+ public int $numberOfStackFramesToSkip
+ ) {
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php b/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php
new file mode 100644
index 0000000..b4305c8
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php
@@ -0,0 +1,33 @@
+, 'n': non-negative-int}
+ * @phpstan-type TestPopNArgs array{'input': TestPopNInput, 'expectedOutput': array}
+ */
+final class ArrayUtilTest extends TestCaseBase
+{
+ use DisableDebugContextTestTrait;
+
+ /**
+ * @param mixed[] $args
+ *
+ * @return void
+ */
+ private static function verifyArgs(array $args): void
+ {
+ self::assertCount(1, $args);
+ $arg0 = $args[0];
+ self::assertIsString($arg0);
+ }
+
+ /**
+ * @param mixed[] $args
+ *
+ * @return void
+ */
+ private static function instrumentationFunc(array $args): void
+ {
+ self::assertCount(1, $args);
+ self::verifyArgs($args);
+ $someParam =& $args[0];
+ self::assertSame('value set by instrumentedFunc caller', $someParam);
+ $someParam = 'value set by instrumentationFunc';
+ }
+
+ private static function instrumentedFunc(string $someParam): string
+ {
+ self::instrumentationFunc([&$someParam]);
+ return $someParam;
+ }
+
+ public static function testReferencesInArray(): void
+ {
+ $instrumentedFuncRetVal = self::instrumentedFunc('value set by instrumentedFunc caller');
+ self::assertSame('value set by instrumentationFunc', $instrumentedFuncRetVal);
+ }
+
+ public static function testRemoveElementFromTwoLevelArrayViaReferenceToFirstLevel(): void
+ {
+ $myArr = [
+ 'level 1 - a' => [
+ 'level 2 - a' => 'value for level 2 - a',
+ 'level 2 - b' => 'value for level 2 - b',
+ ]
+ ];
+ $level1ValRef =& $myArr['level 1 - a'];
+ self::assertArrayHasKey('level 2 - a', $level1ValRef); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ self::assertSame('value for level 2 - a', $level1ValRef['level 2 - a']); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ unset($level1ValRef['level 2 - a']);
+ self::assertArrayNotHasKey('level 2 - a', $myArr['level 1 - a']);
+ self::assertArrayHasKey('level 2 - b', $myArr['level 1 - a']); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ }
+
+ public static function testRemoveFirstByValue(): void
+ {
+ /**
+ * @param list $inArray
+ * @param ?list $expectedOutArray
+ */
+ $testImpl = function (array $inArray, mixed $valueToRemove, ?array $expectedOutArray = null): void {
+ if ($expectedOutArray !== null) {
+ self::assertCount(count($inArray) - 1, $expectedOutArray);
+ }
+ $actualOutArray = $inArray;
+ self::assertSame($expectedOutArray !== null, ArrayUtilForTests::removeFirstByValue(/* in,out */ $actualOutArray, $valueToRemove));
+ if ($expectedOutArray === null) {
+ self::assertSame($inArray, $actualOutArray);
+ } else {
+ self::assertNotSame($inArray, $actualOutArray);
+ $actualOutArrayIndexesFixed = [...$actualOutArray];
+ AssertEx::arraysHaveTheSameContent($expectedOutArray, $actualOutArrayIndexesFixed);
+ }
+ };
+
+ $testImpl([], 'a');
+ $testImpl(['a'], 'a', []);
+ $testImpl(['a', 'b'], 'b', ['a']);
+ $testImpl(['a', 'b', 'c'], 'b', ['a', 'c']);
+
+ // The search is for identical value (i.e., using ===)
+ $testImpl(['1'], 1);
+ $testImpl(['1'], '1', []);
+ }
+
+ public static function testRemoveAllValues(): void
+ {
+ /**
+ * @param list $inArray
+ * @param list $valuesToRemove
+ * @param ?list $expectedOutArray
+ *
+ * @return void
+ */
+ $testImpl = function (array $inArray, array $valuesToRemove, ?array $expectedOutArray = null): void {
+ if ($expectedOutArray !== null) {
+ AssertEx::countAtMost(count($inArray) - 1, $expectedOutArray);
+ self::assertNotEmpty($valuesToRemove);
+ }
+ $expectedRemovedCount = $expectedOutArray === null ? 0 : (count($inArray) - count($expectedOutArray));
+ $actualOutArray = $inArray;
+ self::assertSame($expectedRemovedCount, ArrayUtilForTests::removeAllValues(/* in,out */ $actualOutArray, $valuesToRemove));
+ if ($expectedOutArray === null) {
+ self::assertSame($inArray, $actualOutArray);
+ } else {
+ self::assertNotSame($inArray, $actualOutArray);
+ $actualOutArrayIndexesFixed = [...$actualOutArray];
+ AssertEx::arraysHaveTheSameContent($expectedOutArray, $actualOutArrayIndexesFixed);
+ }
+ };
+
+ $testImpl([], []);
+ $testImpl([], ['a']);
+
+ $testImpl(['a'], ['a'], []);
+ $testImpl(['a', 'b'], ['b'], ['a']);
+ $testImpl(['a', 'b', 'c'], ['b'], ['a', 'c']);
+
+ $testImpl(['a', 'b', 'c'], ['c', 'b'], ['a']);
+ $testImpl(['a', 'b', 'c', 'a'], ['c', 'a'], ['b']);
+
+ // The search is for identical value (i.e., using ===)
+ $testImpl(['1'], [1]);
+ $testImpl(['1'], ['1'], []);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestIterateListInReverse(): iterable
+ {
+ yield [[], []];
+ yield [[1], [1]];
+ yield [['a'], ['a']];
+ yield [['a', 'b'], ['b', 'a']];
+ yield [[1, 2], [2, 1]];
+ yield [['a', 2, 'c'], ['c', 2, 'a']];
+ yield [[1, 'b', 3], [3, 'b', 1]];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestIterateListInReverse
+ *
+ * @param mixed[] $input
+ * @param mixed[] $expectedOutput
+ */
+ public static function testIterateListInReverse(array $input, array $expectedOutput): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $actualOutput = IterableUtil::toList(ArrayUtilForTests::iterateListInReverse($input));
+ $dbgCtx->add(compact('actualOutput'));
+ self::assertCount(count($input), $actualOutput);
+ foreach (IterableUtil::zip($expectedOutput, $actualOutput) as [$expectedValue, $actualValue]) {
+ $dbgCtx->add(compact('expectedValue', 'actualValue'));
+ self::assertSame($expectedValue, $actualValue);
+ }
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function dataProviderForTestIterateMapInReverse(): iterable
+ {
+ yield [[], []];
+ yield [['a' => 1], ['a' => 1]];
+ yield [[1 => 'a'], [1 => 'a']];
+ yield [['a' => 1, 'b' => 2], ['b' => 2, 'a' => 1]];
+ yield [[1 => 'a', 2 => 'b'], [2 => 'b', 1 => 'a']];
+ yield [['a' => 1, 2 => 'b', 'c' => 3], ['c' => 3, 2 => 'b', 'a' => 1]];
+ yield [[1 => 'a', 'b' => 2, 3 => 'c'], [3 => 'c', 'b' => 2, 1 => 'a']];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestIterateMapInReverse
+ *
+ * @param array $input
+ * @param array $expectedOutput
+ */
+ public static function testIterateMapInReverse(array $input, array $expectedOutput): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $actualOutput = IterableUtil::toMap(ArrayUtilForTests::iterateMapInReverse($input));
+ $dbgCtx->add(compact('actualOutput'));
+ self::assertCount(count($input), $actualOutput);
+ foreach (IterableUtil::zip($expectedOutput, $actualOutput) as [$expectedValue, $actualValue]) {
+ $dbgCtx->add(compact('expectedValue', 'actualValue'));
+ self::assertSame($expectedValue, $actualValue);
+ }
+ }
+
+ /**
+ * @template T
+ *
+ * @param array $input
+ * @param non-negative-int $numberOfElementsToPop
+ *
+ * @return array
+ */
+ private static function popNOnCopy(array $input, int $numberOfElementsToPop): array
+ {
+ ArrayUtilForTests::popN(/* in,out */ $input, $numberOfElementsToPop);
+ return $input;
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestPopNForValidInput(): iterable
+ {
+ /**
+ * @return iterable
+ */
+ $genDataSets = function (): iterable {
+ yield ['input' => ['array' => [], 'n' => 0], 'expectedOutput' => []];
+ yield ['input' => ['array' => ['a'], 'n' => 1], 'expectedOutput' => []];
+ yield ['input' => ['array' => ['a', 'b'], 'n' => 2], 'expectedOutput' => []];
+ yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 3], 'expectedOutput' => []];
+
+ yield ['input' => ['array' => ['a'], 'n' => 0], 'expectedOutput' => ['a']];
+ yield ['input' => ['array' => ['a', 'b'], 'n' => 0], 'expectedOutput' => ['a', 'b']];
+ yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 0], 'expectedOutput' => ['a', 'b', 'c']];
+
+ yield ['input' => ['array' => ['a', 'b'], 'n' => 1], 'expectedOutput' => ['a']];
+
+ yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 1], 'expectedOutput' => ['a', 'b']];
+ yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 2], 'expectedOutput' => ['a']];
+ };
+
+ return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestPopNForValidInput
+ *
+ * @param TestPopNInput $input
+ * @param array $expectedOutput
+ */
+ public static function testPopNForValidInput(array $input, array $expectedOutput): void
+ {
+ AssertEx::sameValuesListIterables($expectedOutput, self::popNOnCopy($input['array'], $input['n']));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestPopNForInvalidInput(): iterable
+ {
+ yield [fn() => self::popNOnCopy([], 1)];
+ yield [fn() => self::popNOnCopy(['a'], 2)];
+ yield [fn() => self::popNOnCopy(['a', 'b'], 3)];
+ yield [fn() => self::popNOnCopy(['a', 'b', 'c'], 4)];
+ yield [fn() => self::popNOnCopy(['a', 'b', 'c'], 5)];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestPopNForInvalidInput
+ *
+ * @param callable(): mixed $callThatThrows
+ */
+ public static function testPopNForInvalidInput(callable $callThatThrows): void
+ {
+ AssertEx::throws(OutOfBoundsException::class, $callThatThrows);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php
new file mode 100644
index 0000000..743f869
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php
@@ -0,0 +1,58 @@
+>
+ */
+ public static function dataProviderForSplitFqClassName(): array
+ {
+ return [
+ ['My\\Name\\Space\\MyClass', 'My\\Name\\Space', 'MyClass'],
+ ['\\My\\Name\\Space\\MyClass', 'My\\Name\\Space', 'MyClass'],
+ ['\\MyNameSpace\\MyClass', 'MyNameSpace', 'MyClass'],
+ ['MyNameSpace\\MyClass', 'MyNameSpace', 'MyClass'],
+ ['\\MyClass', '', 'MyClass'],
+ ['MyClass', '', 'MyClass'],
+ ['MyNameSpace\\', 'MyNameSpace', ''],
+ ['\\MyNameSpace\\', 'MyNameSpace', ''],
+ ['', '', ''],
+ ['\\', '', ''],
+ ['a\\', 'a', ''],
+ ['\\a\\', 'a', ''],
+ ['\\b', '', 'b'],
+ ['\\\\', '', ''],
+ ['\\\\\\', '\\', ''],
+ ];
+ }
+
+ /**
+ * @dataProvider dataProviderForSplitFqClassName
+ *
+ * @param string $fqClassName
+ * @param string $expectedNamespace
+ * @param string $expectedShortName
+ */
+ public function testSplitFqClassName(
+ string $fqClassName,
+ string $expectedNamespace,
+ string $expectedShortName
+ ): void {
+ /** @var class-string $fqClassName */
+ $actualNamespace = '';
+ $actualShortName = '';
+ ClassNameUtil::splitFqClassName($fqClassName, /* ref */ $actualNamespace, /* ref */ $actualShortName);
+ self::assertSame($expectedNamespace, $actualNamespace);
+ self::assertSame($expectedShortName, $actualShortName);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php
new file mode 100644
index 0000000..ea95891
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php
@@ -0,0 +1,122 @@
+ $totalSet
+ * @param int $subSetSize
+ *
+ * @return array>
+ */
+ $permutationsAsArray = function (array $totalSet, int $subSetSize): array {
+ return IterableUtil::toList(CombinatorialUtil::permutations($totalSet, $subSetSize));
+ };
+
+ self::assertEqualsCanonicalizing([[]], $permutationsAsArray([], 0));
+ self::assertEqualsCanonicalizing([[]], $permutationsAsArray(['a'], 0));
+ self::assertEqualsCanonicalizing([[]], $permutationsAsArray(['a', 'b'], 0));
+ self::assertEqualsCanonicalizing([['a']], $permutationsAsArray(['a'], 1));
+ self::assertEqualsCanonicalizing([['a'], ['b']], $permutationsAsArray(['a', 'b'], 1));
+ self::assertEqualsCanonicalizing([['a', 'b'], ['b', 'a']], $permutationsAsArray(['a', 'b'], 2));
+ self::assertEqualsCanonicalizing([['a'], ['b'], ['c']], $permutationsAsArray(['a', 'b', 'c'], 1));
+ self::assertEqualsCanonicalizing(
+ [
+ ['a', 'b'],
+ ['a', 'c'],
+ ['b', 'a'],
+ ['b', 'c'],
+ ['c', 'a'],
+ ['c', 'b'],
+ ],
+ $permutationsAsArray(['a', 'b', 'c'], 2)
+ );
+ self::assertEqualsCanonicalizing(
+ [
+ ['a', 'b', 'c'],
+ ['a', 'c', 'b'],
+ ['b', 'a', 'c'],
+ ['b', 'c', 'a'],
+ ['c', 'a', 'b'],
+ ['c', 'b', 'a'],
+ ],
+ $permutationsAsArray(['a', 'b', 'c'], 3)
+ );
+ }
+
+ public function testCartesianProduct(): void
+ {
+ /**
+ * @param array> $iterables
+ *
+ * @return array>
+ */
+ $cartesianProductAsArray = function (array $iterables): array {
+ /** @var array> $iterables */
+ return IterableUtil::toList(CombinatorialUtil::cartesianProduct($iterables));
+ };
+
+ self::assertEqualsCanonicalizing([[]], $cartesianProductAsArray([]));
+
+ self::assertEqualsCanonicalizing(
+ [
+ ['digit' => 1],
+ ['digit' => 2],
+ ['digit' => 3],
+ ],
+ $cartesianProductAsArray(['digit' => [1, 2, 3]])
+ );
+
+ self::assertEqualsCanonicalizing(
+ [
+ ['digit' => 1, 'letter' => 'a'],
+ ['digit' => 1, 'letter' => 'b'],
+ ['digit' => 2, 'letter' => 'a'],
+ ['digit' => 2, 'letter' => 'b'],
+ ['digit' => 3, 'letter' => 'a'],
+ ['digit' => 3, 'letter' => 'b'],
+ ],
+ $cartesianProductAsArray(['digit' => [1, 2, 3], 'letter' => ['a', 'b']])
+ );
+
+ self::assertEqualsCanonicalizing(
+ [
+ [1, 'a'],
+ [1, 'b'],
+ [2, 'a'],
+ [2, 'b'],
+ [3, 'a'],
+ [3, 'b'],
+ ],
+ $cartesianProductAsArray([[1, 2, 3], ['a', 'b']])
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php
new file mode 100644
index 0000000..8efa051
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php
@@ -0,0 +1,211 @@
+
+ */
+ public static function dataProviderForTestInheritedEnvVarsAutoPass(): iterable
+ {
+ /**
+ * @return iterable
+ */
+ $genInheritedEnvVarsVariants = function (): iterable {
+ yield 'Unrelated to OTel/EDOT' => ['COMPOSER_BINARY', 'SHELL', 'XDG_SESSION_TYPE'];
+
+ yield 'All options for tests' => array_map(fn($optName) => $optName->name, OptionForTestsName::cases());
+
+ yield 'DEV_INTERNAL_MODE_IS_DEV' => [PhpPartFacade::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV];
+ };
+
+ $allProdOptionNames = OptionForProdName::cases();
+ foreach ($genInheritedEnvVarsVariants() as $inheritedEnvVarNamesDesc => $inheritedEnvVarNames) {
+ foreach (['All' => $allProdOptionNames, 'None' => []] as $prodOptionNamesDesc => $prodOptionNames) {
+ yield ('Inherited env vars: ' . $inheritedEnvVarNamesDesc . ', Options for production: ' . $prodOptionNamesDesc) => compact('inheritedEnvVarNames', 'prodOptionNames');
+ }
+ }
+
+ // Inherited Log related (but not log related) options for production should be automatically passed through
+ // except for log level related options which should be automatically passed through if and only if none of log level related production options is set
+ foreach (OptionForProdName::getAllLogRelated() as $optName) {
+ $inheritedEnvVarNames = [$optName->toEnvVarName()];
+ $prodOptionNamesAllExceptSome = $allProdOptionNames;
+ if ($optName->isLogLevelRelated()) {
+ foreach (OptionForProdName::getAllLogLevelRelated() as $currentProdOptName) {
+ self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $prodOptionNamesAllExceptSome, $currentProdOptName));
+ }
+ } else {
+ self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $prodOptionNamesAllExceptSome, $optName));
+ }
+ foreach (['All except some' => $prodOptionNamesAllExceptSome, 'None' => []] as $prodOptionNamesDesc => $prodOptionNames) {
+ yield ('Inherited env vars: ' . $optName->toEnvVarName() . ', Options for production: ' . $prodOptionNamesDesc) => compact('inheritedEnvVarNames', 'prodOptionNames');
+ }
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestInheritedEnvVarsAutoPass
+ *
+ * @param string[] $inheritedEnvVarNames
+ * @param OptionForProdName[] $prodOptionNames
+ */
+ public static function testInheritedEnvVarsAutoPass(array $inheritedEnvVarNames, array $prodOptionNames): void
+ {
+ $expectedBuiltEnvVars = [];
+ /** @var OptionsForProdMap $prodOptions */
+ $prodOptions = new Map();
+ foreach ($prodOptionNames as $prodOptName) {
+ $prodOptValue = 'value for production option ' . $prodOptName->name;
+ $prodOptions->put($prodOptName, $prodOptValue);
+ ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), $prodOptValue, /* in,out */ $expectedBuiltEnvVars);
+ }
+
+ $inheritedEnvVars = [];
+ foreach ($inheritedEnvVarNames as $inheritedEnvVarName) {
+ $inheritedEnvVarValue = 'value for inherited env var ' . $inheritedEnvVarName;
+ ArrayUtilForTests::addAssertingKeyNew($inheritedEnvVarName, $inheritedEnvVarValue, /* in,out */ $inheritedEnvVars);
+ ArrayUtilForTests::addAssertingKeyNew($inheritedEnvVarName, $inheritedEnvVarValue, /* in,out */ $expectedBuiltEnvVars);
+ }
+
+ self::buildAndAssertAsExpected($inheritedEnvVars, $prodOptions, $expectedBuiltEnvVars);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestLogLevelRelatedProdOverridesInheritedEnvVars(): iterable
+ {
+ foreach (OptionForProdName::getAllLogLevelRelated() as $prodOptName) {
+ yield $prodOptName->name => [$prodOptName];
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestLogLevelRelatedProdOverridesInheritedEnvVars
+ */
+ public static function testLogLevelRelatedProdOptionOverridesInheritedEnvVars(OptionForProdName $prodOptName): void
+ {
+ $inheritedEnvVars = [];
+ foreach (OptionForProdName::getAllLogLevelRelated() as $currentLogLevelRelatedProdOptName) {
+ $inheritedEnvVarValue = 'value for inherited env var ' . $currentLogLevelRelatedProdOptName->toEnvVarName();
+ ArrayUtilForTests::addAssertingKeyNew($currentLogLevelRelatedProdOptName->toEnvVarName(), $inheritedEnvVarValue, /* in,out */ $inheritedEnvVars);
+ }
+
+ $prodOptValue = 'value for production option ' . $prodOptName->name;
+ /** @var OptionsForProdMap $prodOptions */
+ $prodOptions = new Map();
+ $prodOptions->put($prodOptName, $prodOptValue);
+ $expectedBuiltEnvVars = [$prodOptName->toEnvVarName() => $prodOptValue];
+
+ self::buildAndAssertAsExpected($inheritedEnvVars, $prodOptions, $expectedBuiltEnvVars);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestInheritedEnvVarForProdOptionAllowedViaPassThrough(): iterable
+ {
+ foreach (OptionForProdName::cases() as $prodOptName) {
+ if (!$prodOptName->isLogRelated()) {
+ yield $prodOptName->name => [[$prodOptName]];
+ foreach (OptionForProdName::cases() as $secondProdOptName) {
+ if (!$secondProdOptName->isLogRelated() && $secondProdOptName !== $prodOptName) {
+ yield "[$prodOptName->name, $secondProdOptName->name]" => [[$prodOptName, $secondProdOptName]];
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestInheritedEnvVarForProdOptionAllowedViaPassThrough
+ *
+ * @param OptionForProdName[] $prodOptNames
+ */
+ public static function testInheritedEnvVarForProdOptionAllowedViaPassThrough(array $prodOptNames): void
+ {
+ /** @var OptionsForProdMap $emptyProdOptions */
+ $emptyProdOptions = new Map();
+
+ $inheritedEnvVars = [];
+ foreach ($prodOptNames as $prodOptName) {
+ ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), 'value for inherited env var ' . $prodOptName->toEnvVarName(), /* in,out */ $inheritedEnvVars);
+ }
+
+ // Without pass-though option inherited env var for production option (unless it's log related) should not be passed to app code
+ self::buildAndAssertAsExpected($inheritedEnvVars, $emptyProdOptions, expectedBuiltEnvVars: []);
+
+ $envVarNamesToPassThrough = [];
+ $expectedBuiltEnvVars = [];
+ foreach ($prodOptNames as $prodOptName) {
+ $envVarNamesToPassThrough[] = $prodOptName->toEnvVarName();
+ $passThroughOptEnvVarName = OptionForTestsName::env_vars_to_pass_through->toEnvVarName();
+ $passThroughOptValue = join(',', $envVarNamesToPassThrough);
+ $inheritedEnvVars[$passThroughOptEnvVarName] = $passThroughOptValue;
+ $expectedBuiltEnvVars[$passThroughOptEnvVarName] = $passThroughOptValue;
+ ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), $inheritedEnvVars[$prodOptName->toEnvVarName()], /* in,out */ $expectedBuiltEnvVars);
+
+ $passThroughOptEnvVarValueToRestore = EnvVarUtil::get($passThroughOptEnvVarName);
+ try {
+ // Set env var the pass-through option for this process and recompute AmbientContextForTests::testConfig
+ // because tested code depends on AmbientContextForTests::testConfig
+ EnvVarUtil::set($passThroughOptEnvVarName, $passThroughOptValue);
+ AmbientContextForTests::reconfigure();
+ self::buildAndAssertAsExpected($inheritedEnvVars, $emptyProdOptions, $expectedBuiltEnvVars);
+ } finally {
+ EnvVarUtil::setOrUnsetIfValueNull($passThroughOptEnvVarName, $passThroughOptEnvVarValueToRestore);
+ AmbientContextForTests::reconfigure();
+ }
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php
new file mode 100644
index 0000000..a0469b6
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php
@@ -0,0 +1,115 @@
+, array>}>
+ */
+ public static function dataProviderForTestGenerateEscalatedLogLevels(): iterable
+ {
+ $prodCodeKey = ComponentTestCaseBase::LOG_LEVEL_FOR_PROD_CODE_KEY;
+ $testCodeKey = ComponentTestCaseBase::LOG_LEVEL_FOR_TEST_CODE_KEY;
+ $highestLevel = LogLevelUtil::getHighest();
+
+ /**
+ * When the initial already the highest
+ */
+ yield [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel], []];
+
+ /**
+ * When the initial one step below the highest
+ */
+ yield [
+ // initialLevels:
+ [$prodCodeKey => LogLevel::from($highestLevel->value - 1), $testCodeKey => $highestLevel],
+ // expectedEscalatedLevelsSeq:
+ [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel]],
+ ];
+
+ yield [
+ // initialLevels:
+ [$prodCodeKey => $highestLevel, $testCodeKey => LogLevel::from($highestLevel->value - 1)],
+ // expectedEscalatedLevelsSeq:
+ [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel]],
+ ];
+
+ /**
+ * When the initial is the default
+ */
+ yield [
+ // initialLevels:
+ [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::info],
+ // expectedEscalatedLevelsSeq:
+ [
+ [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::trace],
+ [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::trace],
+ [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::debug],
+ [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::trace],
+ [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::info],
+ [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::debug],
+ [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::debug],
+ [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::info],
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestGenerateEscalatedLogLevels
+ *
+ * @param array $initialLevels
+ * @param array> $expectedLevelsSeq
+ *
+ * @return void
+ */
+ public function testGenerateEscalatedLogLevels(array $initialLevels, array $expectedLevelsSeq): void
+ {
+ $dbgCtx = ['initialLevels' => $initialLevels, 'expectedLevelsSeq' => $expectedLevelsSeq];
+ $actualEscalatedLevelsSeq = IterableUtil::toList(ComponentTestCaseBase::generateEscalatedLogLevels($initialLevels));
+ $dbgCtx['actualEscalatedLevelsSeq'] = $actualEscalatedLevelsSeq;
+ $i = 0;
+ foreach ($actualEscalatedLevelsSeq as $actualLevels) {
+ $dbgCtxPerIter = array_merge(['i' => $i, 'actualLevels' => $actualLevels], $dbgCtx);
+ self::assertGreaterThan($i, count($expectedLevelsSeq), LoggableToString::convert($dbgCtxPerIter));
+ $expectedLevels = $expectedLevelsSeq[$i];
+ $dbgCtxPerIter['expectedLevels'] = $expectedLevels;
+ self::assertCount(count($expectedLevels), $actualLevels, LoggableToString::convert($dbgCtxPerIter));
+ foreach ($expectedLevels as $levelTypeKey => $expectedLevel) {
+ $dbgCtxPerIter2 = array_merge(['levelTypeKey' => $levelTypeKey], $dbgCtxPerIter);
+ $dbgCtxPerIter2Str = LoggableToString::convert($dbgCtxPerIter2);
+ self::assertSame($expectedLevel, $actualLevels[$levelTypeKey], $dbgCtxPerIter2Str);
+ }
+ ++$i;
+ }
+ self::assertCount($i, $expectedLevelsSeq, LoggableToString::convert($dbgCtx));
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php
new file mode 100644
index 0000000..eea6ccf
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php
@@ -0,0 +1,211 @@
+
+ */
+final class DurationOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface
+{
+ private DurationOptionParser $optionParser;
+ private FloatOptionTestValuesGenerator $auxFloatValuesGenerator;
+
+ public function __construct(DurationOptionParser $optionParser)
+ {
+ $this->optionParser = $optionParser;
+ $this->auxFloatValuesGenerator = self::buildAuxFloatValuesGenerator($optionParser);
+ }
+
+ /**
+ * @return iterable>
+ */
+ private function createIfValidValue(string $valueAsString, DurationUnit $units, string $unitsSuffix): iterable
+ {
+ $value = new Duration(floatval($valueAsString), $units);
+ if ($this->auxFloatValuesGenerator->isInValidRange($value->toMilliseconds())) {
+ yield new OptionTestValidValue($valueAsString . $unitsSuffix, $value);
+ }
+ }
+
+ /**
+ * @return iterable>
+ */
+ public function validValues(): iterable
+ {
+ $noUnits = function (float|int $valueWithoutUnits): float {
+ return Duration::valueToMilliseconds(floatval($valueWithoutUnits), $this->optionParser->defaultUnits);
+ };
+
+ /**
+ * We are forced to use list-array of pairs instead of regular associative array
+ * because in an associative array if the key is numeric string it's automatically converted to int
+ * (see https://www.php.net/manual/en/language.types.array.php)
+ *
+ * @var array $predefinedValidValues
+ */
+ $predefinedValidValues = [
+ ['0', 0],
+ [' 0 ms', 0],
+ ["\t 0 s ", 0],
+ ['0m', 0],
+ ['1', $noUnits(1)],
+ ['0.01', $noUnits(0.01)],
+ ['97.5', $noUnits(97.5)],
+ ['1ms', 1],
+ [" \n 97 \t ms ", 97],
+ ['1s', 1000],
+ ['1m', 60 * 1000],
+ ['0.0', 0],
+ ['0.0ms', 0],
+ ['0.0s', 0],
+ ['0.0m', 0],
+ ['1.5ms', 1.5],
+ ['1.5s', 1.5 * 1000],
+ ['1.5m', 1.5 * 60 * 1000],
+ ['-12ms', -12],
+ ['-12.5ms', -12.5],
+ ['-45s', -45 * 1000],
+ ['-45.1s', -45.1 * 1000],
+ ['-78m', -78 * 60 * 1000],
+ ['-78.2m', -78.2 * 60 * 1000],
+ ];
+ foreach ($predefinedValidValues as $rawAndParsedValuesPair) {
+ if ($this->auxFloatValuesGenerator->isInValidRange($rawAndParsedValuesPair[1])) {
+ yield new OptionTestValidValue($rawAndParsedValuesPair[0], new Duration(floatval($rawAndParsedValuesPair[1]), DurationUnit::ms));
+ }
+ }
+
+ /** @var OptionTestValidValue $validNoUnitsValue */
+ foreach ($this->auxFloatValuesGenerator->validValues() as $validNoUnitsValue) {
+ foreach (DurationUnit::cases() as $unit) {
+ $unitsSuffixes = [$unit->name, ' ' . $unit->name];
+ if ($unit === $this->optionParser->defaultUnits) {
+ $unitsSuffixes[] = '';
+ }
+ foreach ($unitsSuffixes as $unitsSuffix) {
+ $valueInUnits = self::convertFromMilliseconds($validNoUnitsValue->parsedValue, $unit);
+
+ // For float keep only 3 digits after the floating point
+ // for tolerance to error in reverse conversion
+ $roundedValueInUnits = round($valueInUnits, 3);
+
+ yield from $this->createIfValidValue(strval($roundedValueInUnits), $unit, $unitsSuffix);
+
+ foreach ([ceil($roundedValueInUnits), floor($roundedValueInUnits)] as $intValueInUnits) {
+ if (FloatOptionTestValuesGenerator::isInIntRange($intValueInUnits)) {
+ $intValueInUnitsAsString = strval(intval($intValueInUnits));
+ yield from $this->createIfValidValue($intValueInUnitsAsString, $unit, $unitsSuffix);
+
+ yield from $this->createIfValidValue(strval($intValueInUnits), $unit, $unitsSuffix);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public function invalidRawValues(): iterable
+ {
+ yield from [
+ '',
+ ' ',
+ '\t',
+ '\r\n',
+ 'a',
+ 'abc',
+ '123abc',
+ 'abc123',
+ 'a_123_b',
+ '1a',
+ '1sm',
+ '1m2',
+ '1s2',
+ '1ms2',
+ '3a2m',
+ 'a32m',
+ '3a2s',
+ 'a32s',
+ '3a2ms',
+ 'a32ms',
+ ];
+
+ foreach ($this->auxFloatValuesGenerator->invalidRawValues() as $invalidRawValue) {
+ if (!FloatOptionParser::isValidFormat($invalidRawValue)) {
+ yield $invalidRawValue;
+ continue;
+ }
+
+ $invalidValueInMilliseconds = floatval($invalidRawValue);
+ if (!$this->auxFloatValuesGenerator->isInValidRange($invalidValueInMilliseconds)) {
+ foreach (DurationUnit::cases() as $unit) {
+ $valueInUnits = self::convertFromMilliseconds($invalidValueInMilliseconds, $unit);
+ yield $valueInUnits . $unit->name;
+ if ($this->optionParser->defaultUnits === $unit) {
+ yield strval($valueInUnits);
+ }
+ }
+ }
+ }
+
+ /** @var OptionTestValidValue $validValue */
+ foreach ($this->validValues() as $validValue) {
+ foreach (['a', 'z'] as $invalidDurationUnitsSuffix) {
+ yield $validValue->rawValue . $invalidDurationUnitsSuffix;
+ }
+ }
+ }
+
+ public static function convertFromMilliseconds(float $valueInMilliseconds, DurationUnit $dstUnit): float
+ {
+ return match ($dstUnit) {
+ DurationUnit::ms => $valueInMilliseconds,
+ DurationUnit::s => $valueInMilliseconds / 1000,
+ DurationUnit::m => $valueInMilliseconds / (60 * 1000),
+ };
+ }
+
+ private static function buildAuxFloatValuesGenerator(DurationOptionParser $optionParser): FloatOptionTestValuesGenerator
+ {
+ $floatOptionParser = new FloatOptionParser($optionParser->minValidValue?->value, $optionParser->maxValidValue?->value);
+
+ return new class ($floatOptionParser) extends FloatOptionTestValuesGenerator {
+ /**
+ * @return iterable
+ */
+ protected function autoGeneratedInterestingValuesToDiff(): iterable
+ {
+ foreach (parent::autoGeneratedInterestingValuesToDiff() as $interestingValuesToDiff) {
+ foreach (DurationUnit::cases() as $unit) {
+ yield DurationOptionTestValuesGenerator::convertFromMilliseconds($interestingValuesToDiff, $unit);
+ }
+ }
+ }
+ };
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php
new file mode 100644
index 0000000..2e45769
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php
@@ -0,0 +1,229 @@
+
+ */
+final class EnumOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface
+{
+ /** @var EnumOptionParser */
+ private EnumOptionParser $optionParser;
+
+ /** @var array> */
+ private array $additionalValidValues;
+
+ /** @var array */
+ private array $additionalInvalidRawValues;
+
+ /**
+ * EnumOptionTestValuesGenerator constructor.
+ *
+ * @param EnumOptionParser $optionParser
+ * @param array> $additionalValidValues
+ * @param array $additionalInvalidRawValues
+ */
+ public function __construct(
+ EnumOptionParser $optionParser,
+ array $additionalValidValues = [],
+ array $additionalInvalidRawValues = []
+ ) {
+ $this->optionParser = $optionParser;
+ $this->additionalValidValues = $additionalValidValues;
+ $this->additionalInvalidRawValues = $additionalInvalidRawValues;
+ }
+
+ private static function flipRandomLetters(string $srcStr, int $numberOfLettersToFlip): string
+ {
+ if ($numberOfLettersToFlip === 0) {
+ return $srcStr;
+ }
+
+ /** @var int[] $letterIndexes */
+ $letterIndexes = [];
+ foreach (RangeUtil::generateUpTo(strlen($srcStr)) as $charIndex) {
+ if (TextUtil::isLetter(ord($srcStr[$charIndex]))) {
+ $letterIndexes[] = $charIndex;
+ }
+ }
+
+ $actualNumberOfLettersToFlip = min($numberOfLettersToFlip, count($letterIndexes));
+ $letterToFlipIndexes = RandomUtil::arrayRandValues($letterIndexes, $actualNumberOfLettersToFlip);
+
+ $result = '';
+ $remainderStartIndex = 0;
+ foreach ($letterToFlipIndexes as $letterToFlipIndex) {
+ $result .= substr($srcStr, $remainderStartIndex, $letterToFlipIndex - $remainderStartIndex);
+ $result .= chr(TextUtil::flipLetterCase(ord($srcStr[$letterToFlipIndex])));
+ $remainderStartIndex = $letterToFlipIndex + 1;
+ }
+ $result .= substr($srcStr, $remainderStartIndex);
+
+ return $result;
+ }
+
+ /**
+ * @param string $enumEntryName
+ *
+ * @return iterable
+ */
+ private function genCaseVariations(string $enumEntryName): iterable
+ {
+ $maxNumberOfLettersToFlip = $this->optionParser->isCaseSensitive() ? 0 : 2;
+ foreach (RangeUtil::generateFromToIncluding(0, $maxNumberOfLettersToFlip) as $numberOfLettersToFlip) {
+ yield self::flipRandomLetters($enumEntryName, $numberOfLettersToFlip);
+ }
+ }
+
+ private function isUnambiguousPrefix(string $prefix): bool
+ {
+ $foundMatchingEntry = false;
+ foreach ($this->optionParser->nameValuePairs() as $enumEntryNameValuePair) {
+ if (TextUtil::isPrefixOf($prefix, $enumEntryNameValuePair[0], $this->optionParser->isCaseSensitive())) {
+ if ($foundMatchingEntry) {
+ return false;
+ }
+ $foundMatchingEntry = true;
+ }
+ }
+ return $foundMatchingEntry;
+ }
+
+ /**
+ * @param string $enumEntryName
+ *
+ * @return iterable
+ */
+ private function genPrefixVariations(string $enumEntryName): iterable
+ {
+ yield $enumEntryName;
+
+ if (!$this->optionParser->isUnambiguousPrefixAllowed()) {
+ return;
+ }
+
+ foreach (RangeUtil::generateFromToIncluding(1, strlen($enumEntryName) - 1) as $lengthToCutOff) {
+ $prefix = substr($enumEntryName, 0, -$lengthToCutOff);
+ if ($this->isUnambiguousPrefix($prefix)) {
+ yield $prefix;
+ } else {
+ break;
+ }
+ }
+ }
+
+ public function validValues(): iterable
+ {
+ yield from $this->additionalValidValues;
+
+ foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) {
+ foreach ($this->genPrefixVariations($enumEntryNameAndValue[0]) as $enumEntryNamePrefix) {
+ foreach ($this->genCaseVariations($enumEntryNamePrefix) as $manipulatedEnumEntryName) {
+ yield new OptionTestValidValue($manipulatedEnumEntryName, $enumEntryNameAndValue[1]);
+ }
+ }
+ }
+ }
+
+ private function isValidRawValue(string $rawValue): bool
+ {
+ foreach ($this->additionalValidValues as $additionalValidValue) {
+ $trimmedRawValue = trim($rawValue);
+ if ($trimmedRawValue === $additionalValidValue->rawValue) {
+ return true;
+ }
+ }
+
+ $foundAsPrefix = false;
+ foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) {
+ if (TextUtil::isPrefixOf($rawValue, $enumEntryNameAndValue[0], $this->optionParser->isCaseSensitive())) {
+ if (strlen($rawValue) === strlen($enumEntryNameAndValue[0])) {
+ return true;
+ }
+ if ($foundAsPrefix) {
+ return false;
+ }
+ $foundAsPrefix = true;
+ }
+ }
+ return $foundAsPrefix;
+ }
+
+ /**
+ * @return iterable
+ */
+ private function invalidRawValuesImpl(): iterable
+ {
+ /**
+ * @param string $rawValue
+ *
+ * @return iterable
+ */
+ $genIfNotValidRawValue = function (string $rawValue): iterable {
+ if (!$this->isValidRawValue($rawValue)) {
+ yield $rawValue;
+ }
+ };
+
+ yield from $this->additionalInvalidRawValues;
+
+ yield from ['', ' ', '\t', '\r\n'];
+
+ /** @var OptionTestValidValue $validValueData */
+ foreach (StringOptionTestValuesGenerator::singletonInstance()->validValues() as $validValueData) {
+ yield from $genIfNotValidRawValue($validValueData->parsedValue);
+ }
+
+ foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) {
+ $lengthsToCutOffVars = RangeUtil::generateFromToIncluding(0, strlen($enumEntryNameAndValue[0]) - 1);
+ foreach ($lengthsToCutOffVars as $lengthToCutOff) {
+ $prefixBeforeCaseVariations = substr($enumEntryNameAndValue[0], 0, -$lengthToCutOff);
+ foreach ($this->genCaseVariations($prefixBeforeCaseVariations) as $prefix) {
+ yield from $genIfNotValidRawValue($prefix);
+ yield from $genIfNotValidRawValue($prefix . '_X');
+ yield from $genIfNotValidRawValue('X_' . $prefix);
+ }
+ }
+ }
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function invalidRawValues(): iterable
+ {
+ foreach ($this->invalidRawValuesImpl() as $invalidRawValue) {
+ if (!$this->isValidRawValue($invalidRawValue)) {
+ yield $invalidRawValue;
+ }
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php
new file mode 100644
index 0000000..e4d0d94
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php
@@ -0,0 +1,121 @@
+, list>}>
+ */
+ public static function dataProviderForTestEnumWithSomeEntriesArePrefixOfOtherOnes(): array
+ {
+ /** @noinspection PhpUnnecessaryLocalVariableInspection */
+ $testArgsTuples = [
+ [
+ EnumOptionParser::useEnumCasesNames(EnumOptionsParsingTestDummyEnum::class, isCaseSensitive: true, isUnambiguousPrefixAllowed: true),
+ [
+ new OptionTestValidValue(" anotherEnumEntry\t\n", EnumOptionsParsingTestDummyEnum::anotherEnumEntry),
+ new OptionTestValidValue("anotherEnumEnt \n ", EnumOptionsParsingTestDummyEnum::anotherEnumEntry),
+ new OptionTestValidValue("another \n ", EnumOptionsParsingTestDummyEnum::anotherEnumEntry),
+ new OptionTestValidValue('a', EnumOptionsParsingTestDummyEnum::anotherEnumEntry),
+ new OptionTestValidValue(' enumEntry', EnumOptionsParsingTestDummyEnum::enumEntry),
+ new OptionTestValidValue("\t enumEntryWithSuffix\n ", EnumOptionsParsingTestDummyEnum::enumEntryWithSuffix),
+ new OptionTestValidValue('enumEntryWithSuffix2', EnumOptionsParsingTestDummyEnum::enumEntryWithSuffix2),
+ ]
+ ],
+ [
+ EnumOptionParser::useEnumCasesValues(EnumOptionsParsingTestDummyBackedEnum::class, isCaseSensitive: true, isUnambiguousPrefixAllowed: true),
+ [
+ new OptionTestValidValue(" anotherEnumEntry_value\t\n", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry),
+ new OptionTestValidValue("anotherEnumEnt \n ", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry),
+ new OptionTestValidValue("another \n ", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry),
+ new OptionTestValidValue('a', EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry),
+ new OptionTestValidValue(' enumEntry_value', EnumOptionsParsingTestDummyBackedEnum::enumEntry),
+ new OptionTestValidValue("\t enumEntryWithSuffix_value\n ", EnumOptionsParsingTestDummyBackedEnum::enumEntryWithSuffix),
+ new OptionTestValidValue('enumEntryWithSuffix2_value', EnumOptionsParsingTestDummyBackedEnum::enumEntryWithSuffix2),
+ ]
+ ],
+ [
+ new EnumOptionParser(
+ dbgDesc: '',
+ nameValuePairs: [
+ ['enumEntry', 'enumEntry_value'],
+ ['enumEntryWithSuffix', 'enumEntryWithSuffix_value'],
+ ['enumEntryWithSuffix2', 'enumEntryWithSuffix2_value'],
+ ['anotherEnumEntry', 'anotherEnumEntry_value'],
+ ],
+ isCaseSensitive: true,
+ isUnambiguousPrefixAllowed: true
+ ),
+ [
+ new OptionTestValidValue(" anotherEnumEntry\t\n", 'anotherEnumEntry_value'),
+ new OptionTestValidValue("anotherEnumEnt \n ", 'anotherEnumEntry_value'),
+ new OptionTestValidValue("another \n ", 'anotherEnumEntry_value'),
+ new OptionTestValidValue('a', 'anotherEnumEntry_value'),
+ new OptionTestValidValue(' enumEntry', 'enumEntry_value'),
+ new OptionTestValidValue("\t enumEntryWithSuffix\n ", 'enumEntryWithSuffix_value'),
+ new OptionTestValidValue('enumEntryWithSuffix2', 'enumEntryWithSuffix2_value'),
+ ],
+ ],
+ ];
+
+ return $testArgsTuples; // @phpstan-ignore return.type
+ }
+
+ /**
+ * @template T
+ *
+ * @dataProvider dataProviderForTestEnumWithSomeEntriesArePrefixOfOtherOnes
+ *
+ * @param EnumOptionParser $optionParser
+ * @param list> $additionalValidValues
+ */
+ public function testEnumWithSomeEntriesArePrefixOfOtherOnes(EnumOptionParser $optionParser, array $additionalValidValues): void
+ {
+ /** @noinspection SpellCheckingInspection */
+ /** @var list $additionalInvalidRawValues */
+ static $additionalInvalidRawValues = [
+ 'e',
+ 'enum',
+ 'enumEnt',
+ 'enumEntr',
+ 'enumEntryWithSuffi',
+ 'enumEntryWithSuffix2_',
+ 'ENUMENTRY',
+ 'enumEntryWithSUFFIX',
+ 'ENUMEntryWithSuffix2',
+ 'anotherenumentry',
+ 'Another',
+ 'A',
+ ];
+
+ $testValuesGenerator = new EnumOptionTestValuesGenerator($optionParser, $additionalValidValues, $additionalInvalidRawValues);
+
+ VariousOptionsParsingTest::parseValidValueTestImpl($testValuesGenerator, $optionParser);
+ VariousOptionsParsingTest::parseInvalidValueTestImpl($testValuesGenerator, $optionParser);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php
new file mode 100644
index 0000000..a6e4473
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php
@@ -0,0 +1,32 @@
+
+ */
+class FloatOptionTestValuesGenerator extends NumericOptionTestValuesGeneratorBase
+{
+ public function __construct(
+ protected FloatOptionParser $optionParser
+ ) {
+ }
+
+ /**
+ * @return FloatOptionParser
+ */
+ protected function optionParser(): NumericOptionParser
+ {
+ return $this->optionParser;
+ }
+
+ /**
+ * @return float
+ */
+ protected static function maxValueSupportedByType(): float
+ {
+ return FloatLimits::MAX;
+ }
+
+ /**
+ * @return float
+ */
+ protected static function minValueSupportedByType(): float
+ {
+ return FloatLimits::MIN;
+ }
+
+ /**
+ * @return iterable>
+ */
+ protected function manualInterestingValues(): iterable
+ {
+ /** @var OptionTestValidValue $intManualInterestingValue */
+ foreach (self::intManualInterestingValues() as $intManualInterestingValue) {
+ yield new OptionTestValidValue(
+ $intManualInterestingValue->rawValue,
+ floatval($intManualInterestingValue->parsedValue)
+ );
+ }
+
+ yield new OptionTestValidValue('0.0', 0.0);
+ yield new OptionTestValidValue('-0.0', 0.0);
+ yield new OptionTestValidValue('+0.0', 0.0);
+ yield new OptionTestValidValue('0.0e0', 0.0);
+ yield new OptionTestValidValue('-0.0E-0', 0.0);
+ yield new OptionTestValidValue('+0.0e+0', 0.0);
+
+ yield new OptionTestValidValue('1.0', 1.0);
+ yield new OptionTestValidValue('+1.0', 1.0);
+ yield new OptionTestValidValue('-1.0', -1.0);
+ yield new OptionTestValidValue('1.0E0', 1.0);
+ yield new OptionTestValidValue('+1.0e0', 1.0);
+ yield new OptionTestValidValue('-1.0E0', -1.0);
+
+ yield new OptionTestValidValue('01.5e1', 15.0);
+ yield new OptionTestValidValue('+5.1E2', 510.0);
+ yield new OptionTestValidValue('-2.5e-3', -0.0025);
+ }
+
+ /**
+ * @return iterable
+ */
+ protected function autoGeneratedInterestingValuesToDiff(): iterable
+ {
+ /** @var Set $result */
+ $result = new Set();
+
+ /** @var int $intInterestingValue */
+ foreach ($this->intInterestingValuesToDiff() as $intInterestingValue) {
+ $result->add(floatval($intInterestingValue));
+ }
+
+ if ($this->optionParser()->minValidValue() !== null) {
+ $result->add(
+ $this->optionParser()->minValidValue(),
+ $this->optionParser()->minValidValue() / 2
+ );
+ }
+ if ($this->optionParser()->maxValidValue() !== null) {
+ $result->add(
+ $this->optionParser()->maxValidValue(),
+ $this->optionParser()->maxValidValue() / 2
+ );
+ }
+ $result->add(
+ FloatLimits::MIN,
+ FloatLimits::MIN / 2,
+ FloatLimits::MAX / 2,
+ FloatLimits::MAX
+ );
+
+ return new IteratorIterator($result);
+ }
+
+ /**
+ * @return iterable
+ */
+ protected function autoGeneratedInterestingValueDiffs(): iterable
+ {
+ foreach (self::intInterestingDiffs() as $intDiff) {
+ foreach (self::fractionInterestingDiffs() as $fractionDiff) {
+ yield $intDiff + $fractionDiff;
+ if ($intDiff > $fractionDiff) {
+ yield $intDiff - $fractionDiff;
+ }
+ }
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ protected static function fractionInterestingDiffs(): iterable
+ {
+ yield from [0.0, 0.001, 0.01, 0.1, 0.5, 0.9];
+ }
+
+ /**
+ * @param float $min
+ * @param float $max
+ */
+ protected static function randomValue($min, $max): float
+ {
+ return RandomUtil::generateFloatInRange($min, $max);
+ }
+
+ public static function isInIntRange(float $value): bool
+ {
+ return NumericUtil::isInClosedInterval(PHP_INT_MIN, $value, PHP_INT_MAX);
+ }
+
+ /**
+ * @param float $value
+ *
+ * @return OptionTestValidValue
+ */
+ protected static function createOptionTestValidValue($value): OptionTestValidValue
+ {
+ $valueAsString = strval($value);
+ return new OptionTestValidValue($valueAsString, floatval($valueAsString));
+ }
+
+ /**
+ * @return iterable>
+ */
+ public function validValues(): iterable
+ {
+ /** @var OptionTestValidValue $value */
+ foreach (parent::validValues() as $value) {
+ yield $value;
+
+ /** @var float $roundedValue */
+ foreach ([ceil($value->parsedValue), floor($value->parsedValue)] as $roundedValue) {
+ if (self::isInIntRange($roundedValue)) {
+ yield static::createOptionTestValidValue($roundedValue);
+ $valueAsString = strval(intval($roundedValue));
+ yield new OptionTestValidValue($valueAsString, floatval($valueAsString));
+ }
+ }
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php
new file mode 100644
index 0000000..feab961
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php
@@ -0,0 +1,115 @@
+
+ */
+class IntOptionTestValuesGenerator extends NumericOptionTestValuesGeneratorBase
+{
+ public function __construct(
+ protected IntOptionParser $optionParser
+ ) {
+ }
+
+ /**
+ * @return IntOptionParser
+ */
+ protected function optionParser(): NumericOptionParser
+ {
+ return $this->optionParser;
+ }
+
+ /**
+ * @return int
+ */
+ final protected static function maxValueSupportedByType(): int
+ {
+ return PHP_INT_MAX;
+ }
+
+ /**
+ * @return int
+ */
+ final protected static function minValueSupportedByType(): int
+ {
+ return PHP_INT_MIN;
+ }
+
+ /**
+ * @return iterable>
+ */
+ protected function manualInterestingValues(): iterable
+ {
+ return self::intManualInterestingValues();
+ }
+
+ /**
+ * @return iterable
+ */
+ protected function autoGeneratedInterestingValuesToDiff(): iterable
+ {
+ return self::intInterestingValuesToDiff();
+ }
+
+ /**
+ * @return iterable
+ */
+ protected function autoGeneratedInterestingValueDiffs(): iterable
+ {
+ return self::intInterestingDiffs();
+ }
+
+ /**
+ * @param int $min
+ * @param int $max
+ */
+ protected static function randomValue($min, $max): int
+ {
+ return RandomUtil::generateIntInRange($min, $max);
+ }
+
+ /**
+ * @param int $value
+ *
+ * @return OptionTestValidValue
+ */
+ protected static function createOptionTestValidValue($value): OptionTestValidValue
+ {
+ return new OptionTestValidValue(strval($value), $value);
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function invalidRawValues(): iterable
+ {
+ yield from ['0.0', '1.0', '-1.0', '1.5', '-1.5', '20.2', '-30.3'];
+ yield from parent::invalidRawValues();
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php
new file mode 100644
index 0000000..657f1ed
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php
@@ -0,0 +1,274 @@
+
+ */
+abstract class NumericOptionTestValuesGeneratorBase implements OptionTestValuesGeneratorInterface
+{
+ /**
+ * @return NumericOptionParser
+ */
+ abstract protected function optionParser(): NumericOptionParser;
+
+ /**
+ * @return T
+ */
+ abstract protected static function maxValueSupportedByType();
+
+ /**
+ * @return T
+ */
+ abstract protected static function minValueSupportedByType();
+
+ /**
+ * @return T
+ */
+ protected function effectiveMaxValidValue()
+ {
+ return $this->optionParser()->maxValidValue() ?? static::maxValueSupportedByType();
+ }
+
+ /**
+ * @return T
+ */
+ protected function effectiveMinValidValue()
+ {
+ return $this->optionParser()->minValidValue() ?? static::minValueSupportedByType();
+ }
+
+ /**
+ * @phpstan-param T $value
+ */
+ public function isInValidRange(float|int $value): bool
+ {
+ return NumericUtil::isInClosedInterval(
+ $this->effectiveMinValidValue(),
+ $value,
+ $this->effectiveMaxValidValue()
+ );
+ }
+
+ /**
+ * @return iterable>
+ */
+ protected function intManualInterestingValues(): iterable
+ {
+ yield new OptionTestValidValue('0', 0);
+ yield new OptionTestValidValue('-0', 0);
+ yield new OptionTestValidValue('+0', 0);
+
+ yield new OptionTestValidValue('1', 1);
+ yield new OptionTestValidValue('+1', 1);
+ yield new OptionTestValidValue('-1', -1);
+
+ yield new OptionTestValidValue('1234', 1234);
+ yield new OptionTestValidValue('-56789', -56789);
+ }
+
+ /**
+ * @return iterable>
+ * @phpstan-return iterable>
+ */
+ abstract protected function manualInterestingValues(): iterable;
+
+ /**
+ * @return iterable>
+ * @phpstan-return iterable>
+ */
+ private function autoGeneratedInterestingValues(): iterable
+ {
+ /** @var Set $values */
+ $values = new Set();
+
+ foreach ($this->autoGeneratedInterestingValuesToDiff() as $interestingValue) {
+ foreach ($this->autoGeneratedInterestingValueDiffs() as $interestingDiff) {
+ if ($interestingValue <= (static::maxValueSupportedByType() - $interestingDiff)) {
+ /**
+ * @var float|int $value
+ * @phpstan-var T $value
+ */
+ $value = $interestingValue + $interestingDiff;
+ $values->add($value);
+ }
+ if ($interestingValue >= (static::minValueSupportedByType() + $interestingDiff)) {
+ /**
+ * @var float|int $value
+ * @phpstan-var T $value
+ */
+ $value = $interestingValue - $interestingDiff;
+ $values->add($value);
+ }
+ }
+ }
+
+ foreach ($values as $value) {
+ yield static::createOptionTestValidValue($value);
+ }
+ }
+
+ /**
+ * @return iterable>
+ * @phpstan-return iterable>
+ */
+ private function interestingValues(): iterable
+ {
+ yield from $this->manualInterestingValues();
+ yield from $this->autoGeneratedInterestingValues();
+ }
+
+ /**
+ * @phpstan-param T $min
+ * @phpstan-param T $max
+ *
+ * @return T
+ */
+ abstract protected static function randomValue(float|int $min, float|int $max);
+
+ /**
+ * @phpstan-param T $value
+ *
+ * @return OptionTestValidValue
+ */
+ abstract protected static function createOptionTestValidValue(float|int $value): OptionTestValidValue;
+
+ public function validValues(): iterable
+ {
+ /**
+ * @var OptionTestValidValue $candidate
+ * @phpstan-var OptionTestValidValue $candidate
+ */
+ foreach ($this->interestingValues() as $candidate) {
+ if ($this->isInValidRange($candidate->parsedValue)) {
+ yield $candidate;
+ }
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) {
+ $value = static::randomValue($this->effectiveMinValidValue(), $this->effectiveMaxValidValue());
+ yield static::createOptionTestValidValue($value);
+ }
+ }
+
+ /** @inheritDoc */
+ #[Override]
+ public function invalidRawValues(): iterable
+ {
+ yield from [
+ '',
+ ' ',
+ '\t',
+ '\r\n',
+ 'a',
+ 'abc',
+ '123abc',
+ 'abc123',
+ 'a_123_b',
+ '1a',
+ '12.3abc',
+ 'abc1.23',
+ 'a_12.3_b',
+ 'a_12.3E+1',
+ '12.3E+1_b',
+ ];
+
+ /**
+ * @var OptionTestValidValue $interestingValue
+ * @phpstan-var OptionTestValidValue $interestingValue
+ */
+ foreach ($this->autoGeneratedInterestingValues() as $interestingValue) {
+ if (!$this->isInValidRange($interestingValue->parsedValue)) {
+ yield $interestingValue->rawValue;
+ }
+ }
+
+ if (static::minValueSupportedByType() < $this->effectiveMinValidValue()) {
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) {
+ yield strval(static::randomValue(static::minValueSupportedByType(), $this->effectiveMinValidValue()));
+ }
+ }
+ if ($this->effectiveMaxValidValue() < static::maxValueSupportedByType()) {
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) {
+ yield strval(static::randomValue($this->effectiveMaxValidValue(), static::maxValueSupportedByType()));
+ }
+ }
+ }
+
+ /**
+ * @return iterable
+ * @phpstan-return iterable
+ */
+ abstract protected function autoGeneratedInterestingValuesToDiff(): iterable;
+
+ /**
+ * @return iterable
+ */
+ abstract protected function autoGeneratedInterestingValueDiffs(): iterable;
+
+ /**
+ * @return iterable
+ */
+ protected function intInterestingValuesToDiff(): iterable
+ {
+ /** @var Set $result */
+ $result = new Set();
+ $result->add(0);
+
+ if (is_int($this->optionParser()->minValidValue())) {
+ $result->add($this->optionParser()->minValidValue(), intdiv($this->optionParser()->minValidValue(), 2));
+ }
+ if (is_int($this->optionParser()->maxValidValue())) {
+ $result->add($this->optionParser()->maxValidValue(), intdiv($this->optionParser()->maxValidValue(), 2));
+ }
+ $result->add(
+ PHP_INT_MIN,
+ intdiv(PHP_INT_MIN, 2),
+ intdiv(PHP_INT_MAX, 2),
+ PHP_INT_MAX
+ );
+
+ return new IteratorIterator($result);
+ }
+
+ /**
+ * @return iterable
+ */
+ protected static function intInterestingDiffs(): iterable
+ {
+ yield from [0, 1, 2, 5, 10, 11, 100, 101, 123, 200, 202, 500, 1000, 9876];
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php
new file mode 100644
index 0000000..fb47993
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php
@@ -0,0 +1,138 @@
+> $optMetas
+ */
+ $impl = function (array $optNameCases, array $optMetas): void {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $optNamesFromCases = array_map(fn($optNameCase) => $optNameCase->name, $optNameCases); // @phpstan-ignore property.nonObject
+ sort(/* ref */ $optNamesFromCases);
+ $optNamesFromMetas = array_keys($optMetas);
+ sort(/* ref */ $optNamesFromMetas);
+ $dbgCtx->add(compact('optNamesFromCases', 'optNamesFromMetas'));
+ AssertEx::arraysHaveTheSameContent($optNamesFromCases, $optNamesFromMetas);
+ };
+
+ $impl(OptionForProdName::cases(), OptionsForProdMetadata::get());
+ $impl(OptionForTestsName::cases(), OptionsForTestsMetadata::get());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestOptionNamesAndSnapshotPropertiesMatch(): iterable
+ {
+ return [
+ [OptionForProdName::cases(), ConfigSnapshotForProd::propertyNamesForOptions()],
+ [OptionForTestsName::cases(), ConfigSnapshotForTests::propertyNamesForOptions()],
+ ];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestOptionNamesAndSnapshotPropertiesMatch
+ *
+ * @param UnitEnum[] $optNameCases
+ * @param string[] $propertyNamesForOptions
+ */
+ public function testOptionNamesAndSnapshotPropertiesMatch(array $optNameCases, array $propertyNamesForOptions): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $remainingSnapPropNames = $propertyNamesForOptions;
+ foreach ($optNameCases as $optNameCase) {
+ $dbgCtx->add(compact('optNameCase', 'remainingSnapPropNames'));
+ self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $remainingSnapPropNames, TextUtil::snakeToCamelCase($optNameCase->name)));
+ }
+
+ self::assertEmpty($remainingSnapPropNames);
+ }
+
+ public function testOptionNameToEnvVarName(): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ /** @var class-string $optNameEnumClass */
+ foreach ([OptionForProdName::class, OptionForTestsName::class] as $optNameEnumClass) {
+ $dbgCtx->add(compact('optNameEnumClass'));
+ foreach ($optNameEnumClass::cases() as $optName) {
+ $dbgCtx->add(compact('optName'));
+ $envVarName = $optName->toEnvVarName();
+ $dbgCtx->add(compact('envVarName'));
+ self::assertTrue(TextUtil::isSuffixOf(strtoupper($optName->name), $envVarName));
+ }
+ }
+ }
+
+ public function testLogRelated(): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ foreach (OptionForProdName::getAllLogLevelRelated() as $optName) {
+ $dbgCtx->add(compact('optName'));
+ self::assertTrue($optName->isLogLevelRelated());
+ }
+
+ foreach (OptionForProdName::cases() as $optName) {
+ $dbgCtx->add(compact('optName'));
+ if (TextUtil::isPrefixOf('log_level_', $optName->name)) {
+ self::assertTrue($optName->isLogLevelRelated());
+ }
+ }
+ }
+
+ public function testProdOptionNameToEnvVar(): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ foreach (OptionForProdName::cases() as $optName) {
+ $dbgCtx->add(compact('optName'));
+ $envVarNamePrefix = $optName->getEnvVarNamePrefix();
+ $dbgCtx->add(compact('envVarNamePrefix'));
+ $envVarName = $optName->toEnvVarName();
+ $dbgCtx->add(compact('envVarName'));
+ self::assertStringStartsWith($envVarNamePrefix, $envVarName);
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php
new file mode 100644
index 0000000..80cd332
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php
@@ -0,0 +1,49 @@
+rawValue = $rawValue;
+ $this->parsedValue = $parsedValue;
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php
new file mode 100644
index 0000000..2880df9
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php
@@ -0,0 +1,42 @@
+>
+ */
+ public function validValues(): iterable;
+
+ /**
+ * @return iterable
+ */
+ public function invalidRawValues(): iterable;
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php
new file mode 100644
index 0000000..16c1a13
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php
@@ -0,0 +1,123 @@
+
+ */
+final class StringOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface
+{
+ use SingletonInstanceTrait;
+
+ /**
+ * @return iterable
+ */
+ private static function charsToUse(): iterable
+ {
+ // latin letters
+ foreach (RangeUtil::generateFromToIncluding(ord('A'), ord('Z')) as $charAsInt) {
+ yield $charAsInt;
+ yield TextUtil::flipLetterCase($charAsInt);
+ }
+
+ // digits
+ foreach (RangeUtil::generateFromToIncluding(ord('0'), ord('9')) as $charAsInt) {
+ yield $charAsInt;
+ }
+
+ // punctuation
+ yield from TextUtilForTests::iterateOverChars(',:;.!?');
+
+ yield from TextUtilForTests::iterateOverChars('@#$%&*()<>{}[]+-=_~^');
+ yield ord('/');
+ yield ord('|');
+ yield ord('\\');
+ yield ord('`');
+ yield ord('\'');
+ yield ord('"');
+
+ // whitespace
+ yield from TextUtilForTests::iterateOverChars(" \t\r\n");
+ }
+
+ /**
+ * @return iterable
+ */
+ private function validStrings(): iterable
+ {
+ yield '';
+ yield 'A';
+ yield 'abc';
+ yield 'abC 123 Xyz';
+
+ /** @var array $charsToUse */
+ $charsToUse = IterableUtil::toList(self::charsToUse());
+
+ $stringFromAllCharsToUse = '';
+ foreach ($charsToUse as $charToUse) {
+ $stringFromAllCharsToUse .= chr($charToUse);
+ }
+ yield $stringFromAllCharsToUse;
+
+ // any two chars (even the same one twice)
+ foreach (RangeUtil::generateUpTo(count($charsToUse)) as $i) {
+ foreach (RangeUtil::generateUpTo(count($charsToUse)) as $j) {
+ yield chr($charsToUse[$i]) . chr($charsToUse[$j]);
+ }
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) {
+ $numberOfChars = RandomUtil::generateIntInRange(1, count($charsToUse));
+ $randString = '';
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ foreach (RangeUtil::generateUpTo($numberOfChars) as $__) {
+ $randString .= chr(RandomUtil::generateIntInRange(0, count($charsToUse) - 1));
+ }
+ yield $randString;
+ }
+ }
+
+ /**
+ * @return iterable>
+ */
+ public function validValues(): iterable
+ {
+ foreach ($this->validStrings() as $validString) {
+ yield new OptionTestValidValue($validString, trim($validString));
+ }
+ }
+
+ public function invalidRawValues(): iterable
+ {
+ return [];
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php
new file mode 100644
index 0000000..3541dc4
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php
@@ -0,0 +1,280 @@
+ $optMeta
+ *
+ * @return OptionTestValuesGeneratorInterface
+ */
+ private static function selectTestValuesGenerator(OptionMetadata $optMeta): OptionTestValuesGeneratorInterface
+ {
+ $optionParser = $optMeta->parser();
+
+ if ($optionParser instanceof BoolOptionParser) {
+ return new EnumOptionTestValuesGenerator($optionParser, additionalValidValues: [new OptionTestValidValue('', false)]); /** @phpstan-ignore return.type */
+ }
+ if ($optionParser instanceof DurationOptionParser) {
+ return new DurationOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */
+ }
+ if ($optionParser instanceof EnumOptionParser) {
+ return new EnumOptionTestValuesGenerator($optionParser);
+ }
+ if ($optionParser instanceof FloatOptionParser) {
+ return new FloatOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */
+ }
+ if ($optionParser instanceof IntOptionParser) {
+ return new IntOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */
+ }
+ if ($optionParser instanceof StringOptionParser) {
+ return StringOptionTestValuesGenerator::singletonInstance(); /** @phpstan-ignore return.type */
+ }
+ if ($optionParser instanceof WildcardListOptionParser) {
+ return WildcardListOptionTestValuesGenerator::singletonInstance(); /** @phpstan-ignore return.type */
+ }
+
+ self::fail('Unknown option metadata type: ' . get_debug_type($optMeta));
+ }
+
+ /**
+ * @return array>
+ */
+ private static function additionalOptionMetas(): array
+ {
+ $result = [];
+
+ $result['Duration s units'] = new DurationOptionMetadata(
+ new Duration(10, DurationUnit::ms) /* minValidValue */,
+ new Duration(20, DurationUnit::ms) /* maxValidValue */,
+ DurationUnit::s /* <- defaultUnits */,
+ new Duration(15, DurationUnit::ms) /* <- defaultValue */
+ );
+
+ $result['Duration m units'] = new DurationOptionMetadata(
+ null /* minValidValue */,
+ null /* maxValidValue */,
+ DurationUnit::m /* <- defaultUnits */,
+ new Duration(123, DurationUnit::m) /* <- defaultValue */
+ );
+
+ $result['Float without constrains'] = new FloatOptionMetadata(
+ null /* minValidValue */,
+ null /* maxValidValue */,
+ 123.321 /* defaultValue */
+ );
+
+ $result['Float only with min constrain'] = new FloatOptionMetadata(
+ -1.0 /* minValidValue */,
+ null /* maxValidValue */,
+ 456.789 /* defaultValue */
+ );
+
+ $result['Float only with max constrain'] = new FloatOptionMetadata(
+ null /* minValidValue */,
+ 1.0 /* maxValidValue */,
+ -987.654 /* defaultValue */
+ );
+
+ /** @var array> $result */
+ return $result; // @phpstan-ignore varTag.nativeType
+ }
+
+ /**
+ * @return array>>
+ */
+ private static function snapshotClassToOptionsMeta(): array
+ {
+ return [
+ ConfigSnapshotForTests::class => OptionsForTestsMetadata::get(),
+ null => self::additionalOptionMetas(),
+ ];
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function allOptionsMetadataProvider(): iterable
+ {
+ foreach (self::snapshotClassToOptionsMeta() as $optionsMeta) {
+ foreach ($optionsMeta as $optMeta) {
+ if (!$optMeta->parser() instanceof CustomOptionParser) {
+ yield [LoggableToString::convert($optMeta), $optMeta];
+ }
+ }
+ }
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function allOptionsMetadataWithPossibleInvalidRawValuesProvider(): iterable
+ {
+ foreach (self::allOptionsMetadataProvider() as $optMetaDescAndDataPair) {
+ /** @var OptionMetadata $optMeta */
+ $optMeta = $optMetaDescAndDataPair[1];
+ if (!IterableUtil::isEmpty(self::selectTestValuesGenerator($optMeta)->invalidRawValues())) {
+ yield $optMetaDescAndDataPair;
+ }
+ }
+ }
+
+ public function testIntOptionParserIsValidFormat(): void
+ {
+ self::assertTrue(IntOptionParser::isValidFormat('0'));
+ self::assertFalse(IntOptionParser::isValidFormat('0.0'));
+ self::assertTrue(IntOptionParser::isValidFormat('+0'));
+ self::assertFalse(IntOptionParser::isValidFormat('+0.0'));
+ self::assertTrue(IntOptionParser::isValidFormat('-0'));
+ self::assertFalse(IntOptionParser::isValidFormat('-0.0'));
+
+ self::assertTrue(IntOptionParser::isValidFormat('1'));
+ self::assertFalse(IntOptionParser::isValidFormat('1.0'));
+ self::assertTrue(IntOptionParser::isValidFormat('+1'));
+ self::assertFalse(IntOptionParser::isValidFormat('+1.0'));
+ self::assertTrue(IntOptionParser::isValidFormat('-1'));
+ self::assertFalse(IntOptionParser::isValidFormat('-1.0'));
+ }
+
+ /**
+ * @param OptionTestValuesGeneratorInterface $testValuesGenerator
+ * @param OptionParser $optParser
+ */
+ public static function parseInvalidValueTestImpl(OptionTestValuesGeneratorInterface $testValuesGenerator, OptionParser $optParser): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $invalidRawValues = $testValuesGenerator->invalidRawValues();
+ if (IterableUtil::isEmpty($invalidRawValues)) {
+ self::dummyAssert();
+ return;
+ }
+ $dbgCtx->add(compact('invalidRawValues'));
+
+ foreach ($invalidRawValues as $invalidRawValue) {
+ $invalidRawValue = self::genOptionalWhitespace() . $invalidRawValue . self::genOptionalWhitespace();
+ $dbgCtx->add([...compact('invalidRawValue'), 'strlen($invalidRawValue)' => strlen($invalidRawValue)]);
+ if (!TextUtil::isEmptyString($invalidRawValue)) {
+ $dbgCtx->add(['ord($invalidRawValue[0])' => ord($invalidRawValue[0])]);
+ }
+ AssertEx::throws(
+ ParseException::class,
+ function () use ($optParser, $invalidRawValue): void {
+ Parser::parseOptionRawValue($invalidRawValue, $optParser);
+ }
+ );
+ }
+ }
+
+ /**
+ * @dataProvider allOptionsMetadataWithPossibleInvalidRawValuesProvider
+ *
+ * @param OptionMetadata $optMeta
+ */
+ public function testParseInvalidValue(string $optMetaDbgDesc, OptionMetadata $optMeta): void
+ {
+ self::parseInvalidValueTestImpl(self::selectTestValuesGenerator($optMeta), $optMeta->parser());
+ }
+
+ private static function genOptionalWhitespace(): string
+ {
+ $whiteSpaceChars = [' ', "\t"];
+ $result = '';
+ foreach (RangeUtil::generateUpTo(3) as $ignored) {
+ $result .= RandomUtil::getRandomValueFromArray($whiteSpaceChars);
+ }
+ return $result;
+ }
+
+ /**
+ * @param OptionTestValuesGeneratorInterface $testValuesGenerator
+ * @param OptionParser $optParser
+ */
+ public static function parseValidValueTestImpl(OptionTestValuesGeneratorInterface $testValuesGenerator, OptionParser $optParser): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $validValues = $testValuesGenerator->validValues();
+ if (IterableUtil::isEmpty($validValues)) {
+ self::dummyAssert();
+ return;
+ }
+ $dbgCtx->add(['validValues' => $validValues]);
+
+ $valueWithDetails = function (mixed $value): mixed {
+ if (!is_float($value)) {
+ return $value;
+ }
+
+ return ['$value' => $value, 'number_format($value)' => number_format($value)];
+ };
+
+ /** @var OptionTestValidValue $validValueData */
+ foreach ($validValues as $validValueData) {
+ $dbgCtx->add(['validValueData' => $validValueData, '$validValueData->parsedValue' => $valueWithDetails($validValueData->parsedValue)]);
+ $validValueData->rawValue = self::genOptionalWhitespace() . $validValueData->rawValue . self::genOptionalWhitespace();
+ $actualParsedValue = Parser::parseOptionRawValue($validValueData->rawValue, $optParser);
+ $dbgCtx->add(['actualParsedValue' => $valueWithDetails($actualParsedValue)]);
+ AssertEx::equalsEx($validValueData->parsedValue, $actualParsedValue);
+ }
+ }
+
+ /**
+ * @dataProvider allOptionsMetadataProvider
+ *
+ * @param OptionMetadata $optMeta
+ */
+ public function testParseValidValue(string $optMetaDbgDesc, OptionMetadata $optMeta): void
+ {
+ self::parseValidValueTestImpl(self::selectTestValuesGenerator($optMeta), $optMeta->parser());
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php
new file mode 100644
index 0000000..23b5a33
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php
@@ -0,0 +1,48 @@
+
+ */
+final class WildcardListOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface
+{
+ use SingletonInstanceTrait;
+
+ /**
+ * @return iterable>
+ */
+ public function validValues(): iterable
+ {
+ return [];
+ }
+
+ public function invalidRawValues(): iterable
+ {
+ return [];
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php
new file mode 100644
index 0000000..ed3f4e4
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php
@@ -0,0 +1,485 @@
+> $callToGetActualDataSets
+ */
+ public function assertCombinationWithHelperDimension(array $testDimensionValues, bool $onlyFirstValueCombinable, callable $callToGetActualDataSets): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $actualDataSets = IterableUtil::toMap($callToGetActualDataSets());
+ $dbgCtx->add(compact('actualDataSets'));
+
+ $expectedDataSets = [];
+ if (!(ArrayUtilForTests::isEmpty($testDimensionValues) || ArrayUtilForTests::isEmpty(self::HELPER_DIMENSION_VALUES))) {
+ if ($onlyFirstValueCombinable) {
+ foreach ($testDimensionValues as $testDimensionValue) {
+ $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValue, self::HELPER_DIMENSION_KEY => self::HELPER_DIMENSION_VALUES[0]];
+ }
+ $firstSkipped = false;
+ foreach (self::HELPER_DIMENSION_VALUES as $helperDimensionValue) {
+ if (!$firstSkipped) {
+ $firstSkipped = true;
+ continue;
+ }
+ $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValues[0], self::HELPER_DIMENSION_KEY => $helperDimensionValue];
+ }
+ } else {
+ foreach ($testDimensionValues as $testDimensionValue) {
+ foreach (self::HELPER_DIMENSION_VALUES as $helperDimensionValue) {
+ $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValue, self::HELPER_DIMENSION_KEY => $helperDimensionValue];
+ }
+ }
+ }
+ }
+ $dbgCtx->add(compact('expectedDataSets'));
+
+ $expectedIterator = IterableUtil::iterableToIterator($expectedDataSets);
+ $expectedIterator->rewind();
+ $actualIterator = IterableUtil::iterableToIterator($actualDataSets);
+ $actualIterator->rewind();
+
+ while (true) {
+ if (!$expectedIterator->valid()) {
+ self::assertFalse($actualIterator->valid());
+ break;
+ }
+ $expectedDataSet = $expectedIterator->current();
+ $expectedIterator->next();
+ $actualDataSetDesc = $actualIterator->key();
+ $actualDataSet = $actualIterator->current();
+ $actualIterator->next();
+ $dbgCtx->add(compact('expectedDataSet', 'actualDataSetDesc', 'actualDataSet'));
+ AssertEx::arrayHasKeyWithSameValue(self::TEST_DIMENSION_KEY, $expectedDataSet[self::TEST_DIMENSION_KEY], $actualDataSet);
+ AssertEx::arrayHasKeyWithSameValue(self::HELPER_DIMENSION_KEY, $expectedDataSet[self::HELPER_DIMENSION_KEY], $actualDataSet);
+ }
+ }
+
+ /**
+ * @param array|callable(): iterable $values
+ *
+ * @noinspection PhpSameParameterValueInspection
+ */
+ private static function addKeyedDimension(DataProviderForTestBuilder $builder, string $dimensionKey, bool $onlyFirstValueCombinable, array|callable $values): void
+ {
+ if ($onlyFirstValueCombinable) {
+ $builder->addKeyedDimensionOnlyFirstValueCombinable($dimensionKey, $values);
+ } else {
+ $builder->addKeyedDimensionAllValuesCombinable($dimensionKey, $values);
+ }
+ }
+
+ private static function addHelperKeyedDimension(DataProviderForTestBuilder $builder, bool $onlyFirstValueCombinable): void
+ {
+ self::addKeyedDimension($builder, self::HELPER_DIMENSION_KEY, $onlyFirstValueCombinable, self::HELPER_DIMENSION_VALUES);
+ }
+
+ /**
+ * @noinspection PhpSameParameterValueInspection
+ */
+ private static function addNullableBoolKeyedDimension(DataProviderForTestBuilder $builder, string $dimensionKey, bool $onlyFirstValueCombinable): void
+ {
+ if ($onlyFirstValueCombinable) {
+ $builder->addNullableBoolKeyedDimensionOnlyFirstValueCombinable($dimensionKey);
+ } else {
+ $builder->addNullableBoolKeyedDimensionAllValuesCombinable($dimensionKey);
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderOneBoolArg
+ */
+ public function testAddNullableBoolKeyedDimension(bool $onlyFirstValueCombinable): void
+ {
+ $builder = new DataProviderForTestBuilder();
+
+ self::addNullableBoolKeyedDimension($builder, self::TEST_DIMENSION_KEY, $onlyFirstValueCombinable);
+ self::addHelperKeyedDimension($builder, $onlyFirstValueCombinable);
+ $actual = $builder->buildAsMultiUse();
+
+ self::assertCombinationWithHelperDimension(BoolUtil::ALL_NULLABLE_VALUES, $onlyFirstValueCombinable, $actual);
+ }
+
+ public function testOneList(): void
+ {
+ $inputList = ['a', 'b', 'c'];
+ $expected = IterableUtil::toList(CombinatorialUtil::cartesianProduct([$inputList]));
+ AssertEx::equalAsSets([['a'], ['b'], ['c']], $expected);
+ foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable) {
+ $actual = IterableUtil::toList(
+ $onlyFirstValueCombinable
+ ? (new DataProviderForTestBuilder())
+ ->addDimensionOnlyFirstValueCombinable($inputList)
+ ->build()
+ : (new DataProviderForTestBuilder())
+ ->addDimensionAllValuesCombinable($inputList)
+ ->build()
+ );
+ AssertEx::equalAsSets($expected, $actual);
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTwoBoolArgs(): iterable
+ {
+ foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable1) {
+ foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable2) {
+ yield [$onlyFirstValueCombinable1, $onlyFirstValueCombinable2];
+ }
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTwoBoolArgs
+ *
+ * @param bool $onlyFirstValueCombinable1
+ * @param bool $onlyFirstValueCombinable2
+ */
+ public function testTwoLists(bool $onlyFirstValueCombinable1, bool $onlyFirstValueCombinable2): void
+ {
+ $inputList1 = ['a', 'b'];
+ $inputList2 = [1, 2, 3];
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addDimension($onlyFirstValueCombinable1, $inputList1)
+ ->addDimension($onlyFirstValueCombinable2, $inputList2)
+ ->build()
+ );
+ if ($onlyFirstValueCombinable1 && $onlyFirstValueCombinable2) {
+ $expected = [
+ ['a', 1],
+ ['b', 1],
+ ['a', 2],
+ ['a', 3],
+ ];
+ } else {
+ $expected = IterableUtil::toList(
+ CombinatorialUtil::cartesianProduct([$inputList1, $inputList2])
+ );
+ }
+ AssertEx::equalAsSets(
+ $expected,
+ $actual,
+ LoggableToString::convert(
+ [
+ 'onlyFirstValueCombinable1' => $onlyFirstValueCombinable1,
+ 'onlyFirstValueCombinable2' => $onlyFirstValueCombinable2,
+ '$expected' => $expected,
+ 'actual' => $actual,
+ ]
+ )
+ );
+ }
+
+ /**
+ * @dataProvider dataProviderForTwoBoolArgs
+ *
+ * @param bool $disableInstrumentationsOnlyFirstValueCombinable
+ * @param bool $dbNameOnlyFirstValueCombinable
+ */
+ public function testOneGeneratorAddsMultipleDimensions(
+ bool $disableInstrumentationsOnlyFirstValueCombinable,
+ bool $dbNameOnlyFirstValueCombinable
+ ): void {
+ $disableInstrumentationsVariants = [
+ '' => true,
+ 'pdo' => false,
+ 'db' => false,
+ ];
+ $dbNameVariants = ['memory', '/tmp/file'];
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addGenerator(
+ $disableInstrumentationsOnlyFirstValueCombinable,
+ /**
+ * @param array $resultSoFar
+ *
+ * @return iterable>
+ */
+ function (array $resultSoFar) use ($disableInstrumentationsVariants): iterable {
+ foreach ($disableInstrumentationsVariants as $optVal => $isInstrumentationEnabled) {
+ yield array_merge($resultSoFar, [$optVal, $isInstrumentationEnabled]);
+ }
+ }
+ )
+ ->addDimension($dbNameOnlyFirstValueCombinable, $dbNameVariants)
+ ->build()
+ );
+
+ $disableInstrumentationsVariantsPairs = [];
+ foreach ($disableInstrumentationsVariants as $optVal => $isInstrumentationEnabled) {
+ $disableInstrumentationsVariantsPairs[] = [$optVal, $isInstrumentationEnabled];
+ }
+ $cartesianProductPacked = IterableUtil::toList(
+ CombinatorialUtil::cartesianProduct([$disableInstrumentationsVariantsPairs, $dbNameVariants])
+ );
+ // Unpack $disableInstrumentationsVariants pair in each row
+ $cartesianProduct = [];
+ foreach ($cartesianProductPacked as $cartesianProductPackedRow) {
+ self::assertIsArray($cartesianProductPackedRow); // @phpstan-ignore staticMethod.alreadyNarrowedType
+ self::assertCount(2, $cartesianProductPackedRow);
+ $pair = $cartesianProductPackedRow[0];
+ self::assertIsArray($pair);
+ self::assertCount(2, $pair);
+ $cartesianProductRow = [];
+ $cartesianProductRow[] = $pair[0];
+ $cartesianProductRow[] = $pair[1];
+ $cartesianProductRow[] = $cartesianProductPackedRow[1];
+ $cartesianProduct[] = $cartesianProductRow;
+ }
+
+ if ($disableInstrumentationsOnlyFirstValueCombinable && $dbNameOnlyFirstValueCombinable) {
+ $expected = [
+ ['', true, 'memory'],
+ ['pdo', false, 'memory'],
+ ['db', false, 'memory'],
+ ['', true, '/tmp/file'],
+ ];
+ } else {
+ $expected = $cartesianProduct;
+ }
+
+ AssertEx::equalAsSets(
+ $expected,
+ $actual,
+ LoggableToString::convert(
+ [
+ 'disableInstrumentationsOnlyFirstValueCombinable'
+ => $disableInstrumentationsOnlyFirstValueCombinable,
+ 'dbNameOnlyFirstValueCombinable' => $dbNameOnlyFirstValueCombinable,
+ '$expected' => $expected,
+ 'actual' => $actual,
+ ]
+ )
+ );
+ }
+
+ /**
+ * @dataProvider dataProviderForTwoBoolArgs
+ *
+ * @param bool $onlyFirstValueCombinable1
+ * @param bool $onlyFirstValueCombinable2
+ */
+ public function testTwoKeyedDimensions(bool $onlyFirstValueCombinable1, bool $onlyFirstValueCombinable2): void
+ {
+ $inputList1 = ['a', 'b'];
+ $inputList2 = [1, 2, 3];
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addKeyedDimension('letter', $onlyFirstValueCombinable1, $inputList1)
+ ->addKeyedDimension('digit', $onlyFirstValueCombinable2, $inputList2)
+ ->build()
+ );
+ if ($onlyFirstValueCombinable1 && $onlyFirstValueCombinable2) {
+ $expected = [
+ ['letter' => 'a', 'digit' => 1],
+ ['letter' => 'b', 'digit' => 1],
+ ['letter' => 'a', 'digit' => 2],
+ ['letter' => 'a', 'digit' => 3],
+ ];
+ } else {
+ $expected = IterableUtil::toList(
+ CombinatorialUtil::cartesianProduct([$inputList1, $inputList2])
+ );
+ }
+ AssertEx::equalAsSets(
+ $expected,
+ $actual,
+ LoggableToString::convert(
+ [
+ 'onlyFirstValueCombinable1' => $onlyFirstValueCombinable1,
+ 'onlyFirstValueCombinable2' => $onlyFirstValueCombinable2,
+ '$expected' => $expected,
+ 'actual' => $actual,
+ ]
+ )
+ );
+ }
+
+ /**
+ * @dataProvider dataProviderOneBoolArg
+ */
+ public function testCartesianProductKeyed(bool $dimAOnlyFirstValueCombinable): void
+ {
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addKeyedDimension('dimA', $dimAOnlyFirstValueCombinable, [1.23, 4.56])
+ ->addCartesianProductOnlyFirstValueCombinable(['dimB' => [1, 2, 3], 'dimC' => ['a', 'b']])
+ ->build()
+ );
+ $expected = $dimAOnlyFirstValueCombinable
+ ?
+ [
+ ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'a'],
+ ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'b'],
+ ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'b'],
+ ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'b'],
+ ]
+ :
+ [
+ ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'a'],
+ ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'b'],
+ ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'b'],
+ ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'a'],
+ ['dimA' => 4.56, 'dimB' => 2, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'b'],
+ ['dimA' => 4.56, 'dimB' => 2, 'dimC' => 'b'],
+ ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'a'],
+ ['dimA' => 4.56, 'dimB' => 3, 'dimC' => 'a'],
+ ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'b'],
+ ['dimA' => 4.56, 'dimB' => 3, 'dimC' => 'b'],
+ ];
+ AssertEx::equalAsSets(
+ $expected,
+ $actual,
+ LoggableToString::convert(['$expected' => $expected, 'actual' => $actual])
+ );
+ }
+
+ /**
+ * @dataProvider dataProviderOneBoolArg
+ */
+ public function testCartesianProduct(bool $dimAOnlyFirstValueCombinable): void
+ {
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addDimension($dimAOnlyFirstValueCombinable, [1.23, 4.56])
+ ->addCartesianProductOnlyFirstValueCombinable([[1, 2, 3], ['a', 'b']])
+ ->build()
+ );
+ $expected = $dimAOnlyFirstValueCombinable
+ ?
+ [
+ [1.23, 1, 'a'],
+ [4.56, 1, 'a'],
+ [1.23, 1, 'b'],
+ [1.23, 2, 'a'],
+ [1.23, 2, 'b'],
+ [1.23, 3, 'a'],
+ [1.23, 3, 'b'],
+ ]
+ :
+ [
+ [1.23, 1, 'a'],
+ [4.56, 1, 'a'],
+ [1.23, 1, 'b'],
+ [4.56, 1, 'b'],
+ [1.23, 2, 'a'],
+ [4.56, 2, 'a'],
+ [1.23, 2, 'b'],
+ [4.56, 2, 'b'],
+ [1.23, 3, 'a'],
+ [4.56, 3, 'a'],
+ [1.23, 3, 'b'],
+ [4.56, 3, 'b'],
+ ];
+ AssertEx::equalAsSets(
+ $expected,
+ $actual,
+ LoggableToString::convert(['$expected' => $expected, 'actual' => $actual])
+ );
+ }
+
+ public function testConditional(): void
+ {
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addKeyedDimensionAllValuesCombinable('1st_dim_key', [1, 2])
+ ->addConditionalKeyedDimensionAllValueCombinable(
+ '2ns_dim_key' /* <- new dimension key */,
+ '1st_dim_key' /* <- depends on dimension key */,
+ 1 /* <- depends on dimension true value */,
+ ['a'] /* <- new dimension variants for true case */,
+ ['b', 'c'] /* <- new dimension variants for false case */
+ )
+ ->build()
+ );
+ $expected =
+ [
+ ['1st_dim_key' => 1, '2ns_dim_key' => 'a'],
+ ['1st_dim_key' => 2, '2ns_dim_key' => 'b'],
+ ['1st_dim_key' => 2, '2ns_dim_key' => 'c'],
+ ];
+ AssertEx::equalAsSets($expected, $actual);
+ }
+
+ /**
+ * @dataProvider dataProviderOneBoolArg
+ */
+ public function testUsingRangeForDimensionValues(bool $dimAOnlyFirstValueCombinable): void
+ {
+ $actual = IterableUtil::toList(
+ (new DataProviderForTestBuilder())
+ ->addKeyedDimension('1st_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2))
+ ->addKeyedDimension('2nd_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2))
+ ->addKeyedDimension('3rd_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2))
+ ->build()
+ );
+ $expected = $dimAOnlyFirstValueCombinable
+ ?
+ [
+ ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 1],
+ ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 0],
+ ]
+ :
+ [
+ ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 1],
+ ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 1],
+ ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 1],
+ ['1st_dim_key' => 1, '2ns_dim_key' => 1, '3rd_dim_key' => 0],
+ ['1st_dim_key' => 1, '2ns_dim_key' => 1, '3rd_dim_key' => 1],
+ ];
+ AssertEx::equalAsSets($expected, $actual);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php
new file mode 100644
index 0000000..4ba75e9
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php
@@ -0,0 +1,1173 @@
+>>
+ */
+class DebugContextTest extends TestCaseBase
+{
+ private const NON_VENDOR_CALLS_DEPTH_KEY = 'non_vendor_calls_depth';
+ private const USE_FAIL_TO_TRIGGER_ASSERTION_KEY = 'use_fail_to_trigger_assertion';
+ private const SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_top_non_vendor_call';
+ private const SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_middle_non_vendor_call';
+ private const SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_bottom_non_vendor_call';
+
+ #[Override]
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ DebugContextConfig::addToAssertionMessage(false);
+ }
+
+ private static function shortcutTestIfDebugContextIsDisabledByDefault(): bool
+ {
+ // Use BoolUtil::toString to avoid error "boolean expression is always false/true" from static analysis
+ if (BoolUtil::toString(DebugContextConfig::ENABLED_DEFAULT_VALUE) === 'false') {
+ self::dummyAssert();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param ?positive-int $line
+ */
+ private static function assertScopeDesc(string $actualScopeDesc, ?string $class = __CLASS__, ?string $function = null, ?string $file = __FILE__, ?int $line = null, string $message = ''): void
+ {
+ $classFuncPart = ($class ?? '') . ($class !== null && $function !== null ? '::' : '') . ($function ?? '');
+ if ($classFuncPart !== '') {
+ self::assertStringContainsString($classFuncPart, $actualScopeDesc, $message);
+ }
+
+ if ($file !== null) {
+ self::assertStringContainsString($file . ($line === null ? '' : (':' . $line)), $actualScopeDesc, $message);
+ }
+ }
+
+ /**
+ * @phpstan-param Context $actualCtx
+ * @phpstan-param Context $args
+ * @phpstan-param Context $addedCtx
+ */
+ private static function assertScopeContext(array $actualCtx, ?object $thisObj, array $args, array $addedCtx): void
+ {
+ $index = 0;
+ if (DebugContextConfig::autoCaptureThis() && ($thisObj !== null)) {
+ self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey));
+ self::assertSame(DebugContext::THIS_CONTEXT_KEY, $actualCtxKey);
+ self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue));
+ self::assertSame(LoggableToString::convert($thisObj), LoggableToString::convert($actualCtxValue));
+ ++$index;
+ } else {
+ self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $actualCtx);
+ }
+
+ if (DebugContextConfig::autoCaptureArgs()) {
+ foreach ($args as $argName => $argValue) {
+ self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey));
+ self::assertSame($argName, $actualCtxKey);
+ self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue));
+ self::assertSame(LoggableToString::convert($argValue), LoggableToString::convert($actualCtxValue));
+ ++$index;
+ }
+ } else {
+ foreach (IterableUtil::keys($args) as $argName) {
+ self::assertArrayNotHasKey($argName, $actualCtx);
+ }
+ }
+
+ foreach ($addedCtx as $addedCtxKey => $addedCtxValue) {
+ self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey));
+ self::assertSame($addedCtxKey, $actualCtxKey);
+ self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue));
+ self::assertSame(LoggableToString::convert($addedCtxValue), LoggableToString::convert($actualCtxValue));
+ ++$index;
+ }
+
+ self::assertCount($index, $actualCtx);
+ }
+
+ /**
+ * @param ExpectedContextsStack $expected
+ */
+ private static function assertCurrentContextsStack(array $expected): void
+ {
+ $actual = DebugContext::getContextsStack();
+
+ if (!DebugContextConfig::enabled()) {
+ self::assertEmpty($actual);
+ return;
+ }
+
+ /** @var array $dbgCtx */
+ $dbgCtx = [];
+ $dbgCtx['count(expected)'] = count($expected);
+ $dbgCtx['count(actual)'] = count($actual);
+ ArrayUtilForTests::append(compact('expected', 'actual'), /* in,out */ $dbgCtx);
+ self::assertSame(count($expected), count($actual), LoggableToString::convert($dbgCtx));
+ foreach (IterableUtil::zip(IterableUtil::keys($expected), IterableUtil::keys($actual)) as [$expectedCtxIndex, $actualCtxDesc]) {
+ /** @var int $expectedCtxIndex */
+ /** @var string $actualCtxDesc */
+ $dbgCtx = array_merge($dbgCtx, compact('expectedCtxIndex', 'actualCtxDesc'));
+ /** @var array{string, array} $expectedCtx */
+ $expectedCtxFuncName = $expected[$expectedCtxIndex]->first;
+ $expectedCtx = $expected[$expectedCtxIndex]->second;
+ $actualCtx = $actual[$actualCtxDesc];
+ $dbgCtx = array_merge($dbgCtx, compact('expectedCtxFuncName', 'expectedCtx', 'actualCtx'));
+ $dbgCtxStr = LoggableToString::convert($dbgCtx);
+ self::assertScopeDesc($actualCtxDesc, function: $expectedCtxFuncName, message: $dbgCtxStr);
+ self::assertStringContainsString($expectedCtxFuncName, $actualCtxDesc, $dbgCtxStr);
+ self::assertStringContainsString(basename(__FILE__) . ':', $actualCtxDesc, $dbgCtxStr);
+ self::assertStringContainsString(__CLASS__, $actualCtxDesc, $dbgCtxStr);
+ self::assertSame(count($expectedCtx), count($actualCtx), $dbgCtxStr);
+ foreach (IterableUtil::zip(IterableUtil::keys($expectedCtx), IterableUtil::keys($actualCtx)) as [$expectedKey, $actualKey]) {
+ $dbgCtx = array_merge($dbgCtx, compact('expectedKey', 'actualKey'));
+ $dbgCtxStr = LoggableToString::convert($dbgCtx);
+ self::assertSame($expectedKey, $actualKey, $dbgCtxStr);
+ self::assertSame($expectedCtx[$expectedKey], $actualCtx[$actualKey], $dbgCtxStr);
+ }
+ }
+ }
+
+ /**
+ * @phpstan-param Context $initialCtx
+ * @phpstan-param ExpectedContextsStack $expectedContextsStackFromCaller
+ *
+ * @return ExpectedContextsStack
+ */
+ private static function newExpectedScope(string $funcName, array $initialCtx = [], array $expectedContextsStackFromCaller = []): array
+ {
+ $expectedContextsStack = $expectedContextsStackFromCaller;
+ $newCount = array_unshift(/* ref */ $expectedContextsStack, new Pair($funcName, $initialCtx));
+ self::assertSame(count($expectedContextsStackFromCaller) + 1, $newCount);
+ self::assertCurrentContextsStack($expectedContextsStack);
+ return $expectedContextsStack;
+ }
+
+ /**
+ * @phpstan-param ExpectedContextsStack $expectedContextsStack
+ * @phpstan-param Context $ctx
+ */
+ private static function addToTopExpectedScope(/* ref */ array $expectedContextsStack, array $ctx): void
+ {
+ self::assertNotEmpty($expectedContextsStack);
+ DebugContextScope::appendContext(from: $ctx, to: $expectedContextsStack[0]->second);
+ self::assertCurrentContextsStack($expectedContextsStack);
+ }
+
+ private static function assertActualTopScopeHasKeyWithSameValue(string|int $expectedKey, mixed $expectedValue): void
+ {
+ $actualContextsStack = DebugContext::getContextsStack();
+ if (!DebugContextConfig::enabled()) {
+ self::assertEmpty($actualContextsStack);
+ return;
+ }
+
+ self::assertNotEmpty($actualContextsStack);
+ $actualTopScope = ArrayUtilForTests::getFirstValue($actualContextsStack);
+ AssertEx::arrayHasKeyWithSameValue($expectedKey, $expectedValue, $actualTopScope);
+ }
+
+ /** @noinspection PhpSameParameterValueInspection */
+ private static function assertActualTopScopeNotHasKey(string|int $expectedKey): void
+ {
+ $actualContextsStack = DebugContext::getContextsStack();
+ if (!DebugContextConfig::enabled()) {
+ self::assertEmpty($actualContextsStack);
+ return;
+ }
+
+ self::assertNotEmpty($actualContextsStack);
+ $actualTopScope = ArrayUtilForTests::getFirstValue($actualContextsStack);
+ self::assertArrayNotHasKey($expectedKey, $actualTopScope);
+ }
+
+ /**
+ * @param array $optionsToVariate
+ */
+ private static function dataProviderBuilderForDebugContextConfig(array $optionsToVariate): DataProviderForTestBuilder
+ {
+ $builder = new DataProviderForTestBuilder();
+ foreach ($optionsToVariate as $optionName) {
+ $builder->addKeyedDimensionAllValuesCombinable($optionName, BoolUtil::allValuesStartingFrom(DebugContextConfig::DEFAULT_VALUES[$optionName]));
+ }
+ return $builder;
+ }
+
+ private static function setDebugContextConfigFromTestArgs(MixedMap $testArgs): void
+ {
+ $config = DebugContextConfig::getCopy();
+ foreach ($testArgs as $testArgName => $testArgValue) {
+ if (array_key_exists($testArgName, DebugContextConfig::DEFAULT_VALUES)) {
+ self::assertIsBool($testArgValue);
+ $config[$testArgName] = $testArgValue;
+ }
+ }
+ DebugContextConfig::set($config);
+ }
+
+ private static function thisTestAssumesOnlyAddedContext(): void
+ {
+ // DebugContext by default should use all scopes not just the ones with added context
+ self::assertFalse(DebugContextConfig::onlyAddedContext());
+ // This test assumes that only scopes with added context are used
+ DebugContextConfig::onlyAddedContext(true);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForBasicDebugContextConfig(): iterable
+ {
+ return self::dataProviderBuilderForDebugContextConfig(
+ [
+ DebugContextConfig::ENABLED_OPTION_NAME,
+ DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME,
+ ]
+ )->buildAsMixedMaps();
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testOneFunctionOnlyAddedContext(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my_key' => 1]);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my_key' => 1]);
+ self::assertActualTopScopeHasKeyWithSameValue('my_key', 1);
+ $dbgCtx->add(['my_key' => '2']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my_key' => '2']);
+ self::assertActualTopScopeHasKeyWithSameValue('my_key', '2');
+ $dbgCtx->add(['my_other_key' => 3.5]);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my_other_key' => 3.5]);
+ self::assertActualTopScopeHasKeyWithSameValue('my_other_key', 3.5);
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testTwoFunctionsOnlyAddedContext(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'before func']);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'before func']);
+
+ /**
+ * @param ExpectedContextsStack $expectedContextsStackFromCaller
+ */
+ $secondFunc = static function (array $expectedContextsStackFromCaller): void {
+ /** @var ExpectedContextsStack $expectedContextsStackFromCaller */
+ DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'func entry']);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'func entry'], $expectedContextsStackFromCaller);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'func entry');
+
+ $dbgCtx->add(['some_other_key' => 'inside func']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['some_other_key' => 'inside func']);
+ self::assertActualTopScopeHasKeyWithSameValue('some_other_key', 'inside func');
+ };
+
+ $secondFunc($expectedContextsStack);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'before func');
+ self::assertActualTopScopeNotHasKey('some_other_key');
+
+ $dbgCtx->add(['my context' => 'after func']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after func']);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'after func');
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testSubScopeSimple(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__);
+ $dbgCtx->add(['my context' => 'before sub-scope']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before sub-scope']);
+
+ {
+ $dbgCtx->add(['my context' => 'inside sub-scope']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'inside sub-scope']);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'inside sub-scope');
+ }
+ self::assertCurrentContextsStack($expectedContextsStack);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'inside sub-scope');
+
+ $dbgCtx->add(['my context' => 'after sub-scope']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after sub-scope']);
+ self::assertActualTopScopeHasKeyWithSameValue('my context', 'after sub-scope');
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testWithLoop(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__);
+ $dbgCtx->add(['my context' => 'before loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before loop']);
+
+ foreach (RangeUtil::generateUpTo(2) as $index) {
+ $dbgCtx->add(compact('index'));
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index'));
+ $dbgCtx->add(['key_with_index_' . $index => 'value_with_index_' . $index]);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['key_with_index_' . $index => 'value_with_index_' . $index]);
+ }
+
+ $dbgCtx->add(['my context' => 'after loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after loop']);
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testSubScopeForLoopWithContinue(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__);
+ $dbgCtx->add(['my context' => 'before 1st loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before 1st loop']);
+
+ foreach (RangeUtil::generateUpTo(3) as $index1stLoop) {
+ $dbgCtx->add(compact('index1stLoop'));
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index1stLoop'));
+
+ $dbgCtx->add(['my context' => 'before 1st loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before 1st loop']);
+
+ $dbgCtx->add(['1st loop key with index ' . $index1stLoop => '1st loop value with index ' . $index1stLoop]);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['1st loop key with index ' . $index1stLoop => '1st loop value with index ' . $index1stLoop]);
+
+ foreach (RangeUtil::generateUpTo(5) as $index2ndLoop) {
+ $dbgCtx->add(compact('index2ndLoop'));
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index2ndLoop'));
+
+ if ($index2ndLoop > 2) {
+ continue;
+ }
+
+ $dbgCtx->add(['2nd loop key with index ' . $index2ndLoop => '2nd loop value with index ' . $index2ndLoop]);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['2nd loop key with index ' . $index2ndLoop => '2nd loop value with index ' . $index2ndLoop]);
+ }
+
+ if ($index1stLoop > 1) {
+ continue;
+ }
+
+ $dbgCtx->add(['my context' => 'after 2nd loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after 2nd loop']);
+ }
+
+ $dbgCtx->add(['my context' => 'after 1st loop']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after 1st loop']);
+ }
+
+ /**
+ * @param ExpectedContextsStack $expectedContextsStackFromCaller
+ */
+ private static function recursiveFunc(int $currentDepth, array $expectedContextsStackFromCaller): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'inside recursive func before recursive call']);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'inside recursive func before recursive call'], $expectedContextsStackFromCaller);
+
+ $dbgCtx->add(['key for depth ' . $currentDepth => 'value for depth ' . $currentDepth]);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['key for depth ' . $currentDepth => 'value for depth ' . $currentDepth]);
+
+ if ($currentDepth < 3) {
+ self::recursiveFunc($currentDepth + 1, $expectedContextsStack);
+ }
+
+ $assertMsgCtx = compact('currentDepth', 'expectedContextsStack');
+ $depth = $currentDepth;
+ foreach (DebugContext::getContextsStack() as $actualCtxDesc => $actualCtx) {
+ $assertMsgCtx = array_merge($assertMsgCtx, compact('depth', 'actualCtxDesc', 'actualCtx'));
+ self::assertStringContainsString(__FUNCTION__, $actualCtxDesc, LoggableToString::convert($assertMsgCtx));
+ self::assertStringContainsString(basename(__FILE__), $actualCtxDesc, LoggableToString::convert($assertMsgCtx));
+ self::assertStringContainsString(__CLASS__, $actualCtxDesc, LoggableToString::convert($assertMsgCtx));
+
+ self::assertSame('inside recursive func before recursive call', $actualCtx['my context'], LoggableToString::convert($assertMsgCtx));
+ self::assertSame('value for depth ' . $depth, $actualCtx['key for depth ' . $depth], LoggableToString::convert($assertMsgCtx));
+
+ if ($depth === 1) {
+ break;
+ }
+ --$depth;
+ }
+
+ $dbgCtx->add(['my context' => 'inside recursive func after recursive call']);
+ self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'inside recursive func after recursive call']);
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testRecursiveFunc(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'before recursive func']);
+ $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'before recursive func']);
+
+ self::recursiveFunc(1, $expectedContextsStack);
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testCaptureVarByRef(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $localVar = 1;
+ // NOTE: $localVar is added to debug context by reference below
+ $dbgCtx->add(['localVar' => &$localVar]);
+
+ $localVar = 2;
+
+ $capturedCtxStack = DebugContext::getContextsStack();
+ if (DebugContextConfig::enabled()) {
+ self::assertCount(1, $capturedCtxStack);
+ $thisFuncCtx = ArrayUtilForTests::getFirstValue($capturedCtxStack);
+ self::assertArrayHasKey('localVar', $thisFuncCtx);
+ self::assertSame(2, $thisFuncCtx['localVar']);
+ } else {
+ self::assertEmpty($capturedCtxStack);
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestFailedAssertionMessageContainsDebugContext(): iterable
+ {
+ return self::dataProviderBuilderForDebugContextConfig(
+ [
+ DebugContextConfig::ENABLED_OPTION_NAME,
+ DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME,
+ DebugContextConfig::ADD_TO_ASSERTION_MESSAGE_OPTION_NAME,
+ ]
+ )->buildAsMixedMaps();
+ }
+
+ /**
+ * @dataProvider dataProviderForTestFailedAssertionMessageContainsDebugContext
+ */
+ public function testFailedAssertionMessageContainsDebugContext(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+ self::thisTestAssumesOnlyAddedContext();
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ $dbgCtx->add(['myLocalVar' => 'my localVar value']);
+
+ $exceptionMsg = null;
+ try {
+ self::fail();
+ } catch (AssertionFailedError $ex) {
+ $exceptionMsg = $ex->getMessage();
+ }
+
+ $expectedMessageContains = DebugContextConfig::enabled() && DebugContextConfig::addToAssertionMessage();
+ $helperCtxStr = LoggableToString::convert(compact('expectedMessageContains', 'exceptionMsg'));
+ self::assertSame($expectedMessageContains, str_contains($exceptionMsg, 'myLocalVar'), $helperCtxStr);
+ self::assertSame($expectedMessageContains, str_contains($exceptionMsg, 'my localVar value'), $helperCtxStr);
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testLineInScopeDescUpdated(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ // Add dummy context entry to ensure this scope is included
+ $dbgCtx->add(['dummy key' => 'dummy value']);
+
+ /**
+ * @phpstan-param positive-int $expectedLine
+ * @phpstan-param ContextsStack $contextsStack
+ */
+ $assertTopScopeLine = function (int $expectedLine): void {
+ $contextsStack = DebugContext::getContextsStack();
+ if (DebugContextConfig::enabled()) {
+ if (DebugContextConfig::onlyAddedContext()) {
+ $testFuncScopeDesc = array_key_first($contextsStack);
+ } else {
+ self::assertTrue(IterableUtil::getNthKey($contextsStack, 1, /* out */ $testFuncScopeDesc));
+ }
+ self::assertIsString($testFuncScopeDesc);
+ self::assertStringContainsString(__FILE__ . ':' . $expectedLine, $testFuncScopeDesc);
+ } else {
+ self::assertEmpty($contextsStack);
+ }
+ };
+
+ $assertTopScopeLine(__LINE__);
+ $assertTopScopeLine(__LINE__);
+ }
+
+ /**
+ * @param int $intParam
+ * @param ?string $nullableStringParam
+ *
+ * @return ContextsStack
+ *
+ * @noinspection PhpUnusedParameterInspection
+ */
+ private static function helperFuncForTestAutoCaptureArgs(int $intParam, ?string $nullableStringParam): array
+ {
+ return DebugContext::getContextsStack();
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestAutoCaptureArgs(): iterable
+ {
+ $dummyArgsTuples = [
+ ['intParam' => 1, 'nullableStringParam' => 'abc'],
+ ['intParam' => 2, 'nullableStringParam' => null],
+ ];
+
+ /**
+ * @param array $resultSoFar
+ *
+ * @return iterable>
+ */
+ $addDummyArgs = function (array $resultSoFar) use ($dummyArgsTuples): iterable {
+ foreach ($dummyArgsTuples as $dummyArgsTuple) {
+ yield array_merge($resultSoFar, $dummyArgsTuple);
+ }
+ };
+
+ $optionsToVariate = [
+ DebugContextConfig::ENABLED_OPTION_NAME,
+ DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME,
+ DebugContextConfig::ONLY_ADDED_CONTEXT_OPTION_NAME,
+ DebugContextConfig::AUTO_CAPTURE_THIS_OPTION_NAME,
+ DebugContextConfig::AUTO_CAPTURE_ARGS_OPTION_NAME,
+ ];
+ return self::dataProviderBuilderForDebugContextConfig($optionsToVariate)
+ ->addGeneratorOnlyFirstValueCombinable($addDummyArgs)
+ ->buildAsMixedMaps();
+ }
+
+ /**
+ * @dataProvider dataProviderForTestAutoCaptureArgs
+ */
+ public function testAutoCaptureArgs(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $myDummyLocalVar = 'my dummy local var value';
+ $dbgCtx->add(compact('myDummyLocalVar'));
+
+ $actualContextsStack = self::helperFuncForTestAutoCaptureArgs($testArgs->getInt('intParam'), $testArgs->getNullableString('nullableStringParam'));
+
+ if (!DebugContextConfig::enabled()) {
+ self::assertEmpty($actualContextsStack);
+ return;
+ }
+ AssertEx::countAtLeast(DebugContextConfig::onlyAddedContext() ? 1 : 2, $actualContextsStack);
+
+ if (!DebugContextConfig::onlyAddedContext()) {
+ $helperFuncCtx = ArrayUtilForTests::getFirstValue($actualContextsStack);
+ // helperFuncForTestAutoCaptureArgs is static so there is no $this
+ self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $helperFuncCtx);
+ foreach (['intParam', 'nullableStringParam'] as $argName) {
+ if (DebugContextConfig::autoCaptureArgs()) {
+ AssertEx::arrayHasKeyWithSameValue($argName, $testArgs->get($argName), $helperFuncCtx);
+ } else {
+ self::assertArrayNotHasKey($argName, $helperFuncCtx);
+ }
+ }
+ }
+
+ self::assertTrue(IterableUtil::getNthValue($actualContextsStack, DebugContextConfig::onlyAddedContext() ? 0 : 1, /* out */ $thisFuncCtx));
+ self::assertIsArray($thisFuncCtx);
+ AssertEx::arrayHasKeyWithSameValue('myDummyLocalVar', $myDummyLocalVar, $thisFuncCtx);
+ if (!DebugContextConfig::onlyAddedContext() && DebugContextConfig::autoCaptureThis()) {
+ AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $thisFuncCtx);
+ } else {
+ self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $thisFuncCtx);
+ }
+ if (!DebugContextConfig::onlyAddedContext() && DebugContextConfig::autoCaptureArgs()) {
+ AssertEx::arrayHasKeyWithSameValue('testArgs', $testArgs, $thisFuncCtx);
+ } else {
+ self::assertArrayNotHasKey('testArgs', $thisFuncCtx);
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForBasicDebugContextConfig
+ */
+ public function testTheSameClosureCalledTwiceOnTheSameLine(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+
+ $capturedVar = 0;
+ /** @var list $contextsStacks */
+ $contextsStacks = [];
+ $closure = function () use (&$capturedVar, &$contextsStacks): int {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $dbgCtx->add(['ctx_' . $capturedVar => 'value for ctx_' . $capturedVar]);
+ $contextsStacks[] = DebugContext::getContextsStack();
+ return $capturedVar++;
+ };
+
+ $thisFuncLine = __LINE__ + 1;
+ self::assertSame(1, $closure() + $closure());
+
+ self::assertCount(2, $contextsStacks);
+ foreach ([0, 1] as $capturedVarVal) {
+ $contextsStack = $contextsStacks[$capturedVarVal];
+
+ if (!DebugContextConfig::enabled()) {
+ self::assertEmpty($contextsStack);
+ continue;
+ }
+
+ self::assertTrue(IterableUtil::getNthKey($contextsStack, 0, /* out */ $closureCtxDesc));
+ /** @var string $closureCtxDesc */
+ self::assertStringContainsString(__FILE__ . ':', $closureCtxDesc);
+ $closureCtx = ArrayUtilForTests::getFirstValue($contextsStack);
+ self::assertCount(2, $closureCtx);
+ AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $closureCtx);
+ AssertEx::arrayHasKeyWithSameValue('ctx_' . $capturedVarVal, 'value for ctx_' . $capturedVarVal, $closureCtx);
+
+ self::assertTrue(IterableUtil::getNthKey($contextsStack, 1, /* out */ $thisFuncCtxDesc));
+ /** @var string $thisFuncCtxDesc */
+ self::assertScopeDesc($thisFuncCtxDesc, function: __FUNCTION__, line: $thisFuncLine);
+ self::assertTrue(IterableUtil::getNthValue($contextsStack, 1, /* out */ $thisFuncCtx));
+ /** @var Context $thisFuncCtx */
+ self::assertCount(2, $thisFuncCtx);
+ AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $thisFuncCtx);
+ AssertEx::arrayHasKeyWithSameValue('testArgs', $testArgs, $thisFuncCtx);
+ }
+ }
+
+ private static function extractTextAddedToAssertionMessage(string $exceptionMsg): string
+ {
+ $prefixPos = strpos($exceptionMsg, DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX);
+ self::assertNotFalse($prefixPos);
+ $afterPrefixPos = $prefixPos + strlen(DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX);
+ $suffixPos = strpos($exceptionMsg, DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_SUFFIX, offset: $afterPrefixPos);
+ self::assertNotFalse($suffixPos);
+ return trim(substr($exceptionMsg, $afterPrefixPos, $suffixPos - $afterPrefixPos));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestTrimVendorFrames(): iterable
+ {
+ $optionsToVariate = [
+ DebugContextConfig::TRIM_VENDOR_FRAMES_OPTION_NAME,
+ ];
+ return self::dataProviderBuilderForDebugContextConfig($optionsToVariate)
+ ->addKeyedDimensionAllValuesCombinable(self::NON_VENDOR_CALLS_DEPTH_KEY, DataProviderForTestBuilder::rangeFromToIncluding(1, 6))
+ ->addBoolKeyedDimensionAllValuesCombinable(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY)
+ ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY)
+ ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY)
+ // Use Assert::assertXYZ with all the combinations in other dimensions and Assert::fail only with the first tuple of the combinations in other dimensions
+ ->addKeyedDimensionOnlyFirstValueCombinable(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY, BoolUtil::allValuesStartingFrom(false))
+ ->buildAsMixedMaps();
+ }
+
+ private const HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME = 'helperFuncForTestTrimVendorFrames';
+
+ /**
+ * @param positive-int &$actualNonVendorCallDepth
+ * @param ?positive-int &$lineNumber
+ *
+ * @param-out positive-int $actualNonVendorCallDepth
+ * @param-out positive-int $lineNumber
+ */
+ private static function helperFuncForTestTrimVendorFrames(MixedMap $testArgs, /* out */ ?int &$lineNumber, /* in,out */ int &$actualNonVendorCallDepth): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ self::assertNull($lineNumber);
+ self::assertGreaterThanOrEqual(1, $actualNonVendorCallDepth);
+ self::assertSame(self::HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME, __FUNCTION__); // @phpstan-ignore staticMethod.alreadyNarrowedType
+
+ ++$actualNonVendorCallDepth;
+
+ $shouldTopNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY);
+ $useFailToTriggerAssertion = $testArgs->getBool(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY);
+
+ if ($shouldTopNonVendorCallAddContext) {
+ $dbgCtx->add(['dummy ctx in top non-vendor call' => '[value for dummy ctx in top non-vendor call]']);
+ }
+
+ /** @noinspection PhpIfWithCommonPartsInspection */
+ if ($useFailToTriggerAssertion) {
+ $lineNumber = __LINE__ + 1;
+ self::fail('Dummy message');
+ } else {
+ $lineNumber = __LINE__ + 1;
+ self::assertSame(1, 2); // @phpstan-ignore staticMethod.impossibleType
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestTrimVendorFrames
+ */
+ public function testTrimVendorFrames(MixedMap $testArgs): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ self::setDebugContextConfigFromTestArgs($testArgs);
+
+ // This test extracts debug context from text added to assertion message
+ DebugContextConfig::addToAssertionMessage(true);
+
+ $nonVendorCallsDepth = $testArgs->getInt(self::NON_VENDOR_CALLS_DEPTH_KEY);
+ AssertEx::inClosedRange(1, $nonVendorCallsDepth, 6);
+ /** @var int<1, 6> $nonVendorCallsDepth */
+ $shouldTopNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY);
+ $shouldMiddleNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY);
+ $shouldBottomNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY);
+ $useFailToTriggerAssertion = $testArgs->getBool(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY);
+
+ if ($shouldBottomNonVendorCallAddContext) {
+ $dbgCtx->add(['dummy ctx in bottom non-vendor call' => '[value for dummy ctx in bottom non-vendor call]']);
+ }
+
+ $actualNonVendorCallDepth = 1;
+
+ /** @var ?positive-int $helperFuncLine */
+ $helperFuncLine = null;
+ /** @var ?positive-int $closureCallingHelperFuncLine */
+ $closureCallingHelperFuncLine = null;
+ $closureCallingHelperFunc = static function () use ($testArgs, /* out */ &$helperFuncLine, /* out */ &$closureCallingHelperFuncLine, /* in,out */ &$actualNonVendorCallDepth): void {
+ ++$actualNonVendorCallDepth;
+ self::assertNull($closureCallingHelperFuncLine);
+ $closureCallingHelperFuncLine = __LINE__ + 1;
+ self::helperFuncForTestTrimVendorFrames($testArgs, /* out */ $helperFuncLine, /* in,out */ $actualNonVendorCallDepth);
+ };
+
+ /** @var ?positive-int $closureCallingDummyFuncWithoutNamespaceLine */
+ $closureCallingDummyFuncWithoutNamespaceLine = null;
+ $closureCallingDummyFuncWithoutNamespace = function () use (
+ $closureCallingHelperFunc,
+ $shouldMiddleNonVendorCallAddContext,
+ &$closureCallingDummyFuncWithoutNamespaceLine,
+ &$actualNonVendorCallDepth,
+ ): void {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ if ($shouldMiddleNonVendorCallAddContext) {
+ $dbgCtx->add(['dummy ctx in middle non-vendor call' => '[value for dummy ctx in middle non-vendor call]']);
+ }
+
+ // Add 2 because it's this closure and dummyFuncForTestsWithoutNamespace
+ $actualNonVendorCallDepth += 2;
+ self::assertNull($closureCallingDummyFuncWithoutNamespaceLine);
+ $closureCallingDummyFuncWithoutNamespaceLine = __LINE__ + 1;
+ dummyFuncForTestsWithoutNamespace($closureCallingHelperFunc);
+ };
+
+ /** @var ?string $assertionMsg */
+ $assertionMsg = null;
+ /** @var ?positive-int $testFuncLine */
+ $testFuncLine = null;
+ try {
+ switch ($nonVendorCallsDepth) {
+ case 1:
+ // [testFunc]
+ /** @noinspection PhpIfWithCommonPartsInspection */
+ if ($useFailToTriggerAssertion) {
+ $testFuncLine = __LINE__ + 1;
+ self::fail('Dummy message');
+ } else {
+ $testFuncLine = __LINE__ + 1;
+ self::assertSame(1, 2); // @phpstan-ignore staticMethod.impossibleType
+ }
+ break;
+ case 2:
+ // [testFunc, helperFunc]
+ $testFuncLine = __LINE__ + 1;
+ self::helperFuncForTestTrimVendorFrames($testArgs, /* out */ $helperFuncLine, /* in,out */ $actualNonVendorCallDepth);
+ break;
+ case 3:
+ // [testFunc, closureCallingHelperFunc, helperFunc]
+ $testFuncLine = __LINE__ + 1;
+ $closureCallingHelperFunc();
+ break;
+ case 4:
+ ++$actualNonVendorCallDepth; // to account for dummyFuncForTestsWithoutNamespace call
+ // [testFunc, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+ $testFuncLine = __LINE__ + 1;
+ dummyFuncForTestsWithoutNamespace($closureCallingHelperFunc);
+ break;
+ case 5:
+ // [testFunc, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+ $testFuncLine = __LINE__ + 1;
+ $closureCallingDummyFuncWithoutNamespace();
+ break;
+ case 6:
+ // [testFunc, dummyFuncForTestsWithNamespace, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+ ++$actualNonVendorCallDepth; // to account for dummyFuncForTestsWithNamespace call
+ $testFuncLine = __LINE__ + 1;
+ dummyFuncForTestsWithNamespace($closureCallingDummyFuncWithoutNamespace);
+ break;
+ default:
+ throw new RuntimeException('Unexpected nonVendorCallsDepth value: ' . $nonVendorCallsDepth);
+ }
+ } catch (AssertionFailedError $ex) {
+ $assertionMsg = $ex->getMessage();
+ }
+ $dbgCtx->add(compact('assertionMsg'));
+ self::assertNotNull($assertionMsg);
+ self::assertNotNull($testFuncLine);
+ self::assertSame($nonVendorCallsDepth, $actualNonVendorCallDepth);
+
+ $addedText = self::extractTextAddedToAssertionMessage($assertionMsg);
+
+ if (!DebugContextConfig::enabled()) {
+ self::assertSame(DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_WHEN_DISABLED, $addedText);
+ return;
+ }
+
+ $decodedContextsStack = JsonUtil::decode($addedText, asAssocArray: true);
+ self::assertIsArray($decodedContextsStack);
+
+ // Contexts in $decodedContextsStack is the order of the top call being at index 0
+ if (DebugContextConfig::trimVendorFrames()) {
+ self::assertCount($nonVendorCallsDepth + 1, $decodedContextsStack);
+ $testFuncCtxIndex = $nonVendorCallsDepth;
+ } else {
+ // ... calls ... ... calls ... Assert::xyz()
+ AssertEx::countAtLeast($nonVendorCallsDepth + 2, $decodedContextsStack);
+ $testFuncCtxIndex = null;
+ /** @var non-negative-int $scopeIndex */
+ /** @var string $scopeDesc */
+ foreach (IterableUtil::zipWithIndex(IterableUtil::keys($decodedContextsStack)) as [$scopeIndex, $scopeDesc]) {
+ if (str_contains(haystack: $scopeDesc, needle: __FUNCTION__)) {
+ $testFuncCtxIndex = $scopeIndex;
+ }
+ }
+ self::assertIsInt($testFuncCtxIndex);
+ }
+ self::assertGreaterThanOrEqual($nonVendorCallsDepth, $testFuncCtxIndex);
+
+ // 1: [testFunc]
+ // 2: [testFunc, helperFunc]
+ // 3: [testFunc, closureCallingHelperFunc, helperFunc]
+ // 4: [testFunc, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+ // 5: [testFunc, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+ // 6: [testFunc, dummyFuncForTestsWithNamespace, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc]
+
+ $scopeIndex = $testFuncCtxIndex;
+
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $testFuncScopeDesc));
+ self::assertIsString($testFuncScopeDesc);
+ self::assertScopeDesc($testFuncScopeDesc, function: __FUNCTION__, line: $testFuncLine);
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $testFuncScopeCtx));
+ self::assertIsArray($testFuncScopeCtx);
+ /** @var Context $testFuncScopeCtx */
+ $addedCtx = $shouldBottomNonVendorCallAddContext ? ['dummy ctx in bottom non-vendor call' => '[value for dummy ctx in bottom non-vendor call]'] : [];
+ self::assertScopeContext($testFuncScopeCtx, thisObj: $this, args: compact('testArgs'), addedCtx: $addedCtx);
+ --$scopeIndex;
+
+ if ($nonVendorCallsDepth >= 6) {
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithNamespaceScopeDesc));
+ self::assertIsString($dummyFuncForTestsWithNamespaceScopeDesc);
+ self::assertScopeDesc(
+ actualScopeDesc: $dummyFuncForTestsWithNamespaceScopeDesc,
+ class: null,
+ function: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_FUNCTION,
+ file: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_FILE,
+ line: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_CONTINUATION_CALL_LINE,
+ );
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithNamespaceScopeCtx));
+ self::assertIsArray($dummyFuncForTestsWithNamespaceScopeCtx);
+ /** @var Context $dummyFuncForTestsWithNamespaceScopeCtx */
+ // dummyFuncForTestsWithNamespace is freestanding function (i.e., not a function that is a method in a class)
+ self::assertScopeContext($dummyFuncForTestsWithNamespaceScopeCtx, thisObj: null, args: ['continuation' => $closureCallingDummyFuncWithoutNamespace], addedCtx: []);
+ --$scopeIndex;
+ }
+
+ if ($nonVendorCallsDepth >= 5) {
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $closureCallingDummyFuncWithoutNamespaceScopeDesc));
+ self::assertIsString($closureCallingDummyFuncWithoutNamespaceScopeDesc);
+ self::assertNotNull($closureCallingDummyFuncWithoutNamespaceLine);
+ self::assertScopeDesc($closureCallingDummyFuncWithoutNamespaceScopeDesc, line: $closureCallingDummyFuncWithoutNamespaceLine);
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $closureCallingDummyFuncWithoutNamespaceScopeCtx));
+ self::assertIsArray($closureCallingDummyFuncWithoutNamespaceScopeCtx);
+ /** @var Context $closureCallingDummyFuncWithoutNamespaceScopeCtx */
+ $addedCtx = $shouldMiddleNonVendorCallAddContext ? ['dummy ctx in middle non-vendor call' => '[value for dummy ctx in middle non-vendor call]'] : [];
+ self::assertScopeContext($closureCallingDummyFuncWithoutNamespaceScopeCtx, thisObj: $this, args: [], addedCtx: $addedCtx);
+ --$scopeIndex;
+ }
+
+ if ($nonVendorCallsDepth >= 4) {
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithoutNamespaceScopeDesc));
+ self::assertIsString($dummyFuncForTestsWithoutNamespaceScopeDesc);
+ self::assertScopeDesc(
+ actualScopeDesc: $dummyFuncForTestsWithoutNamespaceScopeDesc,
+ class: null,
+ function: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_FUNCTION,
+ file: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_FILE,
+ line: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_CONTINUATION_CALL_LINE,
+ );
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithoutNamespaceScopeCtx));
+ self::assertIsArray($dummyFuncForTestsWithoutNamespaceScopeCtx);
+ /** @var Context $dummyFuncForTestsWithoutNamespaceScopeCtx */
+ // dummyFuncForTestsWithoutNamespace is freestanding function (i.e., not a function that is a method in a class)
+ self::assertScopeContext($dummyFuncForTestsWithoutNamespaceScopeCtx, thisObj: null, args: ['continuation' => $closureCallingHelperFunc], addedCtx: []);
+ --$scopeIndex;
+ }
+
+ if ($nonVendorCallsDepth >= 3) {
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $closureCallingHelperFuncScopeDesc));
+ self::assertIsString($closureCallingHelperFuncScopeDesc);
+ self::assertNotNull($closureCallingHelperFuncLine);
+ self::assertScopeDesc($closureCallingHelperFuncScopeDesc, line: $closureCallingHelperFuncLine);
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $closureCallingHelperFuncScopeCtx));
+ self::assertIsArray($closureCallingHelperFuncScopeCtx);
+ /** @var Context $closureCallingHelperFuncScopeCtx */
+ // $closureCallingHelperFunc is static so there is no $this
+ self::assertScopeContext($closureCallingHelperFuncScopeCtx, thisObj: null, args: [], addedCtx: []);
+ --$scopeIndex;
+ }
+
+ if ($nonVendorCallsDepth >= 2) {
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $helperFuncScopeDesc));
+ self::assertIsString($helperFuncScopeDesc);
+ self::assertNotNull($helperFuncLine);
+ self::assertScopeDesc($helperFuncScopeDesc, function: self::HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME, line: $helperFuncLine);
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $helperFuncScopeCtx));
+ self::assertIsArray($helperFuncScopeCtx);
+ /** @var Context $helperFuncScopeCtx */
+ $helperFuncArgs = compact('testArgs');
+ $helperFuncArgs['lineNumber'] = $helperFuncLine;
+ $helperFuncArgs['actualNonVendorCallDepth'] = $nonVendorCallsDepth;
+ $addedCtx = $shouldTopNonVendorCallAddContext ? ['dummy ctx in top non-vendor call' => '[value for dummy ctx in top non-vendor call]'] : [];
+ // helperFuncForTestTrimVendorFrames is static so there is no $this
+ self::assertScopeContext($helperFuncScopeCtx, thisObj:null, args: $helperFuncArgs, addedCtx: $addedCtx);
+ --$scopeIndex;
+ }
+
+ self::assertSame($nonVendorCallsDepth, $testFuncCtxIndex - $scopeIndex);
+
+ self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $assertionFuncScopeDesc));
+ self::assertIsString($assertionFuncScopeDesc);
+ self::assertScopeDesc($assertionFuncScopeDesc, class: Assert::class, function: $useFailToTriggerAssertion ? 'fail' : 'assertSame', file: null);
+
+ self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $assertionFuncScopeCtx));
+ self::assertIsArray($assertionFuncScopeCtx);
+ /** @var Context $assertionFuncScopeCtx */
+ $assertionFuncArgs = $useFailToTriggerAssertion ? ['message' => 'Dummy message'] : ['expected' => 1, 'actual' => 2];
+ // Assert::assertXyz()/fail() is static method so there is no $this
+ self::assertScopeContext($assertionFuncScopeCtx, thisObj: null, args: $assertionFuncArgs, addedCtx: []);
+ }
+
+ private const HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT = 'helperFuncForTestAddedTextFormat';
+
+ /**
+ * @param list $listArg
+ * @param array $mapArg
+ * @param ?positive-int &$lineNumber
+ *
+ * @param-out positive-int $lineNumber
+ *
+ * @noinspection PhpUnusedParameterInspection
+ */
+ private static function helperFuncForTestAddedTextFormat(array $listArg, array $mapArg, /* out */ ?int &$lineNumber): void
+ {
+ self::assertNull($lineNumber);
+ self::assertSame(self::HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT, __FUNCTION__); // @phpstan-ignore staticMethod.alreadyNarrowedType
+
+ $lineNumber = __LINE__ + 1;
+ self::fail('Dummy message');
+ }
+
+ public static function testAddedTextFormat(): void
+ {
+ if (self::shortcutTestIfDebugContextIsDisabledByDefault()) {
+ return;
+ }
+
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+
+ // This test extracts debug context from text added to assertion message
+ DebugContextConfig::addToAssertionMessage(true);
+
+ $listArg = ['a', 1, null, 4.5];
+ $mapArg = ['list key' => $listArg, 'int key' => 2, 'null key' => null];
+ $helperFuncLine = null;
+ $assertionMsg = null;
+ try {
+ $thisFuncLine = __LINE__ + 1;
+ self::helperFuncForTestAddedTextFormat(listArg: $listArg, mapArg: $mapArg, /* out */ lineNumber: $helperFuncLine);
+ } catch (AssertionFailedError $ex) {
+ $assertionMsg = $ex->getMessage();
+ }
+ $dbgCtx->add(compact('assertionMsg'));
+ self::assertNotNull($assertionMsg);
+ self::assertNotNull($helperFuncLine); // @phpstan-ignore staticMethod.impossibleType
+ $actualAddedText = self::extractTextAddedToAssertionMessage($assertionMsg);
+
+ $phpUnitFrameworkAssertPhpFileFullPath = FileUtil::normalizePath(VendorDir::get() . FileUtil::adaptUnixDirectorySeparators('/phpunit/phpunit/src/Framework/Assert.php'));
+
+ // Extract line number in Framework/Assert.php
+ $strBeforeAssertPhpLine = JsonUtil::adaptStringToSearchInJson($phpUnitFrameworkAssertPhpFileFullPath . ':');
+ $dbgCtx->add(compact('strBeforeAssertPhpLine'));
+ self::assertNotFalse($strBeforeAssertPhpLinePos = strpos($actualAddedText, $strBeforeAssertPhpLine));
+ $assertPhpLineStrPos = $strBeforeAssertPhpLinePos + strlen($strBeforeAssertPhpLine);
+ self::assertGreaterThan(0, $assertPhpLineNumberStrLen = strspn($actualAddedText, '0123456789', $assertPhpLineStrPos));
+ $phpUnitFrameworkAssertPhpLine = substr($actualAddedText, $assertPhpLineStrPos, $assertPhpLineNumberStrLen);
+
+ $thisFileFullPath = __FILE__;
+ $thisClass = __CLASS__;
+ $thisFuncName = __FUNCTION__;
+ $helperFuncName = self::HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT;
+ $expectedAddedText = <<
+ */
+ public static function dataProviderForTestIsValidHexNumberString(): array
+ {
+ /** @noinspection SpellCheckingInspection */
+ return [
+ ['1234', 2, true],
+ ['1234', 3, false],
+ ['1234', 1, false],
+ ['abcdef', 3, true],
+ ['AbCdEf', 3, true],
+ ['0123456789AbCdEf', 8, true],
+ ['abcd', 2, true],
+ ['zabc', 2, false],
+ ['abcz', 2, false],
+ ];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestIsValidHexNumberString
+ */
+ public function testIsValidHexNumberString(
+ string $numberAsString,
+ int $expectedSizeInBytes,
+ bool $expectedResult
+ ): void {
+ $actualResult = IdValidationUtil::isValidHexNumberString($numberAsString, $expectedSizeInBytes);
+ self::assertSame(
+ $expectedResult,
+ $actualResult,
+ LoggableToString::convert(
+ [
+ 'numberAsString' => $numberAsString,
+ 'expectedSizeInBytes' => $expectedSizeInBytes,
+ 'expectedResult' => $expectedResult,
+ '$actualResult' => $actualResult,
+ ]
+ )
+ );
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php
new file mode 100644
index 0000000..9744e2a
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php
@@ -0,0 +1,222 @@
+, 'upTo': non-negative-int}
+ * @phpstan-type TestTakeUpToArgs array{'input': TestTakeUpToInput, 'expectedOutput': array}
+ */
+final class IterableUtilTest extends TestCaseBase
+{
+ public static function testPrepend(): void
+ {
+ AssertEx::equalLists([1, 2], IterableUtil::toList(IterableUtil::prepend(1, [2])));
+ AssertEx::equalLists([1, 2, 3], IterableUtil::toList(IterableUtil::prepend(1, [2, 3])));
+ AssertEx::equalLists([1], IterableUtil::toList(IterableUtil::prepend(1, [])));
+ }
+
+ public static function testArraySuffix(): void
+ {
+ AssertEx::equalLists([1, 2], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 0)));
+ AssertEx::equalLists([2], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 1)));
+ AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 2)));
+ AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 3)));
+ AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([], 0)));
+ AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([], 1)));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestZip(): iterable
+ {
+ yield [[[]], []];
+ yield [[[], []], []];
+ yield [[[], [], []], []];
+
+ yield [[['a'], [1]], [['a', 1]]];
+ yield [[['a', 'b'], [1, 2]], [['a', 1], ['b', 2]]];
+ yield [[['a', 'b', 'c'], [1, 2, 3], [4.4, 5.5, 6.6]], [['a', 1, 4.4], ['b', 2, 5.5], ['c', 3, 6.6]]];
+ }
+
+ /**
+ * @param iterable[] $inputIterables
+ * @param mixed[][] $expectedOutput
+ */
+ private static function helperForTestZip(bool $withIndex, array $inputIterables, array $expectedOutput): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $dbgCtx->add(['count($inputIterables)' => count($inputIterables)]);
+ $i = 0;
+ foreach (($withIndex ? IterableUtil::zipWithIndex(...$inputIterables) : IterableUtil::zip(...$inputIterables)) as $actualTuple) {
+ $dbgCtx->add(compact('i', 'actualTuple'));
+ self::assertLessThan(count($expectedOutput), $i);
+ $expectedTuple = $expectedOutput[$i];
+ AssertEx::equalLists($expectedTuple, $actualTuple);
+ ++$i;
+ }
+ self::assertSame(count($expectedOutput), $i);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestZip
+ *
+ * @param mixed[][] $inputArrays
+ * @param mixed[][] $expectedOutput
+ */
+ public static function testZip(array $inputArrays, array $expectedOutput): void
+ {
+ self::helperForTestZip(/* withIndex */ false, $inputArrays, $expectedOutput);
+
+ /**
+ * @param mixed[] $inputArray
+ *
+ * @return Generator
+ */
+ $arrayToGenerator = function (array $inputArray): Generator {
+ foreach ($inputArray as $val) {
+ yield $val;
+ }
+ };
+
+ self::helperForTestZip(/* withIndex */ false, array_map($arrayToGenerator(...), $inputArrays), $expectedOutput);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestZip
+ *
+ * @param mixed[][] $inputArrays
+ * @param mixed[][] $expectedOutputWithoutIndex
+ */
+ public static function testZipWithIndex(array $inputArrays, array $expectedOutputWithoutIndex): void
+ {
+ $expectedOutput = [];
+ $index = 0;
+ foreach ($expectedOutputWithoutIndex as $expectedTupleWithoutIndex) {
+ $expectedOutput[] = [$index++, ...$expectedTupleWithoutIndex];
+ }
+
+ self::helperForTestZip(/* withIndex */ true, $inputArrays, $expectedOutput);
+
+ /**
+ * @param mixed[] $inputArray
+ *
+ * @return Generator
+ */
+ $arrayToGenerator = function (array $inputArray): Generator {
+ foreach ($inputArray as $val) {
+ yield $val;
+ }
+ };
+
+ self::helperForTestZip(/* withIndex */ true, array_map($arrayToGenerator(...), $inputArrays), $expectedOutput);
+ }
+
+ public static function testMap(): void
+ {
+ /**
+ * @template TInputValue
+ * @template TOutputValue
+ *
+ * @param iterable $inputRange
+ * @param callable(TInputValue): TOutputValue $mapFunc
+ * @param iterable $expectedOutputRange
+ */
+ $impl = function (iterable $inputRange, callable $mapFunc, iterable $expectedOutputRange): void {
+ $actualOutputRange = IterableUtil::map($inputRange, $mapFunc);
+ foreach (IterableUtil::zip($expectedOutputRange, $actualOutputRange) as $expectedActualOutputValues) {
+ self::assertCount(2, $expectedActualOutputValues);
+ self::assertSame($expectedActualOutputValues[0], $expectedActualOutputValues[1]);
+ }
+ };
+
+ /**
+ * @template T of number
+ *
+ * @phpstan-param T $x
+ *
+ * @phpstan-return T
+ */
+ $x2Func = fn (int|float $x) => $x * 2;
+
+ $impl([], $x2Func, []);
+ $impl([1], $x2Func, [2]);
+ $impl([1, 2], $x2Func, [2, 4]);
+ $impl([1.2, 3, 4.6], $x2Func, [2.4, 6, 9.2]);
+ }
+
+ public static function testMax(): void
+ {
+ /**
+ * @template T of number
+ * @param iterable $range
+ * @param T $expectedResult
+ */
+ $impl = function (iterable $range, mixed $expectedResult): void {
+ /** @var iterable $range */
+ self::assertSame($expectedResult, IterableUtil::max($range));
+ };
+
+ $impl([1], 1);
+ $impl([1.2], 1.2);
+ $impl([1, 1.2], 1.2);
+ $impl([1.2, 2], 2);
+ $impl([1, 3.4, 2], 3.4);
+ $impl([5, 1.2, 3.4], 5);
+ $impl([7, 7.7, 7.8], 7.8);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestTakeUpTo(): iterable
+ {
+ /**
+ * @return iterable
+ */
+ $genDataSets = function (): iterable {
+ yield ['input' => ['iterable' => [], 'upTo' => 0], 'expectedOutput' => []];
+ };
+
+ return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestTakeUpTo
+ *
+ * @param TestTakeUpToInput $input
+ * @param iterable $expectedOutput
+ */
+ public static function testTakeUpTo(array $input, iterable $expectedOutput): void
+ {
+ AssertEx::sameValuesListIterables($expectedOutput, IterableUtil::takeUpTo($input['iterable'], $input['upTo']));
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php
new file mode 100644
index 0000000..599c0d7
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php
@@ -0,0 +1,55 @@
+ 0];
+ $serialized = JsonUtil::encode((object)$original);
+ self::assertSame(1, preg_match('/^\s*{\s*"0"\s*:\s*0\s*}\s*$/', $serialized));
+ $decodedJson = self::decode($serialized, asAssocArray: true);
+ self::assertIsArray($decodedJson);
+ AssertEx::equalMaps($original, $decodedJson);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php
new file mode 100644
index 0000000..82d0ac8
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php
@@ -0,0 +1,157 @@
+, 'offset'?: non-negative-int, 'length'?: ?non-negative-int}
+ * @phpstan-type TestConstructorArgs array{'input': TestConstructorInput, 'expectedOutput': iterable}
+ */
+final class ListSliceTest extends TestCaseBase
+{
+ use DisableDebugContextTestTrait;
+
+ /**
+ * @return iterable, iterable}>
+ */
+ public static function dataProviderForTestConstructorValidInput(): iterable
+ {
+ /**
+ * @return iterable
+ */
+ $genDataSets = function (): iterable {
+ yield ['input' => ['base' => [], 'offset' => 0], 'expectedOutput' => []];
+
+ yield ['input' => ['base' => [], 'offset' => 0, 0], 'expectedOutput' => []];
+
+ yield ['input' => ['base' => ['a'], 'offset' => 1], 'expectedOutput' => []];
+
+ yield ['input' => ['base' => ['a'], 'offset' => 0], 'expectedOutput' => ['a']];
+ yield ['input' => ['base' => ['a', 'b'], 'offset' => 0], 'expectedOutput' => ['a', 'b']];
+ yield ['input' => ['base' => ['a', 'b'], 'offset' => 1], 'expectedOutput' => ['b']];
+
+ yield ['input' => ['base' => ['a', 'b', 'c'],'offset' => 0], 'expectedOutput' => ['a', 'b', 'c']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 0], 'expectedOutput' => []];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 1], 'expectedOutput' => ['a']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 2], 'expectedOutput' => ['a', 'b']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 3], 'expectedOutput' => ['a', 'b', 'c']];
+
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1], 'expectedOutput' => ['b', 'c']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 0], 'expectedOutput' => []];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 1], 'expectedOutput' => ['b']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 2], 'expectedOutput' => ['b', 'c']];
+
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2], 'expectedOutput' => ['c']];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2, 'length' => 0], 'expectedOutput' => []];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2, 'length' => 1], 'expectedOutput' => ['c']];
+
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 3], 'expectedOutput' => []];
+ yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 3, 'length' => 0], 'expectedOutput' => []];
+ };
+
+ return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestConstructorValidInput
+ *
+ * @param TestConstructorInput $input
+ * @param iterable $expectedOutput
+ */
+ public static function testConstructorValidInput(array $input, iterable $expectedOutput): void
+ {
+ if (array_key_exists('offset', $input)) {
+ if (array_key_exists('length', $input)) {
+ $actualOutput = new ListSlice($input['base'], $input['offset'], $input['length']);
+ } else {
+ $actualOutput = new ListSlice($input['base'], $input['offset']);
+ }
+ } else {
+ $actualOutput = new ListSlice($input['base']);
+ }
+ AssertEx::sameValuesListIterables($expectedOutput, $actualOutput);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestInvalidInput(): iterable
+ {
+ yield [fn() => new ListSlice([], 1)];
+ yield [fn() => new ListSlice(['a'], 2)];
+ yield [fn() => new ListSlice(['a', 'b'], 3)];
+ yield [fn() => new ListSlice(['a', 'b', 'b'], 4)];
+
+ yield [fn() => new ListSlice([], 0, 1)];
+ yield [fn() => new ListSlice(['a'], 1, 1)];
+ yield [fn() => new ListSlice(['a', 'b'], 2, 1)];
+ yield [fn() => new ListSlice(['a', 'b', 'b'], 1, 3)];
+ yield [fn() => new ListSlice(['a', 'b', 'b'], 2, 2)];
+ yield [fn() => new ListSlice(['a', 'b', 'b'], 3, 1)];
+ }
+
+ /**
+ * @dataProvider dataProviderForTestInvalidInput
+ *
+ * @param callable(): mixed $throwingCall
+ */
+ public static function testInvalidInput(callable $throwingCall): void
+ {
+ AssertEx::throws(OutOfBoundsException::class, $throwingCall);
+ }
+
+ public static function testWithoutPrefix(): void
+ {
+ AssertEx::sameValuesListIterables([], (new ListSlice([]))->withoutPrefix(0));
+ AssertEx::throws(OutOfBoundsException::class, fn() => (new ListSlice([]))->withoutPrefix(1));
+
+ AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a']))->withoutPrefix(0));
+ AssertEx::sameValuesListIterables([], (new ListSlice(['a']))->withoutPrefix(1));
+
+ AssertEx::sameValuesListIterables(['a', 'b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(0));
+ AssertEx::sameValuesListIterables(['b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(1));
+ AssertEx::sameValuesListIterables(['c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(2));
+ AssertEx::sameValuesListIterables([], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(3));
+ }
+
+ public static function testWithoutSuffix(): void
+ {
+ AssertEx::sameValuesListIterables([], (new ListSlice([]))->withoutSuffix(0));
+ AssertEx::throws(OutOfBoundsException::class, fn() => (new ListSlice([]))->withoutSuffix(1));
+
+ AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a']))->withoutSuffix(0));
+ AssertEx::sameValuesListIterables([], (new ListSlice(['a']))->withoutSuffix(1));
+
+ AssertEx::sameValuesListIterables(['a', 'b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(0));
+ AssertEx::sameValuesListIterables(['a', 'b'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(1));
+ AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(2));
+ AssertEx::sameValuesListIterables([], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(3));
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php
new file mode 100644
index 0000000..df58e7f
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php
@@ -0,0 +1,40 @@
+
+ */
+ protected static function propertiesExcludedFromLogImpl(): array
+ {
+ return array_merge(parent::propertiesExcludedFromLogImpl(), ['anotherExcludedProp']);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php
new file mode 100644
index 0000000..6fb0dda
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php
@@ -0,0 +1,32 @@
+loggerForClass(LogCategoryForTests::TEST, __NAMESPACE__, __CLASS__, __FILE__);
+ }
+
+ /**
+ * @param Logger $logger
+ *
+ * @return array
+ */
+ private static function includeStackTraceHelperFunc(Logger $logger): array
+ {
+ ($lgrPxy = $logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) && $lgrPxy->includeStackTrace()->log('');
+ $expectedSrcCodeLine = __LINE__ - 1;
+ return [
+ StackTraceUtil::FUNCTION_KEY => __FUNCTION__,
+ StackTraceUtil::LINE_KEY => $expectedSrcCodeLine,
+ ];
+ }
+
+ /**
+ * @param array $expectedSrcCodeData
+ * @param array $actualFrame
+ *
+ * @return void
+ */
+ public static function verifyStackFrame(array $expectedSrcCodeData, array $actualFrame): void
+ {
+ $ctx = LoggableToString::convert(['$actualFrame' => $actualFrame]);
+ self::assertCount(4, $actualFrame, $ctx);
+
+ self::assertArrayHasKey(StackTraceUtil::FILE_KEY, $actualFrame, $ctx);
+ $actualFilePath = $actualFrame[StackTraceUtil::FILE_KEY];
+ self::assertIsString($actualFilePath, $ctx);
+ self::assertSame(basename(__FILE__), basename($actualFilePath), $ctx);
+
+ $expectedSrcCodeLine = $expectedSrcCodeData[StackTraceUtil::LINE_KEY];
+ AssertEx::arrayHasKeyWithSameValue(StackTraceUtil::LINE_KEY, $expectedSrcCodeLine, $actualFrame, $ctx);
+
+ self::assertArrayHasKey(StackTraceUtil::CLASS_KEY, $actualFrame, $ctx);
+ $thisClassShortName = ClassNameUtil::fqToShort(__CLASS__);
+ $actualClass = $actualFrame[StackTraceUtil::CLASS_KEY];
+ self::assertIsString($actualClass, $ctx);
+ /** @var class-string $actualClass */
+ $actualClassShortName = ClassNameUtil::fqToShort($actualClass);
+ self::assertSame($thisClassShortName, $actualClassShortName, $ctx);
+
+ $expectedSrcCodeFunc = $expectedSrcCodeData[StackTraceUtil::FUNCTION_KEY];
+ AssertEx::arrayHasKeyWithSameValue(StackTraceUtil::FUNCTION_KEY, $expectedSrcCodeFunc, $actualFrame, $ctx);
+ }
+
+ public function testIncludeStackTrace(): void
+ {
+ $mockLogSink = new MockLogPreformattedSink();
+ $logger = self::buildLogger($mockLogSink);
+
+ $expectedSrcCodeLineForThisFrame = __LINE__ + 1;
+ $expectedSrcCodeDataForTopFrame = self::includeStackTraceHelperFunc($logger);
+
+ self::assertCount(1, $mockLogSink->consumed);
+ $actualLogStatement = $mockLogSink->consumed[0];
+ self::assertSame(LogLevel::trace, $actualLogStatement->statementLevel);
+ self::assertSame(LogCategoryForTests::TEST, $actualLogStatement->category);
+ self::assertSame(__FILE__, $actualLogStatement->srcCodeFile);
+ self::assertSame($expectedSrcCodeDataForTopFrame[StackTraceUtil::LINE_KEY], $actualLogStatement->srcCodeLine);
+ self::assertSame(
+ $expectedSrcCodeDataForTopFrame[StackTraceUtil::FUNCTION_KEY],
+ $actualLogStatement->srcCodeFunc
+ );
+
+ $actualCtx = JsonUtil::decode($actualLogStatement->messageWithContext, asAssocArray: true);
+ self::assertIsArray($actualCtx);
+ /** @var array $actualCtx */
+ AssertEx::arrayHasKeyWithSameValue(LogBackend::NAMESPACE_KEY, __NAMESPACE__, $actualCtx);
+ self::assertArrayHasKey(LogBackend::CLASS_KEY, $actualCtx);
+ $thisClassShortName = ClassNameUtil::fqToShort(__CLASS__);
+ $actualFqClassName = $actualCtx[LogBackend::CLASS_KEY];
+ self::assertSame($thisClassShortName, ClassNameUtil::fqToShort($actualFqClassName)); // @phpstan-ignore argument.type
+
+ self::assertArrayHasKey(LoggableStackTrace::STACK_TRACE_KEY, $actualCtx);
+ $actualStackTrace = $actualCtx[LoggableStackTrace::STACK_TRACE_KEY];
+ self::assertIsArray($actualStackTrace);
+ /** @var array[] $actualStackTrace */
+ AssertEx::countAtLeast(2, $actualStackTrace);
+ self::verifyStackFrame($expectedSrcCodeDataForTopFrame, $actualStackTrace[0]);
+ $expectedSrcCodeDataForThisFrame = [
+ StackTraceUtil::FUNCTION_KEY => __FUNCTION__,
+ StackTraceUtil::LINE_KEY => $expectedSrcCodeLineForThisFrame,
+ ];
+ self::verifyStackFrame($expectedSrcCodeDataForThisFrame, $actualStackTrace[1]);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php
new file mode 100644
index 0000000..258d01e
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php
@@ -0,0 +1,102 @@
+loggerForClass(LogCategoryForTests::TEST, __NAMESPACE__, __CLASS__, __FILE__);
+ }
+
+ public function testMergingContexts(): void
+ {
+ DebugContext::getCurrentScope(/* out */ $dbgCtx);
+ $mockLogSink = new MockLogPreformattedSink();
+ $dbgCtx->add(compact('mockLogSink'));
+ $level1Ctx = ['level_1_key_1' => 'level_1_key_1 value', 'level_1_key_2' => 'level_1_key_2 value', 'some_key' => 'some_key level_1 value'];
+ $loggerA = self::buildLogger($mockLogSink)->addAllContext($level1Ctx);
+ $level2Ctx = ['level_2_key_1' => 'level_2_key_1 value', 'level_2_key_2' => 'level_2_key_2 value'];
+ $loggerB = $loggerA->inherit()->addAllContext($level2Ctx);
+
+ $loggerProxyDebug = $loggerB->ifDebugLevelEnabledNoLine(__FUNCTION__);
+
+ $level3Ctx = ['level_3_key_1' => 'level_3_key_1 value', 'level_3_key_2' => 'level_3_key_2 value', 'some_key' => 'some_key level_3 value'];
+ $loggerB->addAllContext($level3Ctx);
+
+ $stmtMsg = 'Some message';
+ $stmtCtx = ['stmt_key_1' => 'stmt_key_1 value', 'stmt_key_2' => 'stmt_key_2 value'];
+ $stmtLine = __LINE__ + 1;
+ $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, $stmtMsg, $stmtCtx);
+
+ $actualStmt = ArrayUtilForTests::getSingleValue($mockLogSink->consumed);
+
+ self::assertSame(LogLevel::debug, $actualStmt->statementLevel);
+ self::assertSame(LogCategoryForTests::TEST, $actualStmt->category);
+ self::assertSame(__FILE__, $actualStmt->srcCodeFile);
+ self::assertSame($stmtLine, $actualStmt->srcCodeLine);
+ self::assertSame(__FUNCTION__, $actualStmt->srcCodeFunc);
+
+ self::assertStringStartsWith($stmtMsg, $actualStmt->messageWithContext);
+ $actualCtxEncodedAsJson = trim(substr($actualStmt->messageWithContext, strlen($stmtMsg)));
+ $dbgCtx->add(compact('actualCtxEncodedAsJson'));
+
+ $actualCtx = JsonUtil::decode($actualCtxEncodedAsJson, asAssocArray: true);
+ self::assertIsArray($actualCtx);
+ $expectedCtx = [
+ 'stmt_key_1' => 'stmt_key_1 value', 'stmt_key_2' => 'stmt_key_2 value',
+ 'level_3_key_1' => 'level_3_key_1 value', 'level_3_key_2' => 'level_3_key_2 value', 'some_key' => 'some_key level_3 value',
+ 'level_2_key_1' => 'level_2_key_1 value', 'level_2_key_2' => 'level_2_key_2 value',
+ 'level_1_key_1' => 'level_1_key_1 value', 'level_1_key_2' => 'level_1_key_2 value',
+ LogBackend::NAMESPACE_KEY => __NAMESPACE__,
+ LogBackend::CLASS_KEY => ClassNameUtil::fqToShort(__CLASS__),
+ ];
+ self::assertCount(count($expectedCtx), $actualCtx);
+ foreach (IterableUtil::zip(IterableUtil::keys($expectedCtx), IterableUtil::keys($actualCtx)) as [$expectedKey, $actualKey]) {
+ $dbgCtx->add(compact('expectedKey', 'actualKey'));
+ self::assertSame($expectedKey, $actualKey);
+ self::assertSame($expectedCtx[$expectedKey], $actualCtx[$actualKey]);
+ }
+
+ $expectedCtxEncodedAsJson = JsonUtil::encode($expectedCtx);
+ $dbgCtx->add(compact('expectedCtxEncodedAsJson'));
+ self::assertSame($expectedCtxEncodedAsJson, $actualCtxEncodedAsJson);
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php
new file mode 100644
index 0000000..7e88e46
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php
@@ -0,0 +1,36 @@
+{}[]+-=_~^ \t\r\n,:;.!?"];
+ foreach ($valuesToTest as $value) {
+ self::logValueAndVerify($value, $value);
+ }
+ }
+
+ public function testEnum(): void
+ {
+ /** @var list $enumObjAndLoggedStringPairs */
+ $enumObjAndLoggedStringPairs = [
+ [DummyEnum::small, __NAMESPACE__ . '\\DummyEnum(small)']
+ ];
+ foreach ($enumObjAndLoggedStringPairs as $enumObjAndLoggedStringPair) {
+ self::logValueAndVerify($enumObjAndLoggedStringPair[0], $enumObjAndLoggedStringPair[1]);
+ }
+ }
+
+ // public function testResource(): void
+ // {
+ // // $tmpFile = tmpfile()
+ // // self::logValueAndVerify(null, null);
+ // }
+ //
+ // public function testListArray(): void
+ // {
+ // // new SimpleObjectForTests()
+ // }
+ //
+ // public function testMapArray(): void
+ // {
+ // // new SimpleObjectForTests()
+ // }
+
+ /**
+ * @return array
+ */
+ private static function expectedSimpleObject(?string $className = null, bool $isPropExcluded = true, mixed $lateInitPropVal = LogConsts::UNINITIALIZED_PROPERTY_SUBSTITUTE): array
+ {
+ return ($className === null ? [] : [LogConsts::TYPE_KEY => $className])
+ + [
+ 'intProp' => 123,
+ 'stringProp' => 'Abc',
+ 'nullableStringProp' => null,
+ 'lateInitProp' => $lateInitPropVal,
+ 'recursiveProp' => null,
+ ]
+ + ($isPropExcluded ? [] : ['excludedProp' => 'excludedProp value']);
+ }
+
+ /**
+ * @param string|null $className
+ * @param bool $isPropExcluded
+ *
+ * @return array
+ */
+ private static function expectedDerivedSimpleObject(?string $className = null, bool $isPropExcluded = true): array
+ {
+ return self::expectedSimpleObject($className, $isPropExcluded)
+ + ['derivedFloatProp' => 1.5]
+ + ($isPropExcluded ? [] : ['anotherExcludedProp' => 'anotherExcludedProp value']);
+ }
+
+ public function tearDown(): void
+ {
+ ObjectForLoggableTraitTests::logWithoutClassName();
+ ObjectForLoggableTraitTests::shouldExcludeProp();
+ DerivedObjectForLoggableTraitTests::logWithoutClassName();
+ DerivedObjectForLoggableTraitTests::shouldExcludeProp();
+
+ parent::tearDown();
+ }
+
+ public function testObject(): void
+ {
+ self::logValueAndVerify(new ObjectForLoggableTraitTests(), self::expectedSimpleObject());
+
+ ObjectForLoggableTraitTests::logWithShortClassName();
+
+ self::logValueAndVerify(new ObjectForLoggableTraitTests(), self::expectedSimpleObject(className: 'ObjectForLoggableTraitTests'));
+
+ ObjectForLoggableTraitTests::logWithCustomClassName('My-custom-type');
+
+ self::logValueAndVerify(
+ new ObjectForLoggableTraitTests(),
+ self::expectedSimpleObject(className: 'My-custom-type')
+ );
+
+ ObjectForLoggableTraitTests::logWithoutClassName();
+ ObjectForLoggableTraitTests::shouldExcludeProp(false);
+
+ self::logValueAndVerify(
+ new ObjectForLoggableTraitTests(),
+ self::expectedSimpleObject(isPropExcluded: false)
+ );
+ }
+
+ public function testDerivedObject(): void
+ {
+ self::logValueAndVerify(new DerivedObjectForLoggableTraitTests(), self::expectedDerivedSimpleObject());
+
+ DerivedObjectForLoggableTraitTests::logWithShortClassName();
+
+ self::logValueAndVerify(
+ new DerivedObjectForLoggableTraitTests(),
+ self::expectedDerivedSimpleObject(className: 'DerivedObjectForLoggableTraitTests')
+ );
+
+ DerivedObjectForLoggableTraitTests::logWithCustomClassName('My-custom-type');
+
+ self::logValueAndVerify(
+ new DerivedObjectForLoggableTraitTests(),
+ self::expectedDerivedSimpleObject(className: 'My-custom-type')
+ );
+
+ DerivedObjectForLoggableTraitTests::logWithoutClassName();
+ DerivedObjectForLoggableTraitTests::shouldExcludeProp(false);
+
+ self::logValueAndVerify(
+ new DerivedObjectForLoggableTraitTests(),
+ self::expectedDerivedSimpleObject(isPropExcluded: false)
+ );
+ }
+
+ // public function testObjectWithThrowingToString(): void
+ // {
+ // // new SimpleObjectForTests()
+ // }
+ //
+ // public function testObjectWithDebugInfo(): void
+ // {
+ // // new SimpleObjectForTests()
+ // }
+ //
+ // public function testThrowable(): void
+ // {
+ // // new SimpleObjectForTests()
+ // }
+
+ public function testNoopObject(): void
+ {
+ self::logValueAndVerify(NoopLogSink::singletonInstance(), [LogConsts::TYPE_KEY => 'NoopLogSink']);
+ }
+
+ public function testLogBackend(): void
+ {
+ self::logValueAndVerify(
+ new LogBackend(LogLevel::warning, NoopLogSink::singletonInstance()),
+ [
+ 'maxEnabledLevel' => 'WARNING',
+ 'logSink' => NoopLogSink::class,
+ ]
+ );
+ }
+
+ public function testLogger(): void
+ {
+ $loggerFactory = new LoggerFactory(new LogBackend(LogLevel::debug, NoopLogSink::singletonInstance()));
+ $category = 'test category';
+ $namespace = 'test namespace';
+ $fqClassName = __CLASS__;
+ $srcCodeFile = 'test source code file';
+ self::logValueAndVerify(
+ $loggerFactory->loggerForClass($category, $namespace, $fqClassName, $srcCodeFile),
+ [
+ 'category' => $category,
+ 'count(context)' => 0,
+ 'fqClassName' => $fqClassName,
+ 'inheritedData' => null,
+ 'namespace' => $namespace,
+ 'srcCodeFile' => $srcCodeFile,
+ 'backend' => ['maxEnabledLevel' => 'DEBUG', 'logSink' => NoopLogSink::class],
+ ]
+ );
+ }
+
+ public function testLateInit(): void
+ {
+ $obj = new ObjectForLoggableTraitTests();
+ self::logValueAndVerify($obj, self::expectedSimpleObject());
+ $lateInitPropVal = 'late inited value';
+ $obj->lateInitProp = $lateInitPropVal;
+ self::logValueAndVerify($obj, self::expectedSimpleObject(lateInitPropVal: 'late inited value'));
+ }
+
+ private const MAX_DEPTH_KEY = 'max_depth';
+
+ /**
+ * @return iterable
+ */
+ public static function dataProviderForTestMaxDepth(): iterable
+ {
+ /**
+ * @return iterable>
+ */
+ $generateDataSets = function (): iterable {
+ $maxDepthVariants = [0, 1, 2, 3, 10, 15, 20];
+ $maxDepthVariants[] = LoggableToJsonEncodable::MAX_DEPTH_IN_PROD_MODE;
+ $maxDepthVariants[] = BootstrapTests::LOG_COMPOSITE_DATA_MAX_DEPTH_IN_TEST_MODE;
+ $maxDepthVariants = array_unique($maxDepthVariants, SORT_NUMERIC);
+ asort(/* ref */ $maxDepthVariants, SORT_NUMERIC);
+ foreach ($maxDepthVariants as $maxDepth) {
+ yield [self::MAX_DEPTH_KEY => $maxDepth];
+ }
+ };
+
+ return DataProviderForTestBuilder::convertEachDataSetToMixedMapAndAddDesc($generateDataSets);
+ }
+
+ /**
+ * @dataProvider dataProviderForTestMaxDepth
+ */
+ public function testMaxDepthForScalar(MixedMap $testArgs): void
+ {
+ $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY);
+ $savedMaxDepth = LoggableToJsonEncodable::$maxDepth;
+ try {
+ LoggableToJsonEncodable::$maxDepth = $maxDepth;
+ self::assertSame(null, LoggableToJsonEncodable::convert(null, 0));
+ self::assertSame(123, LoggableToJsonEncodable::convert(123, 0));
+ self::assertSame(654.5, LoggableToJsonEncodable::convert(654.5, 0));
+ self::assertSame('my string', LoggableToJsonEncodable::convert('my string', 0));
+ } finally {
+ LoggableToJsonEncodable::$maxDepth = $savedMaxDepth;
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestMaxDepth
+ */
+ public function testMaxDepthForArray(MixedMap $testArgs): void
+ {
+ $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY);
+ $savedMaxDepth = LoggableToJsonEncodable::$maxDepth;
+ try {
+ LoggableToJsonEncodable::$maxDepth = $maxDepth;
+ self::implTestMaxDepthForArray(LoggableToJsonEncodable::$maxDepth);
+ } finally {
+ LoggableToJsonEncodable::$maxDepth = $savedMaxDepth;
+ }
+ }
+
+ private static function implTestMaxDepthForArray(int $maxDepth): void
+ {
+ /**
+ * @param array $currentArray
+ * @param int $depth
+ *
+ * @return array
+ */
+ $buildParentArray = function (array $currentArray, int $depth): array {
+ $parentArray = [];
+ $parentArray['depth ' . $depth . ' int'] = $depth * 10;
+ $parentArray['depth ' . $depth . ' string'] = strval($depth * 10);
+ $parentArray['depth ' . $depth . ' array'] = $currentArray;
+ return $parentArray;
+ };
+
+ $arrayToLog = [];
+ foreach (RangeUtil::generateDownFrom($maxDepth + 2) as $depth) {
+ $arrayToLog = $buildParentArray($arrayToLog, $depth);
+ }
+
+ $decodedLoggedArray = self::logValueAndDecodeToJson($arrayToLog);
+ $currentLoggedArray = $decodedLoggedArray;
+ foreach (RangeUtil::generateUpTo($maxDepth + 2) as $depth) {
+ self::assertIsArray($currentLoggedArray);
+ self::assertLessThanOrEqual($maxDepth, $depth);
+
+ if ($depth === $maxDepth) {
+ AssertEx::equalMaps(LoggableToJsonEncodable::convertArrayForMaxDepth($buildParentArray([], $maxDepth), $maxDepth), $currentLoggedArray);
+ break;
+ }
+
+ AssertEx::arrayHasKeyWithSameValue('depth ' . $depth . ' int', $depth * 10, $currentLoggedArray);
+ AssertEx::arrayHasKeyWithSameValue('depth ' . $depth . ' string', strval($depth * 10), $currentLoggedArray);
+
+ $key = 'depth ' . $depth . ' array';
+ self::assertArrayHasKey($key, $currentLoggedArray);
+ $currentLoggedArray = $currentLoggedArray[$key];
+ }
+ }
+
+ /**
+ * @dataProvider dataProviderForTestMaxDepth
+ */
+ public function testMaxDepthForObject(MixedMap $testArgs): void
+ {
+ $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY);
+ $savedMaxDepth = LoggableToJsonEncodable::$maxDepth;
+ try {
+ LoggableToJsonEncodable::$maxDepth = $maxDepth;
+ self::implTestMaxDepthForObject($maxDepth);
+ } finally {
+ LoggableToJsonEncodable::$maxDepth = $savedMaxDepth;
+ }
+ }
+
+ private static function implTestMaxDepthForObject(int $maxDepth): void
+ {
+ $buildParentObject = function (ObjectForLoggableTraitTests $currentObject, int $depth) use ($maxDepth): ObjectForLoggableTraitTests {
+ return new ObjectForLoggableTraitTests($depth, 'depth: ' . $depth . ', maxDepth: ' . $maxDepth, $currentObject);
+ };
+
+ $objectToLog = new ObjectForLoggableTraitTests();
+ foreach (RangeUtil::generateDownFrom($maxDepth + 2) as $depth) {
+ $objectToLog = $buildParentObject($objectToLog, $depth);
+ }
+
+ $decodedLoggedObject = self::logValueAndDecodeToJson($objectToLog);
+ $currentLoggedObject = $decodedLoggedObject;
+ foreach (RangeUtil::generateUpTo($maxDepth + 2) as $depth) {
+ self::assertIsArray($currentLoggedObject);
+ self::assertLessThanOrEqual($maxDepth, $depth);
+
+ if ($depth === $maxDepth) {
+ AssertEx::equalMaps(LoggableToJsonEncodable::convertObjectForMaxDepth($buildParentObject(new ObjectForLoggableTraitTests(), $maxDepth), $maxDepth), $currentLoggedObject);
+ break;
+ }
+
+ AssertEx::arrayHasKeyWithSameValue('intProp', $depth, $currentLoggedObject);
+ AssertEx::arrayHasKeyWithSameValue('stringProp', 'depth: ' . $depth . ', maxDepth: ' . $maxDepth, $currentLoggedObject);
+
+ $key = 'recursiveProp';
+ self::assertArrayHasKey($key, $currentLoggedObject);
+ $currentLoggedObject = $currentLoggedObject[$key];
+ }
+ }
+}
diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php
new file mode 100644
index 0000000..55a6d06
--- /dev/null
+++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php
@@ -0,0 +1,93 @@
+intProp = $intProp;
+ $this->stringProp = $stringProp;
+ $this->recursiveProp = $recursiveProp;
+ }
+
+ public static function logWithoutClassName(): void
+ {
+ self::$logWithClassNameValue = null;
+ }
+
+ public static function logWithCustomClassName(string $className): void
+ {
+ self::$logWithClassNameValue = $className;
+ }
+
+ public static function logWithShortClassName(): void
+ {
+ self::$logWithClassNameValue = ClassNameUtil::fqToShort(static::class);
+ }
+
+ protected static function classNameToLog(): ?string
+ {
+ return self::$logWithClassNameValue;
+ }
+
+ public static function shouldExcludeProp(bool $shouldExcludeProp = true): void
+ {
+ self::$shouldExcludeProp = $shouldExcludeProp;
+ }
+
+ /**
+ * @return array
+ */
+ protected static function propertiesExcludedFromLogImpl(): array
+ {
+ return ['excludedProp'];
+ }
+
+ /**
+ * @return array