diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 28025abbf..a9b80846c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -60,6 +60,7 @@ jobs: 'ResourceDetectors/Container', 'ResourceDetectors/DigitalOcean', 'Sampler/RuleBased', + 'Sampler/Xray', 'Shims/OpenTracing', 'Symfony', 'Utils/Test' diff --git a/.gitsplit.yml b/.gitsplit.yml index b357f4137..24ee46352 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -86,6 +86,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-detector-digitalocean.git" - prefix: "src/Sampler/RuleBased" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sampler-rulebased.git" + - prefix: "src/Sampler/Xray" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sampler-aws-xray.git" - prefix: "src/Shims/OpenTracing" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-shim-opentracing.git" - prefix: "src/Utils/Test" diff --git a/src/Sampler/Xray/.gitattributes b/src/Sampler/Xray/.gitattributes new file mode 100644 index 000000000..ac40e9f84 --- /dev/null +++ b/src/Sampler/Xray/.gitattributes @@ -0,0 +1,13 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.phan export-ignore +/.php-cs-fixer.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore diff --git a/src/Sampler/Xray/.gitignore b/src/Sampler/Xray/.gitignore new file mode 100644 index 000000000..b557f8918 --- /dev/null +++ b/src/Sampler/Xray/.gitignore @@ -0,0 +1,6 @@ +composer.lock + +.phpunit.cache + +var +vendor \ No newline at end of file diff --git a/src/Sampler/Xray/.phan/config.php b/src/Sampler/Xray/.phan/config.php new file mode 100644 index 000000000..d3c9f4911 --- /dev/null +++ b/src/Sampler/Xray/.phan/config.php @@ -0,0 +1,370 @@ + '8.1', + + // If enabled, missing properties will be created when + // they are first seen. If false, we'll report an + // error message if there is an attempt to write + // to a class property that wasn't explicitly + // defined. + 'allow_missing_properties' => false, + + // If enabled, null can be cast to any type and any + // type can be cast to null. Setting this to true + // will cut down on false positives. + 'null_casts_as_any_type' => false, + + // If enabled, allow null to be cast as any array-like type. + // + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'null_casts_as_array' => true, + + // If enabled, allow any array-like type to be cast to null. + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'array_casts_as_null' => true, + + // If enabled, scalars (int, float, bool, string, null) + // are treated as if they can cast to each other. + // This does not affect checks of array keys. See `scalar_array_key_cast`. + 'scalar_implicit_cast' => false, + + // If enabled, any scalar array keys (int, string) + // are treated as if they can cast to each other. + // E.g. `array` can cast to `array` and vice versa. + // Normally, a scalar type such as int could only cast to/from int and mixed. + 'scalar_array_key_cast' => true, + + // If this has entries, scalars (int, float, bool, string, null) + // are allowed to perform the casts listed. + // + // E.g. `['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]` + // allows casting null to a string, but not vice versa. + // (subset of `scalar_implicit_cast`) + 'scalar_implicit_partial' => [], + + // If enabled, Phan will warn if **any** type in a method invocation's object + // is definitely not an object, + // or if **any** type in an invoked expression is not a callable. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_method_checking' => false, + + // If enabled, Phan will warn if **any** type of the object expression for a property access + // does not contain that property. + 'strict_object_checking' => false, + + // If enabled, Phan will warn if **any** type in the argument's union type + // cannot be cast to a type in the parameter's expected union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_param_checking' => false, + + // If enabled, Phan will warn if **any** type in a property assignment's union type + // cannot be cast to a type in the property's declared union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_property_checking' => false, + + // If enabled, Phan will warn if **any** type in a returned value's union type + // cannot be cast to the declared return type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_return_checking' => false, + + // If true, seemingly undeclared variables in the global + // scope will be ignored. + // + // This is useful for projects with complicated cross-file + // globals that you have no hope of fixing. + 'ignore_undeclared_variables_in_global_scope' => true, + + // Set this to false to emit `PhanUndeclaredFunction` issues for internal functions that Phan has signatures for, + // but aren't available in the codebase, or from Reflection. + // (may lead to false positives if an extension isn't loaded) + // + // If this is true(default), then Phan will not warn. + // + // Even when this is false, Phan will still infer return values and check parameters of internal functions + // if Phan has the signatures. + 'ignore_undeclared_functions_with_known_signatures' => true, + + // Backwards Compatibility Checking. This is slow + // and expensive, but you should consider running + // it before upgrading your version of PHP to a + // new version that has backward compatibility + // breaks. + // + // If you are migrating from PHP 5 to PHP 7, + // you should also look into using + // [php7cc (no longer maintained)](https://github.com/sstalle/php7cc) + // and [php7mar](https://github.com/Alexia/php7mar), + // which have different backwards compatibility checks. + 'backward_compatibility_checks' => false, + + // If true, check to make sure the return type declared + // in the doc-block (if any) matches the return type + // declared in the method signature. + 'check_docblock_signature_return_type_match' => false, + + // If true, make narrowed types from phpdoc params override + // the real types from the signature, when real types exist. + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // Affects analysis of the body of the method and the param types passed in by callers. + // + // (*Requires `check_docblock_signature_param_type_match` to be true*) + 'prefer_narrowed_phpdoc_param_type' => true, + + // (*Requires `check_docblock_signature_return_type_match` to be true*) + // + // If true, make narrowed types from phpdoc returns override + // the real types from the signature, when real types exist. + // + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // This setting affects the analysis of return statements in the body of the method and the return types passed in by callers. + 'prefer_narrowed_phpdoc_return_type' => true, + + // If enabled, check all methods that override a + // parent method to make sure its signature is + // compatible with the parent's. + // + // This check can add quite a bit of time to the analysis. + // + // This will also check if final methods are overridden, etc. + 'analyze_signature_compatibility' => true, + + // This setting maps case-insensitive strings to union types. + // + // This is useful if a project uses phpdoc that differs from the phpdoc2 standard. + // + // If the corresponding value is the empty string, + // then Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`) + // + // If the corresponding value is not empty, + // then Phan will act as though it saw the corresponding UnionTypes(s) + // when the keys show up in a UnionType of `@param`, `@return`, `@var`, `@property`, etc. + // + // This matches the **entire string**, not parts of the string. + // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting) + // + // (These are not aliases, this setting is ignored outside of doc comments). + // (Phan does not check if classes with these names exist) + // + // Example setting: `['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']` + 'phpdoc_type_mapping' => [], + + // Set to true in order to attempt to detect dead + // (unreferenced) code. Keep in mind that the + // results will only be a guess given that classes, + // properties, constants and methods can be referenced + // as variables (like `$class->$property` or + // `$class->$method()`) in ways that we're unable + // to make sense of. + 'dead_code_detection' => false, + + // Set to true in order to attempt to detect unused variables. + // `dead_code_detection` will also enable unused variable detection. + // + // This has a few known false positives, e.g. for loops or branches. + 'unused_variable_detection' => false, + + // Set to true in order to attempt to detect redundant and impossible conditions. + // + // This has some false positives involving loops, + // variables set in branches of loops, and global variables. + 'redundant_condition_detection' => false, + + // If enabled, Phan will act as though it's certain of real return types of a subset of internal functions, + // even if those return types aren't available in reflection (real types were taken from php 7.3 or 8.0-dev, depending on target_php_version). + // + // Note that with php 7 and earlier, php would return null or false for many internal functions if the argument types or counts were incorrect. + // As a result, enabling this setting with target_php_version 8.0 may result in false positives for `--redundant-condition-detection` when codebases also support php 7.x. + 'assume_real_types_for_internal_functions' => false, + + // If true, this runs a quick version of checks that takes less + // time at the cost of not running as thorough + // of an analysis. You should consider setting this + // to true only when you wish you had more **undiagnosed** issues + // to fix in your code base. + // + // In quick-mode the scanner doesn't rescan a function + // or a method's code block every time a call is seen. + // This means that the problem here won't be detected: + // + // ```php + // false, + + // Enable or disable support for generic templated + // class types. + 'generic_types_enabled' => true, + + // Override to hardcode existence and types of (non-builtin) globals in the global scope. + // Class names should be prefixed with `\`. + // + // (E.g. `['_FOO' => '\FooClass', 'page' => '\PageClass', 'userId' => 'int']`) + 'globals_type_map' => [], + + // The minimum severity level to report on. This can be + // set to `Issue::SEVERITY_LOW`, `Issue::SEVERITY_NORMAL` or + // `Issue::SEVERITY_CRITICAL`. Setting it to only + // critical issues is a good place to start on a big + // sloppy mature code base. + 'minimum_severity' => Issue::SEVERITY_LOW, + + // Add any issue types (such as `'PhanUndeclaredMethod'`) + // to this deny-list to inhibit them from being reported. + 'suppress_issue_types' => [], + + // A regular expression to match files to be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding groups of test or example + // directories/files, unanalyzable files, or files that + // can't be removed for whatever reason. + // (e.g. `'@Test\.php$@'`, or `'@vendor/.*/(tests|Tests)/@'`) + 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', + + // A list of files that will be excluded from parsing and analysis + // and will not be read at all. + // + // This is useful for excluding hopelessly unanalyzable + // files that can't be removed for whatever reason. + 'exclude_file_list' => [ + 'vendor/composer/composer/src/Composer/InstalledVersions.php' + ], + + // A directory list that defines files that will be excluded + // from static analysis, but whose class and method + // information should be included. + // + // Generally, you'll want to include the directories for + // third-party code (such as "vendor/") in this list. + // + // n.b.: If you'd like to parse but not analyze 3rd + // party code, directories containing that code + // should be added to the `directory_list` as well as + // to `exclude_analysis_directory_list`. + 'exclude_analysis_directory_list' => [ + 'vendor/', + 'src/ComponentProvider/', + ], + + // Enable this to enable checks of require/include statements referring to valid paths. + 'enable_include_path_checks' => true, + + // The number of processes to fork off during the analysis + // phase. + 'processes' => 1, + + // List of case-insensitive file extensions supported by Phan. + // (e.g. `['php', 'html', 'htm']`) + 'analyzed_file_extensions' => [ + 'php', + ], + + // You can put paths to stubs of internal extensions in this config option. + // If the corresponding extension is **not** loaded, then Phan will use the stubs instead. + // Phan will continue using its detailed type annotations, + // but load the constants, classes, functions, and classes (and their Reflection types) + // from these stub files (doubling as valid php files). + // Use a different extension from php to avoid accidentally loading these. + // The `tools/make_stubs` script can be used to generate your own stubs (compatible with php 7.0+ right now) + // + // (e.g. `['xdebug' => '.phan/internal_stubs/xdebug.phan_php']`) + 'autoload_internal_extension_signatures' => [], + + // A list of plugin files to execute. + // + // Plugins which are bundled with Phan can be added here by providing their name (e.g. `'AlwaysReturnPlugin'`) + // + // Documentation about available bundled plugins can be found [here](https://github.com/phan/phan/tree/master/.phan/plugins). + // + // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. `'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'`) + 'plugins' => [ + 'AlwaysReturnPlugin', + 'PregRegexCheckerPlugin', + 'UnreachableCodePlugin', + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in `exclude_analysis_directory_list`, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'src', + 'vendor' + ], + + // A list of individual files to include in analysis + // with a path relative to the root directory of the + // project. + 'file_list' => [], +]; diff --git a/src/Sampler/Xray/.php-cs-fixer.php b/src/Sampler/Xray/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/Sampler/Xray/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); + diff --git a/src/Sampler/Xray/README.md b/src/Sampler/Xray/README.md new file mode 100644 index 000000000..0d83091af --- /dev/null +++ b/src/Sampler/Xray/README.md @@ -0,0 +1,51 @@ +# AWS X-Ray Sampler + +Provides a sampler which can get sampling configurations from AWS X-Ray to make sampling decisions. See: [AWS X-Ray Sampling](https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-sampling) + +## Installation + +```shell +composer require open-telemetry/sampler-aws-xray +``` + +## Configuration +You can configure the `AWSXRayRemoteSampler` as per the following example. +Note that you will need to configure your [OpenTelemetry Collector for +X-Ray remote sampling](https://aws-otel.github.io/docs/getting-started/remote-sampling). + +```php + 'MyServiceName', + 'service.version'=> '1.0.0', + 'cloud.provider' => 'aws', +])); + +$xraySampler = new AWSXRayRemoteSampler( + $resource, + 'http://localhost:2000', + 2 +); + +$tracerProvider = TracerProvider::builder() + ->setResource($resource) + ->setSampler($xraySampler) + ->addSpanProcessor( + new SimpleSpanProcessor( + (new ConsoleSpanExporterFactory())->create() + ) + ) + ->build(); +``` diff --git a/src/Sampler/Xray/composer.json b/src/Sampler/Xray/composer.json new file mode 100644 index 000000000..c5a80a7c8 --- /dev/null +++ b/src/Sampler/Xray/composer.json @@ -0,0 +1,39 @@ +{ + "name": "open-telemetry/sampler-aws-xray", + "description": "AWS X-Ray Remote Sampler for OpenTelemetry PHP Contrib", + "type": "library", + "license": "Apache-2.0", + "require": { + "php": "^8.1", + "aws/aws-sdk-php": "^3.0", + "open-telemetry/api": "^1.1.0", + "open-telemetry/sdk": "^1.1.0", + "open-telemetry/sdk-configuration": "^0.0.5", + "open-telemetry/sem-conv": "^1.32" + }, + "require-dev": { + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^6 || ^7", + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5.0", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "psalm/plugin-phpunit": "^0.19.2", + "phpunit/phpunit": "^10 || ^11", + "vimeo/psalm": "^4|^5|6.4.0" + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\Contrib\\Sampler\\Xray\\": "src/" + }, + "classmap": ["src/AWSXRayRemoteSampler.php"] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } + } +} diff --git a/src/Sampler/Xray/phpstan.neon.dist b/src/Sampler/Xray/phpstan.neon.dist new file mode 100644 index 000000000..4c06d6b2e --- /dev/null +++ b/src/Sampler/Xray/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + tmpDir: var/cache/phpstan + level: 5 + paths: + - src + - tests \ No newline at end of file diff --git a/src/Sampler/Xray/phpunit.xml.dist b/src/Sampler/Xray/phpunit.xml.dist new file mode 100644 index 000000000..aebce8c3c --- /dev/null +++ b/src/Sampler/Xray/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + + + + + + + tests/Unit + + + tests/Integration + + + + + src + + + diff --git a/src/Sampler/Xray/psalm.xml.dist b/src/Sampler/Xray/psalm.xml.dist new file mode 100644 index 000000000..155711712 --- /dev/null +++ b/src/Sampler/Xray/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/Sampler/Xray/src/AWSXRayRemoteSampler.php b/src/Sampler/Xray/src/AWSXRayRemoteSampler.php new file mode 100644 index 000000000..69abfa559 --- /dev/null +++ b/src/Sampler/Xray/src/AWSXRayRemoteSampler.php @@ -0,0 +1,214 @@ +root = new ParentBased(new _AWSXRayRemoteSampler($resource, $host, $pollingInterval)); + } + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + return $this->root->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + + public function getDescription(): string + { + return sprintf( + 'AWSXRayRemoteSampler{root=%s}', + $this->root->getDescription() + ); + } +} + +class _AWSXRayRemoteSampler implements SamplerInterface +{ + private RulesCache $rulesCache; + private FallbackSampler $fallback; + private AWSXRaySamplerClient $client; + + private int $rulePollingIntervalNanos; + /** @psalm-suppress UnusedProperty */ + private int $targetPollingIntervalNanos; + + // the times below are in nanoseconds + private int $nextRulesFetchTime; + private int $nextTargetFetchTime; + + private int $rulePollingJitterNanos; + private int $targetPollingJitterNanos; + + private string $awsProxyEndpoint; + + /** + * @param ResourceInfo $resource + * Must contain attributes like service.name, cloud.platform, etc. + * @param string $awsProxyEndpoint + * X-Ray awsProxyEndpoint, e.g. "xray.us-west-2.amazonaws.com" + * @param int $pollingInterval + * Base interval (seconds) between rule fetches (will be jittered). + */ + public function __construct( + ResourceInfo $resource, + string $awsProxyEndpoint, + int $pollingInterval + ) { + $this->fallback = new FallbackSampler(); + $this->rulesCache = new RulesCache( + bin2hex(random_bytes(12)), + $resource, + $this->fallback + ); + + $this->rulePollingIntervalNanos = $pollingInterval * ClockInterface::NANOS_PER_SECOND; + $this->rulePollingJitterNanos = rand(1, 5000) * ClockInterface::NANOS_PER_MILLISECOND; + + $this->targetPollingIntervalNanos = $this->rulesCache::DEFAULT_TARGET_INTERVAL_SEC * ClockInterface::NANOS_PER_SECOND; + $this->targetPollingJitterNanos = rand(1, 100) * ClockInterface::NANOS_PER_MILLISECOND; + + $this->awsProxyEndpoint = $awsProxyEndpoint; + + $this->client = new AWSXRaySamplerClient($awsProxyEndpoint); + + // 1) Initial fetch of rules + try { + $initialRules = $this->client->getSamplingRules(); + $this->rulesCache->updateRules($initialRules); + } catch (Exception $e) { + // ignore failures + } + + // 2) Schedule next fetch times with jitter + $now = Clock::getDefault()->now(); + $this->nextRulesFetchTime = $now + ($this->rulePollingJitterNanos + $this->rulePollingIntervalNanos); + $this->nextTargetFetchTime = $now + ($this->targetPollingJitterNanos + $this->targetPollingIntervalNanos); + } + + /** + * Called on each sampling decision. If it’s time, refresh rules or targets. + */ + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + $now = Clock::getDefault()->now(); + + // 1) Refresh rules if needed + if ($now >= $this->nextRulesFetchTime) { + $this->getAndUpdateRules($now); + } + + // 2) Refresh targets if needed + if ($now >= $this->nextTargetFetchTime) { + $appliers = $this->rulesCache->getAppliers(); + $statsDocs = []; + foreach ($appliers as $applier) { + $statsDocs[] = $applier->snapshot($now); + } + + try { + $resp = $this->client->getSamplingTargets($statsDocs); + if ($resp !== null && isset($resp->SamplingTargetDocuments)) { + $map = []; + foreach ($resp->SamplingTargetDocuments as $tgt) { + $map[$tgt->RuleName] = $tgt; + } + $this->rulesCache->updateTargets($map); + + if (isset($resp->LastRuleModification) && $resp->LastRuleModification > 0) { + if (($resp->LastRuleModification * ClockInterface::NANOS_PER_SECOND) > $this->rulesCache->getUpdatedAt()) { + $this->getAndUpdateRules($now); + } + } + } + } catch (Exception $e) { + //ignore for now + } + + $nextTargetFetchTime = $this->rulesCache->nextTargetFetchTime(); + $nextTargetFetchInterval = $nextTargetFetchTime - Clock::getDefault()->now(); + if ($nextTargetFetchInterval < 0) { + $nextTargetFetchInterval = $this->rulesCache::DEFAULT_TARGET_INTERVAL_SEC * ClockInterface::NANOS_PER_SECOND; + } + $this->nextTargetFetchTime = $now + ($this->targetPollingJitterNanos + $nextTargetFetchInterval); + } + + // 3) Delegate decision to rulesCache or fallback + // if cache expired, fallback + if ($this->rulesCache->expired()) { + return $this->fallback->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + + // delegate + return $this->rulesCache->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + + private function getAndUpdateRules(int $now) + { + try { + $rules = $this->client->getSamplingRules(); + $this->rulesCache->updateRules($rules); + } catch (Exception $e) { + // ignore error + } + $this->nextRulesFetchTime = $now + ($this->rulePollingJitterNanos + $this->rulePollingIntervalNanos); + } + + public function getDescription(): string + { + return sprintf( + '_AWSXRayRemoteSampler{awsProxyEndpoint=%s,rulePollingIntervalNanos=%ds}', + $this->awsProxyEndpoint, + $this->rulePollingIntervalNanos + ); + } +} diff --git a/src/Sampler/Xray/src/AWSXRaySamplerClient.php b/src/Sampler/Xray/src/AWSXRaySamplerClient.php new file mode 100644 index 000000000..edba28540 --- /dev/null +++ b/src/Sampler/Xray/src/AWSXRaySamplerClient.php @@ -0,0 +1,93 @@ +host = rtrim($host, '/'); + $this->httpClient = new HttpClient([ + 'base_uri' => $this->host, + 'timeout' => 2.0, + ]); + } + + /** + * Fetches all sampling rules from X-Ray by paging through NextToken. + * + * @return SamplingRule[] Array of SamplingRule instances. + */ + public function getSamplingRules(): array + { + $rules = []; + $response = $this->httpClient->post('GetSamplingRules'); + $data = json_decode((string) $response->getBody(), true); + + if (isset($data['SamplingRuleRecords'])) { + foreach ($data['SamplingRuleRecords'] as $rec) { + $r = $rec['SamplingRule']; + $rules[] = new SamplingRule( + $r['RuleName'], + $r['Priority'], + $r['FixedRate'], + $r['ReservoirSize'], + $r['Host'] ?? '*', + $r['HTTPMethod'] ?? '*', + $r['ResourceARN'] ?? '*', + $r['ServiceName'] ?? '*', + $r['ServiceType'] ?? '*', + $r['URLPath'] ?? '*', + $r['Version'] ?? 1, + $r['Attributes'] ?? [] + ); + } + } + + return $rules; + } + + /** + * Sends current statistics documents to X-Ray and returns the decoded response. + * + * @param SamplingStatisticsDocument[] $statistics + * @return object|null stdClass of the X-Ray GetSamplingTargets response. + */ + public function getSamplingTargets(array $statistics): ?object + { + $docs = []; + foreach ($statistics as $d) { + $docs[] = [ + 'ClientID' => $d->ClientID, + 'RuleName' => $d->RuleName, + 'RequestCount' => $d->RequestCount, + 'SampleCount' => $d->SampleCount, + 'BorrowCount' => $d->BorrowCount, + 'Timestamp' => $d->Timestamp, + ]; + } + + $response = $this->httpClient->post('SamplingTargets', [ + 'json' => ['SamplingStatisticsDocuments' => $docs], + ]); + + return json_decode((string) $response->getBody()); + } +} diff --git a/src/Sampler/Xray/src/FallbackSampler.php b/src/Sampler/Xray/src/FallbackSampler.php new file mode 100644 index 000000000..1d8b3cbe3 --- /dev/null +++ b/src/Sampler/Xray/src/FallbackSampler.php @@ -0,0 +1,45 @@ +reservoir = new RateLimitingSampler(1); // 1/sec + $this->fixedRate = new TraceIdRatioBasedSampler(0.05); // 5% + } + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + $res = $this->reservoir->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + if ($res->getDecision() !== SamplingResult::DROP) { + return $res; + } + + return $this->fixedRate->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + + public function getDescription(): string + { + return 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests'; + } +} diff --git a/src/Sampler/Xray/src/Matcher.php b/src/Sampler/Xray/src/Matcher.php new file mode 100644 index 000000000..bb3549a06 --- /dev/null +++ b/src/Sampler/Xray/src/Matcher.php @@ -0,0 +1,58 @@ + 'AWS::EC2::Instance', + 'aws_ecs' => 'AWS::ECS::Container', + 'aws_eks' => 'AWS::EKS::Container', + 'aws_elastic_beanstalk' => 'AWS::ElasticBeanstalk::Environment', + 'aws_lambda' => 'AWS::Lambda::Function', + ]; + + /** + * Check that every rule attribute key/value is present and equal in the span tags. + */ + public static function attributeMatch(?array $tags, array $attributes): bool + { + foreach ($attributes as $key => $value) { + if ($tags === null || !array_key_exists($key, $tags)) { + return false; + } + if ((string) $tags[$key] !== (string) $value) { + return false; + } + } + + return true; + } + + /** + * Wildcard match (‘*’ → any chars). Null value only matches if pattern is '*' or empty. + */ + public static function wildcardMatch(?string $value, string $pattern): bool + { + if ($pattern === '' || $pattern === '*') { + return true; + } + if ($value === null) { + return false; + } + // escape regex, then replace \* with .* + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + + return (bool) preg_match($regex, $value); + } + + /** + * Map OpenTelemetry cloud.platform values to X-Ray service type strings. + */ + public static function getXRayCloudPlatform(string $platform): string + { + return self::$xrayCloudPlatform[$platform] ?? ''; + } +} diff --git a/src/Sampler/Xray/src/RateLimiter.php b/src/Sampler/Xray/src/RateLimiter.php new file mode 100644 index 000000000..9b3727461 --- /dev/null +++ b/src/Sampler/Xray/src/RateLimiter.php @@ -0,0 +1,57 @@ +capacity = $capacity; + $this->tokens = $capacity; + $this->intervalMillis = $intervalMillis; + $this->lastRefillTime = microtime(true) * 1000; + } + + /** + * Attempt to take one token. Returns true if successful, false if rate‐limited. + */ + public function tryAcquire(): bool + { + $now = microtime(true) * 1000; + $elapsed = $now - $this->lastRefillTime; + + if ($elapsed >= $this->intervalMillis) { + // Time to refill the bucket + $this->tokens = $this->capacity; + $this->lastRefillTime = $now; + } + + if ($this->tokens > 0) { + $this->tokens--; + + return true; + } + + return false; + } + + public function getCapacity(): int + { + return $this->capacity; + } +} diff --git a/src/Sampler/Xray/src/RateLimitingSampler.php b/src/Sampler/Xray/src/RateLimitingSampler.php new file mode 100644 index 000000000..4aaa10a9c --- /dev/null +++ b/src/Sampler/Xray/src/RateLimitingSampler.php @@ -0,0 +1,48 @@ +limiter = new RateLimiter($maxTracesPerSecond); + } + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + if ($this->limiter->tryAcquire()) { + return new SamplingResult(SamplingResult::RECORD_AND_SAMPLE, [], null); + } + + return new SamplingResult(SamplingResult::DROP, [], null); + } + + public function getDescription(): string + { + return sprintf('RateLimitingSampler{rate limiting sampling with sampling config of %d req/sec and 0%% of additional requests}', $this->limiter->getCapacity()); + } +} diff --git a/src/Sampler/Xray/src/RulesCache.php b/src/Sampler/Xray/src/RulesCache.php new file mode 100644 index 000000000..63de5a28b --- /dev/null +++ b/src/Sampler/Xray/src/RulesCache.php @@ -0,0 +1,126 @@ +clientId = $clientId; + $this->resource = $resource; + $this->fallbackSampler = $fallback; + $this->updatedAt = Clock::getDefault()->now(); + } + + public function expired(): bool + { + return Clock::getDefault()->now() > $this->updatedAt + self::CACHE_TTL; + } + + public function updateRules(array $newRules): void + { + usort($newRules, fn (SamplingRule $a, SamplingRule $b) => $a->compareTo($b)); + $newAppliers = []; + foreach ($newRules as $rule) { + // reuse existing applier if same ruleName + $found = null; + foreach ($this->appliers as $ap) { + if ($ap->getRuleName() === $rule->RuleName) { + $found = $ap; + + break; + } + } + $applier = $found ?? new SamplingRuleApplier($this->clientId, $rule); + + // update rule in applier + $applier->setRule($rule); + $newAppliers[] = $applier; + } + $this->appliers = $newAppliers; + $this->updatedAt = Clock::getDefault()->now(); + } + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + foreach ($this->appliers as $applier) { + if ($applier->matches($attributes, $this->resource)) { + return $applier->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + } + + // fallback if no rule matched + return $this->fallbackSampler->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + + public function nextTargetFetchTime(): int + { + $defaultPollingTime = Clock::getDefault()->now() + (self::DEFAULT_TARGET_INTERVAL_SEC * ClockInterface::NANOS_PER_SECOND); + + if (empty($this->appliers)) { + return $defaultPollingTime; + } + $times = array_map(fn ($a) => $a->getNextSnapshotTime(), $this->appliers); + $min = min($times); + + return $min < Clock::getDefault()->now() + ? $defaultPollingTime + : $min; + } + + /** Update reservoir/fixed rates from GetSamplingTargets response */ + public function updateTargets(array $targets): void + { + $new = []; + foreach ($this->appliers as $applier) { + $name = $applier->getRuleName(); + if (isset($targets[$name])) { + $new[] = $applier->withTarget($targets[$name], Clock::getDefault()->now()); + } else { + $new[] = $applier; + } + } + $this->appliers = $new; + } + + public function getAppliers(): array + { + return $this->appliers; + } + + public function getDescription(): string + { + return 'RulesCache'; + } + + public function getUpdatedAt(): int + { + return $this->updatedAt; + } +} diff --git a/src/Sampler/Xray/src/SamplingRule.php b/src/Sampler/Xray/src/SamplingRule.php new file mode 100644 index 000000000..6d4250b56 --- /dev/null +++ b/src/Sampler/Xray/src/SamplingRule.php @@ -0,0 +1,38 @@ +Priority <=> $other->Priority; + + return $cmp !== 0 ? $cmp : strcmp($this->RuleName, $other->RuleName); + } +} diff --git a/src/Sampler/Xray/src/SamplingRuleApplier.php b/src/Sampler/Xray/src/SamplingRuleApplier.php new file mode 100644 index 000000000..f26fe3acf --- /dev/null +++ b/src/Sampler/Xray/src/SamplingRuleApplier.php @@ -0,0 +1,199 @@ +clientId = $clientId; + $this->rule = $rule; + $this->ruleName = $rule->RuleName; + $this->statistics = $stats ?? new Statistics(); + + if ($rule->ReservoirSize > 0) { + $this->reservoirSampler = new RateLimitingSampler($rule->ReservoirSize); + $this->borrowing = true; + } else { + $this->reservoirSampler = new AlwaysOffSampler(); + $this->borrowing = false; + } + + $this->fixedRateSampler = new TraceIdRatioBasedSampler($rule->FixedRate); + $this->reservoirEndTime = PHP_INT_MAX; + $this->nextSnapshotTime = Clock::getDefault()->now(); + } + + public function matches(AttributesInterface $attributes, ResourceInfo $resource): bool + { + // Extract HTTP path + $httpTarget = $attributes->get(TraceAttributes::HTTP_TARGET) ?? $attributes->get(TraceAttributes::URL_PATH); // @phan-suppress-current-line PhanDeprecatedClassConstant + $httpUrl = $attributes->get(TraceAttributes::HTTP_URL) ?? $attributes->get(TraceAttributes::URL_FULL); // @phan-suppress-current-line PhanDeprecatedClassConstant + if ($httpTarget == null && isset($httpUrl)) { + $httpTarget = parse_url($httpUrl, PHP_URL_PATH); + $httpTarget = $httpTarget ? $httpTarget : null; + } + + $httpMethod = $attributes->get(TraceAttributes::HTTP_METHOD) ?? $attributes->get(TraceAttributes::HTTP_REQUEST_METHOD); // @phan-suppress-current-line PhanDeprecatedClassConstant + if ($httpMethod == '_OTHER') { + $httpMethod = $attributes->get(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL); + } + $httpHost = $attributes->get(TraceAttributes::HTTP_HOST) ?? $attributes->get(TraceAttributes::SERVER_ADDRESS); // @phan-suppress-current-line PhanDeprecatedClassConstant + $serviceName= $resource->getAttributes()->get(TraceAttributes::SERVICE_NAME) ?? ''; + $cloudPlat = $resource->getAttributes()->get(TraceAttributes::CLOUD_PLATFORM) ?? ''; + $serviceType= Matcher::getXRayCloudPlatform($cloudPlat); + + // ARN: ECS container ARN or Lambda faas.id + $arn = $resource->getAttributes()->get('aws.ecs.container.arn') + ?? ($serviceType === 'AWS::Lambda::Function' ? ($attributes->get('faas.id') ?? null) : null) + ?? ''; + + return Matcher::attributeMatch($attributes->toArray(), $this->rule->Attributes) + && Matcher::wildcardMatch($httpTarget, $this->rule->UrlPath) + && Matcher::wildcardMatch($httpMethod, $this->rule->HttpMethod) + && Matcher::wildcardMatch($httpHost, $this->rule->Host) + && Matcher::wildcardMatch($serviceName, $this->rule->ServiceName) + && Matcher::wildcardMatch($serviceType, $this->rule->ServiceType) + && Matcher::wildcardMatch($arn, $this->rule->ResourceArn); + } + + /** @psalm-suppress ArgumentTypeCoercion */ + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links, + ): SamplingResult { + $this->statistics->requestCount++; + $now = Clock::getDefault()->now(); + if ($now < $this->reservoirEndTime) { + $res = $this->reservoirSampler->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + ; + if ($res->getDecision() !== SamplingResult::DROP) { + if ($this->borrowing) { + $this->statistics->borrowCount++; + } + $this->statistics->sampleCount++; + + return $res; + } + } + + $res = $this->fixedRateSampler->shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + if ($res->getDecision() !== SamplingResult::DROP) { + $this->statistics->sampleCount++; + } + + return $res; + } + + public function snapshot(int $now): SamplingStatisticsDocument + { + $ts = intdiv($now, ClockInterface::NANOS_PER_MILLISECOND); + $req = $this->statistics->requestCount; + $smp = $this->statistics->sampleCount; + $brw = $this->statistics->borrowCount; + // reset + $this->statistics->requestCount = 0; + $this->statistics->sampleCount = 0; + $this->statistics->borrowCount = 0; + + return new SamplingStatisticsDocument( + $this->clientId, + $this->ruleName, + $req, + $smp, + $brw, + $ts + ); + } + + /** + * Apply an AWS X-Ray SamplingTargets response to this rule applier, + * returning a new applier with updated reservoir & fixed-rate samplers. + * + * @param object $targetDoc stdClass from AWS SDK getSamplingTargets() + * @param int $now “now” timestamp for computing next snapshot + */ + public function withTarget(object $targetDoc, int $now): self + { + // 1) Determine new fixed-rate sampler + if (isset($targetDoc->FixedRate)) { + $newFixedRateSampler = new TraceIdRatioBasedSampler((float) $targetDoc->FixedRate); + } else { + $newFixedRateSampler = $this->fixedRateSampler; + } + + // 2) Determine new reservoir sampler & end time + $newReservoirEndTime = PHP_INT_MAX; + if (isset($targetDoc->ReservoirQuota, $targetDoc->ReservoirQuotaTTL)) { + $quota = (int) floor($targetDoc->ReservoirQuota); + $ttlSeconds = (int) floor($targetDoc->ReservoirQuotaTTL); + $newReservoirSampler = $quota > 0 + ? new RateLimitingSampler($quota) + : new AlwaysOffSampler(); + $newReservoirEndTime = $ttlSeconds * ClockInterface::NANOS_PER_SECOND + ?: $newReservoirEndTime; + } else { + // if no quota provided, turn off reservoir + $newReservoirSampler = new AlwaysOffSampler(); + } + + // 3) Next snapshot time (Interval in seconds, defaulting to 10s) + $intervalSec = isset($targetDoc->Interval) + ? (int) $targetDoc->Interval + : 10; + $newNextSnapshotTime = $now + ($intervalSec * ClockInterface::NANOS_PER_SECOND); + + // 4) Clone & patch + $clone = clone $this; + $clone->fixedRateSampler = $newFixedRateSampler; + $clone->reservoirSampler = $newReservoirSampler; + $clone->borrowing = false; // once we’ve applied a target, no more borrowing + $clone->reservoirEndTime = $newReservoirEndTime; + $clone->nextSnapshotTime = $newNextSnapshotTime; + + return $clone; + } + + public function getNextSnapshotTime(): int + { + return $this->nextSnapshotTime; + } + + public function getRuleName(): string + { + return $this->ruleName; + } + + public function setRule($rule) + { + $this->rule = $rule; + } +} diff --git a/src/Sampler/Xray/src/SamplingStatisticsDocument.php b/src/Sampler/Xray/src/SamplingStatisticsDocument.php new file mode 100644 index 000000000..0d1b177f6 --- /dev/null +++ b/src/Sampler/Xray/src/SamplingStatisticsDocument.php @@ -0,0 +1,19 @@ +createMock(AWSXRaySamplerClient::class); + $dummyRules = ['rule1', 'rule2']; + $mockClient->expects($this->once()) + ->method('getSamplingRules') + ->willReturn($dummyRules); + $mockClient->expects($this->once()) + ->method('getSamplingTargets') + ->willReturn((object) [ + 'SamplingTargetDocuments' => [], + 'Interval' => 5, + 'LastRuleModification' => 0, + ]); + + // 2) Mock RulesCache + $mockRulesCache = $this->getMockBuilder(RulesCache::class) + ->disableOriginalConstructor() + ->getMock(); + $mockRulesCache->expects($this->once()) + ->method('updateRules') + ->with($dummyRules); + $mockRulesCache->expects($this->once()) + ->method('getAppliers') + ->willReturn([]); + $mockRulesCache->expects($this->once()) + ->method('updateTargets') + ->with([]); + $mockRulesCache->expects($this->once()) + ->method('expired') + ->willReturn(false); + $expectedResult = new SamplingResult(SamplingResult::RECORD_AND_SAMPLE); + $mockRulesCache->expects($this->once()) + ->method('shouldSample') + ->willReturn($expectedResult); + + // 3) Instantiate and inject mocks + $sampler = new _AWSXRayRemoteSampler($resource, 'host', 0); + $ref = new ReflectionClass($sampler); + $ref->getProperty('client')->setAccessible(true); + $ref->getProperty('client')->setValue($sampler, $mockClient); + $ref->getProperty('rulesCache')->setAccessible(true); + $ref->getProperty('rulesCache')->setValue($sampler, $mockRulesCache); + $ref->getProperty('fallback')->setAccessible(true); + $ref->getProperty('fallback')->setValue($sampler, $this->createMock(FallbackSampler::class)); + + // 4) Force fetch times into the past so updates run + $now = Clock::getDefault()->now(); + $ref->getProperty('nextRulesFetchTime')->setAccessible(true); + $ref->getProperty('nextRulesFetchTime')->setValue($sampler, $now - (1 * ClockInterface::NANOS_PER_SECOND)); + $ref->getProperty('nextTargetFetchTime')->setAccessible(true); + $ref->getProperty('nextTargetFetchTime')->setValue($sampler, $now - (1 * ClockInterface::NANOS_PER_SECOND)); + + // 5) Call shouldSample + $result = $sampler->shouldSample( + $this->createMock(ContextInterface::class), + 'traceId', + 'spanName', + 1, + Attributes::create([]), + [] + ); + $this->assertSame($expectedResult, $result); + } + + public function testShouldSampleFallbackWhenExpired(): void + { + $resource = ResourceInfo::create(Attributes::create([])); + $mockClient = $this->createMock(AWSXRaySamplerClient::class); + + $mockRulesCache = $this->getMockBuilder(RulesCache::class) + ->disableOriginalConstructor() + ->getMock(); + // No updateRules call since expired + $mockRulesCache->expects($this->never()) + ->method('updateRules'); + $mockRulesCache->expects($this->once()) + ->method('expired') + ->willReturn(true); + + $fallback = $this->createMock(FallbackSampler::class); + $expected = new SamplingResult(SamplingResult::DROP); + $fallback->expects($this->once()) + ->method('shouldSample') + ->willReturn($expected); + + $sampler = new _AWSXRayRemoteSampler($resource, 'host', 0); + $ref = new ReflectionClass($sampler); + $ref->getProperty('client')->setAccessible(true); + $ref->getProperty('client')->setValue($sampler, $mockClient); + $ref->getProperty('rulesCache')->setAccessible(true); + $ref->getProperty('rulesCache')->setValue($sampler, $mockRulesCache); + $ref->getProperty('fallback')->setAccessible(true); + $ref->getProperty('fallback')->setValue($sampler, $fallback); + + $result = $sampler->shouldSample( + $this->createMock(ContextInterface::class), + 't', + 'n', + 1, + Attributes::create([]), + [] + ); + $this->assertSame($expected, $result); + } +} diff --git a/src/Sampler/Xray/tests/Unit/AWSXraySamplerClientTest.php b/src/Sampler/Xray/tests/Unit/AWSXraySamplerClientTest.php new file mode 100644 index 000000000..7e8f9d39e --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/AWSXraySamplerClientTest.php @@ -0,0 +1,107 @@ +rulesJson = file_get_contents($dataDir . '/sampling_rules.json'); + $this->targetsJson = file_get_contents($dataDir . '/sampling_targets.json'); + } + + public function testGetSamplingRules(): void + { + // 1) Mock Guzzle client to return our sample JSON + $mockHttp = $this->createMock(\GuzzleHttp\Client::class); + $mockHttp->expects($this->once()) + ->method('post') + ->with('GetSamplingRules') + ->willReturn(new Response(200, [], $this->rulesJson)); + + // 2) Instantiate client and inject mock + $client = new AWSXRaySamplerClient('https://xray'); + $ref = new ReflectionClass($client); + $prop = $ref->getProperty('httpClient'); + $prop->setAccessible(true); + $prop->setValue($client, $mockHttp); + + // 3) Call method under test + $rules = $client->getSamplingRules(); + + // 4) Assertions: two rules, correct mapping + $this->assertCount(2, $rules); + + $r1 = $rules[0]; + $this->assertSame('Default', $r1->RuleName); + $this->assertSame(0.05, $r1->FixedRate); + $this->assertSame(100, $r1->ReservoirSize); + $this->assertEquals(['foo'=>'bar','abc'=>'1234'], $r1->Attributes); + + $r2 = $rules[1]; + $this->assertSame('test', $r2->RuleName); + $this->assertSame(0.11, $r2->FixedRate); + $this->assertSame(1, $r2->ReservoirSize); + } + + public function testGetSamplingTargets(): void + { + // 1) Mock Guzzle client for SamplingTargets + $mockHttp = $this->createMock(\GuzzleHttp\Client::class); + $mockHttp->expects($this->once()) + ->method('post') + ->with('SamplingTargets', $this->arrayHasKey('json')) + ->willReturn(new Response(200, [], $this->targetsJson)); + + // 2) Instantiate and inject mock + $client = new AWSXRaySamplerClient('https://xray'); + $ref = new ReflectionClass($client); + $prop = $ref->getProperty('httpClient'); + $prop->setAccessible(true); + $prop->setValue($client, $mockHttp); + + // 3) Build a sample SamplingStatisticsDocument + $statDoc = new SamplingStatisticsDocument( + 'client1', + 'test', + 10, + 5, + 2, + 1234.0 + ); + + // 4) Call method under test + $resp = $client->getSamplingTargets([$statDoc]); + + // 5) Assertions on returned stdClass + $this->assertIsObject($resp); + $this->assertSame(1707551387.0, $resp->LastRuleModification); + $this->assertObjectHasProperty('SamplingTargetDocuments', $resp); + $this->assertCount(2, $resp->SamplingTargetDocuments); + + $t1 = $resp->SamplingTargetDocuments[0]; + $this->assertSame('test', $t1->RuleName); + $this->assertSame(30, $t1->ReservoirQuota); + $this->assertSame(0.10, $t1->FixedRate); + + $t2 = $resp->SamplingTargetDocuments[1]; + $this->assertSame('Default', $t2->RuleName); + $this->assertSame(0, $t2->ReservoirQuota); + $this->assertSame(0.05, $t2->FixedRate); + } +} diff --git a/src/Sampler/Xray/tests/Unit/RateLimiterTest.php b/src/Sampler/Xray/tests/Unit/RateLimiterTest.php new file mode 100644 index 000000000..ff4a04c24 --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/RateLimiterTest.php @@ -0,0 +1,44 @@ +assertTrue($limiter->tryAcquire()); + $this->assertTrue($limiter->tryAcquire()); + $this->assertTrue($limiter->tryAcquire()); + } + + public function testCannotAcquireAfterCapacity(): void + { + $limiter = new RateLimiter(2, 1000); + $this->assertTrue($limiter->tryAcquire()); + $this->assertTrue($limiter->tryAcquire()); + $this->assertFalse($limiter->tryAcquire(), 'Should not acquire more than capacity'); + } + + public function testRefillAfterInterval(): void + { + // Use a short interval for test (100 ms) + $limiter = new RateLimiter(1, 100); + $this->assertTrue($limiter->tryAcquire(), 'First acquire should succeed'); + $this->assertFalse($limiter->tryAcquire(), 'Second acquire before refill should fail'); + + // Wait >100 ms to allow refill + usleep(150000); // 150 ms + + $this->assertTrue($limiter->tryAcquire(), 'Acquire after interval should succeed'); + } + + public function testGetCapacity(): void + { + $limiter = new RateLimiter(5, 1000); + $this->assertEquals(5, $limiter->getCapacity()); + } +} diff --git a/src/Sampler/Xray/tests/Unit/RulesCacheTest.php b/src/Sampler/Xray/tests/Unit/RulesCacheTest.php new file mode 100644 index 000000000..7131d93fe --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/RulesCacheTest.php @@ -0,0 +1,116 @@ +resource = ResourceInfo::create(Attributes::create([ + 'service.name' => 'test-service', + 'cloud.platform' => 'aws_ecs', + ])); + } + + public function testUpdateRulesSortsByPriorityThenName(): void + { + $fallback = new AlwaysOffSampler(); + $cache = new RulesCache('client', $this->resource, $fallback); + + $rule1 = new SamplingRule('b', 2, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + $rule2 = new SamplingRule('a', 1, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + $rule3 = new SamplingRule('c', 1, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + + // Provide in unsorted order + $cache->updateRules([$rule1, $rule2, $rule3]); + + $names = array_map( + fn ($ap) => $ap->getRuleName(), + $cache->getAppliers() + ); + + // Expected order: a (prio1), c (prio1, name 'c' > 'a'), b (prio2) + $this->assertEquals(['a', 'c', 'b'], $names); + } + + public function testUpdateRulesReusesExistingAppliers(): void + { + $fallback = new AlwaysOffSampler(); + $cache = new RulesCache('client', $this->resource, $fallback); + + $ruleA1 = new SamplingRule('ruleA', 1, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + $ruleB = new SamplingRule('ruleB', 1, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + + $cache->updateRules([$ruleA1, $ruleB]); + $appliers1 = $cache->getAppliers(); + + // Now update rules: change ruleA's FixedRate and remove ruleB; add ruleC + $ruleA2 = new SamplingRule('ruleA', 1, 0.5, 0, '*', '*', '*', '*', '*', '*', 1, []); + $ruleC = new SamplingRule('ruleC', 2, 0.1, 0, '*', '*', '*', '*', '*', '*', 1, []); + + $cache->updateRules([$ruleA2, $ruleC]); + $appliers2 = $cache->getAppliers(); + + // Extract applier objects for ruleA + $a1 = array_filter($appliers1, fn ($a) => $a->getRuleName() === 'ruleA'); + $a2 = array_filter($appliers2, fn ($a) => $a->getRuleName() === 'ruleA'); + $this->assertCount(1, $a1); + $this->assertCount(1, $a2); + $a1 = array_shift($a1); + $a2 = array_shift($a2); + + // The applier for ruleA should be the same instance (reused) + $this->assertSame($a1, $a2); + + // ruleB should be removed, ruleC added + $names2 = array_map(fn ($a) => $a->getRuleName(), $appliers2); + $this->assertNotContains('ruleB', $names2); + $this->assertContains('ruleC', $names2); + } + + public function testUpdateTargetsClonesMatchingAppliers(): void + { + $fallback = new AlwaysOffSampler(); + $cache = new RulesCache('client', $this->resource, $fallback); + + $ruleA = new SamplingRule('ruleA', 1, 0.1, 5, '*', '*', '*', '*', '*', '*', 1, []); + $ruleB = new SamplingRule('ruleB', 1, 0.1, 5, '*', '*', '*', '*', '*', '*', 1, []); + + $cache->updateRules([$ruleA, $ruleB]); + $appliers1 = $cache->getAppliers(); + + // Prepare a dummy target doc for ruleA + $targetDoc = (object) [ + 'RuleName' => 'ruleA', + 'FixedRate' => 0.2, + 'ReservoirQuota' => 2, + 'ReservoirQuotaTTL'=> time() + 60, + 'Interval' => 15, + ]; + $cache->updateTargets(['ruleA' => $targetDoc]); + $appliers2 = $cache->getAppliers(); + + // ruleA applier should be a new instance + $a1 = array_filter($appliers1, fn ($a) => $a->getRuleName() === 'ruleA'); + $a2 = array_filter($appliers2, fn ($a) => $a->getRuleName() === 'ruleA'); + $a1 = array_shift($a1); + $a2 = array_shift($a2); + $this->assertNotSame($a1, $a2); + + // ruleB applier should be unchanged + $b1 = array_filter($appliers1, fn ($a) => $a->getRuleName() === 'ruleB'); + $b2 = array_filter($appliers2, fn ($a) => $a->getRuleName() === 'ruleB'); + $b1 = array_shift($b1); + $b2 = array_shift($b2); + $this->assertSame($b1, $b2); + } +} diff --git a/src/Sampler/Xray/tests/Unit/SamplingRuleApplierTest.php b/src/Sampler/Xray/tests/Unit/SamplingRuleApplierTest.php new file mode 100644 index 000000000..6bbfce6d9 --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/SamplingRuleApplierTest.php @@ -0,0 +1,308 @@ + 'POST', + TraceAttributes::HTTP_TARGET => '/foo/bar', + TraceAttributes::HTTP_HOST => 'example.com', + ]); + $resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => 'AnyService', + TraceAttributes::CLOUD_PLATFORM => 'aws_lambda', + ])); + + $this->assertTrue( + $applier->matches($attrs, $resource), + 'Wildcard rule should match any attributes' + ); + } + + public function testSpecificRuleMatchesExactAttributesOldSemanticConventions(): void + { + // Rule with specific matching values + $rule = new SamplingRule( + 'specific', + 1, + 0.5, + 0, + 'example.com', + 'GET', + 'arn:aws:ecs:123', + 'MyService', + 'AWS::ECS::Container', + '/api/test', + 1, + ['env' => 'prod'] + ); + $applier = new SamplingRuleApplier('client', $rule); + + // Matching attributes + $attrs = Attributes::create([ + TraceAttributes::HTTP_METHOD => 'GET', + TraceAttributes::HTTP_URL => 'https://example.com/api/test?x=1', + TraceAttributes::HTTP_HOST => 'example.com', + 'env' => 'prod', + ]); + $resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => 'MyService', + TraceAttributes::CLOUD_PLATFORM => 'aws_ecs', + 'aws.ecs.container.arn' => 'arn:aws:ecs:123', + ])); + + $this->assertTrue( + $applier->matches($attrs, $resource), + 'Specific rule should match when all values line up' + ); + } + + public function testWildcardRuleMatchesAnyAttributesNewSemanticConventions(): void + { + // Rule with all wildcards and no specific attributes + $rule = new SamplingRule( + 'wildcard', + 1, + 0.5, + 0, + '*', + '*', + '*', + '*', + '*', + '*', + 1, + [] + ); + $applier = new SamplingRuleApplier('client', $rule); + + // Attributes that should all match '*' + $attrs = Attributes::create([ + TraceAttributes::HTTP_REQUEST_METHOD => 'POST', + TraceAttributes::URL_PATH => '/foo/bar', + TraceAttributes::SERVER_ADDRESS => 'example.com', + ]); + $resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => 'AnyService', + TraceAttributes::CLOUD_PLATFORM => 'aws_lambda', + ])); + + $this->assertTrue( + $applier->matches($attrs, $resource), + 'Wildcard rule should match any attributes' + ); + } + + public function testSpecificRuleMatchesExactAttributesNewSemanticConventions(): void + { + // Rule with specific matching values + $rule = new SamplingRule( + 'specific', + 1, + 0.5, + 0, + 'example.com', + 'GET', + 'arn:aws:ecs:123', + 'MyService', + 'AWS::ECS::Container', + '/api/test', + 1, + ['env' => 'prod'] + ); + $applier = new SamplingRuleApplier('client', $rule); + + // Matching attributes + $attrs = Attributes::create([ + TraceAttributes::HTTP_REQUEST_METHOD => 'GET', + TraceAttributes::URL_FULL => 'https://example.com/api/test?x=1', + TraceAttributes::SERVER_ADDRESS => 'example.com', + 'env' => 'prod', + ]); + $resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => 'MyService', + TraceAttributes::CLOUD_PLATFORM => 'aws_ecs', + 'aws.ecs.container.arn' => 'arn:aws:ecs:123', + ])); + + $this->assertTrue( + $applier->matches($attrs, $resource), + 'Specific rule should match when all values line up' + ); + } + + public function testRuleDoesNotMatchWhenOneAttributeDiffers(): void + { + // Same rule as above + $rule = new SamplingRule( + 'specific', + 1, + 0.5, + 0, + 'example.com', + 'GET', + 'arn:aws:ecs:123', + 'MyService', + 'AWS::ECS::Container', + '/api/test', + 1, + ['env' => 'prod'] + ); + $applier = new SamplingRuleApplier('client', $rule); + + // Attributes with wrong HTTP method + $attrs = Attributes::create([ + TraceAttributes::HTTP_METHOD => 'POST', + TraceAttributes::HTTP_URL => 'https://example.com/api/test', + TraceAttributes::HTTP_HOST => 'example.com', + 'env' => 'prod', + ]); + $resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => 'MyService', + TraceAttributes::CLOUD_PLATFORM => 'aws_ecs', + 'aws.ecs.container.arn' => 'arn:aws:ecs:123', + ])); + + $this->assertFalse( + $applier->matches($attrs, $resource), + 'Rule should not match when HTTP method differs' + ); + } + + public function testShouldSample_incrementsStatistics_andHonorsReservoirSamplerDecision(): void + { + $rule = new SamplingRule('r', 1, 0.0, 1, '*', '*', '*', '*', '*', '*', 1, []); + $applier = new SamplingRuleApplier('c', $rule, null); + + // Mock reservoirSampler to RECORD + $reservoirMock = $this->createMock(SamplerInterface::class); + $reservoirMock->method('shouldSample') + ->willReturn(new SamplingResult(SamplingResult::RECORD_AND_SAMPLE, [], null)); + // Mock fixedRateSampler to DROP (should not be used) + $fixedMock = $this->createMock(SamplerInterface::class); + $fixedMock->method('shouldSample') + ->willReturn(new SamplingResult(SamplingResult::DROP, [], null)); + + // Inject mocks via reflection + $ref = new \ReflectionClass($applier); + $propRes = $ref->getProperty('reservoirSampler'); + $propRes->setAccessible(true); + $propRes->setValue($applier, $reservoirMock); + $propFix = $ref->getProperty('fixedRateSampler'); + $propFix->setAccessible(true); + $propFix->setValue($applier, $fixedMock); + // Ensure borrowing = true + $propBorrow = $ref->getProperty('borrowing'); + $propBorrow->setAccessible(true); + $propBorrow->setValue($applier, true); + + $context = $this->createMock(ContextInterface::class); + $attributes = Attributes::create([]); + + // Perform sampling + $result = $applier->shouldSample($context, 'trace', 'span', 0, $attributes, []); + $this->assertSame(SamplingResult::RECORD_AND_SAMPLE, $result->getDecision()); + + // Snapshot statistics + $now = Clock::getDefault()->now(); + $statsDoc = $applier->snapshot($now); + + $this->assertSame(1, $statsDoc->RequestCount); + $this->assertSame(1, $statsDoc->SampleCount); + $this->assertSame(1, $statsDoc->BorrowCount); + } + + public function testShouldSample_onReservoirDrop_usesFixedRateSampler_andIncrementsSampleCountOnly(): void + { + $rule = new SamplingRule('r2', 1, 1.0, 0, '*', '*', '*', '*', '*', '*', 1, []); + $applier = new SamplingRuleApplier('c2', $rule, null); + + // reservoirSampler: always DROP + $reservoirMock = $this->createMock(SamplerInterface::class); + $reservoirMock->method('shouldSample') + ->willReturn(new SamplingResult(SamplingResult::DROP, [], null)); + // fixedRateSampler: RECORD + $fixedMock = $this->createMock(SamplerInterface::class); + $fixedMock->method('shouldSample') + ->willReturn(new SamplingResult(SamplingResult::RECORD_AND_SAMPLE, [], null)); + + $ref = new \ReflectionClass($applier); + $propRes = $ref->getProperty('reservoirSampler'); + $propRes->setAccessible(true); + $propRes->setValue($applier, $reservoirMock); + $propFix = $ref->getProperty('fixedRateSampler'); + $propFix->setAccessible(true); + $propFix->setValue($applier, $fixedMock); + + $context = $this->createMock(ContextInterface::class); + $attributes = Attributes::create([]); + + $result = $applier->shouldSample($context, 't2', 's2', 0, $attributes, []); + $this->assertSame(SamplingResult::RECORD_AND_SAMPLE, $result->getDecision()); + + $now = Clock::getDefault()->now(); + $statsDoc = $applier->snapshot($now); + $this->assertSame(1, $statsDoc->RequestCount); + $this->assertSame(1, $statsDoc->SampleCount); + $this->assertSame(0, $statsDoc->BorrowCount); + } + + public function testSnapshot_resetsStatisticsAfterCapture(): void + { + $rule = new SamplingRule('r3', 1, 0.0, 1, '*', '*', '*', '*', '*', '*', 1, []); + $applier = new SamplingRuleApplier('c3', $rule, null); + + // simulate stats by reflection + $refStats = new \ReflectionProperty($applier, 'statistics'); + $refStats->setAccessible(true); + $stats = $refStats->getValue($applier); + $stats->requestCount = 5; + $stats->sampleCount = 2; + $stats->borrowCount = 1; + + $now = Clock::getDefault()->now(); + $doc1 = $applier->snapshot($now); + $this->assertSame(5, $doc1->RequestCount); + $this->assertSame(2, $doc1->SampleCount); + $this->assertSame(1, $doc1->BorrowCount); + + // After snapshot, internal counters should reset + $doc2 = $applier->snapshot($now); + $this->assertSame(0, $doc2->RequestCount); + $this->assertSame(0, $doc2->SampleCount); + $this->assertSame(0, $doc2->BorrowCount); + } +} diff --git a/src/Sampler/Xray/tests/Unit/data/sampling_rules.json b/src/Sampler/Xray/tests/Unit/data/sampling_rules.json new file mode 100644 index 000000000..e686867f7 --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/data/sampling_rules.json @@ -0,0 +1,43 @@ +{ + "NextToken": null, + "SamplingRuleRecords": [ + { + "CreatedAt": 1.676038494E9, + "ModifiedAt": 1.676038494E9, + "SamplingRule": { + "Attributes": {"foo":"bar","abc":"1234"}, + "FixedRate": 0.05, + "HTTPMethod": "*", + "Host": "*", + "Priority": 10000, + "ReservoirSize": 100, + "ResourceARN": "*", + "RuleARN": "arn:aws:xray:us-east-1:999999999999:sampling-rule/Default", + "RuleName": "Default", + "ServiceName": "*", + "ServiceType": "*", + "URLPath": "*", + "Version": 1 + } + }, + { + "CreatedAt": 1.67799933E9, + "ModifiedAt": 1.67799933E9, + "SamplingRule": { + "Attributes": {"abc":"1234"}, + "FixedRate": 0.11, + "HTTPMethod": "*", + "Host": "*", + "Priority": 20, + "ReservoirSize": 1, + "ResourceARN": "*", + "RuleARN": "arn:aws:xray:us-east-1:999999999999:sampling-rule/test", + "RuleName": "test", + "ServiceName": "*", + "ServiceType": "*", + "URLPath": "*", + "Version": 1 + } + } + ] +} \ No newline at end of file diff --git a/src/Sampler/Xray/tests/Unit/data/sampling_targets.json b/src/Sampler/Xray/tests/Unit/data/sampling_targets.json new file mode 100644 index 000000000..498fe1505 --- /dev/null +++ b/src/Sampler/Xray/tests/Unit/data/sampling_targets.json @@ -0,0 +1,20 @@ +{ + "LastRuleModification": 1707551387.0, + "SamplingTargetDocuments": [ + { + "FixedRate": 0.10, + "Interval": 10, + "ReservoirQuota": 30, + "ReservoirQuotaTTL": 1707764006.0, + "RuleName": "test" + }, + { + "FixedRate": 0.05, + "Interval": 10, + "ReservoirQuota": 0, + "ReservoirQuotaTTL": 1707764006.0, + "RuleName": "Default" + } + ], + "UnprocessedStatistics": [] +} \ No newline at end of file