diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b73a41309..4515fadea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -82,6 +82,7 @@ updates: - "/src/Instrumentation/Psr18" - "/src/Instrumentation/Psr3" - "/src/Instrumentation/Psr6" + - "/src/Instrumentation/ReactPHP" - "/src/Instrumentation/Slim" - "/src/Instrumentation/Symfony" - "/src/Instrumentation/Wordpress" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 601a3f5d0..769e89061 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -43,6 +43,7 @@ jobs: 'Instrumentation/Psr15', 'Instrumentation/Psr16', 'Instrumentation/Psr18', + 'Instrumentation/ReactPHP', 'Instrumentation/Slim', 'Instrumentation/Symfony', 'Instrumentation/Yii', diff --git a/.gitsplit.yml b/.gitsplit.yml index 072982784..e6268eaaa 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -48,6 +48,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-psr16.git" - prefix: "src/Instrumentation/Psr18" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-psr18.git" + - prefix: "src/Instrumentation/ReactPHP" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-reactphp.git" - prefix: "src/Instrumentation/Slim" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-slim.git" - prefix: "src/Instrumentation/Symfony" diff --git a/composer.json b/composer.json index 363615182..6155e0978 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "OpenTelemetry\\Contrib\\Instrumentation\\Psr3\\": "src/Instrumentation/Psr3/src", "OpenTelemetry\\Contrib\\Instrumentation\\Psr15\\": "src/Instrumentation/Psr15/src", "OpenTelemetry\\Contrib\\Instrumentation\\Psr18\\": "src/Instrumentation/Psr18/src", + "OpenTelemetry\\Contrib\\Instrumentation\\ReactPHP\\": "src/Instrumentation/ReactPHP/src", "OpenTelemetry\\Contrib\\Instrumentation\\Slim\\": "src/Instrumentation/Slim/src", "OpenTelemetry\\Contrib\\Instrumentation\\Symfony\\": "src/Instrumentation/Symfony/src", "OpenTelemetry\\Contrib\\Instrumentation\\Wordpress\\": "src/Instrumentation/Wordpress/src", @@ -53,6 +54,7 @@ "src/Instrumentation/Psr3/_register.php", "src/Instrumentation/Psr15/_register.php", "src/Instrumentation/Psr18/_register.php", + "src/Instrumentation/ReactPHP/_register.php", "src/Instrumentation/Slim/_register.php", "src/Instrumentation/Symfony/_register.php", "src/Instrumentation/Wordpress/_register.php", @@ -79,6 +81,7 @@ "open-telemetry/opentelemetry-auto-psr3": "self.version", "open-telemetry/opentelemetry-auto-psr15": "self.version", "open-telemetry/opentelemetry-auto-psr18": "self.version", + "open-telemetry/opentelemetry-auto-reactphp": "self.version", "open-telemetry/opentelemetry-auto-slim": "self.version", "open-telemetry/opentelemetry-auto-symfony": "self.version", "open-telemetry/opentelemetry-auto-wordpress": "self.version", diff --git a/src/Instrumentation/ReactPHP/.gitattributes b/src/Instrumentation/ReactPHP/.gitattributes new file mode 100644 index 000000000..ed9722120 --- /dev/null +++ b/src/Instrumentation/ReactPHP/.gitattributes @@ -0,0 +1,14 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.phan export-ignore +/.php-cs-fixer.php export-ignore +/examples export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore diff --git a/src/Instrumentation/ReactPHP/.gitignore b/src/Instrumentation/ReactPHP/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/src/Instrumentation/ReactPHP/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/Instrumentation/ReactPHP/.phan/config.php b/src/Instrumentation/ReactPHP/.phan/config.php new file mode 100644 index 000000000..da2ac2d99 --- /dev/null +++ b/src/Instrumentation/ReactPHP/.phan/config.php @@ -0,0 +1,371 @@ + '8.0', + + // 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/', + 'proto/', + 'thrift/' + ], + + // 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/Instrumentation/ReactPHP/.php-cs-fixer.php b/src/Instrumentation/ReactPHP/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/Instrumentation/ReactPHP/.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/Instrumentation/ReactPHP/README.md b/src/Instrumentation/ReactPHP/README.md new file mode 100644 index 000000000..f621f646a --- /dev/null +++ b/src/Instrumentation/ReactPHP/README.md @@ -0,0 +1,43 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-auto-reactphp/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Instrumentation/ReactPHP) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-auto-reactphp) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-auto-reactphp/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-reactphp/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-auto-reactphp/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-reactphp/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry ReactPHP HTTP Browser auto-instrumentation + +Please read https://opentelemetry.io/docs/instrumentation/php/automatic/ for instructions on how to +install and configure the extension and SDK. + +## Overview + +Auto-instrumentation hooks are registered via composer, which will: + +* create spans automatically for each ReactPHP HTTP Browser request that is sent +* add a `traceparent` header to the request to facilitate distributed tracing + +Note that span lifetime behavior differs based on how ReactPHP is utilized; see [examples/README.md](examples/README.md) for more information. + +## Configuration + +The extension can be disabled via [runtime configuration](https://opentelemetry.io/docs/instrumentation/php/sdk/#configuration): + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=reactphp +``` + +Custom HTTP methods can replace the known methods via environment variables, e.g.: + +```shell +OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS="GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH,MyCustomMethod" +``` + +Request and/or response headers can be added as span attributes via environment variables, e.g.: + +```shell +OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=Accept +OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS="Content-Length,Content-Type" +``` diff --git a/src/Instrumentation/ReactPHP/_register.php b/src/Instrumentation/ReactPHP/_register.php new file mode 100644 index 000000000..0900c4b23 --- /dev/null +++ b/src/Instrumentation/ReactPHP/_register.php @@ -0,0 +1,18 @@ +create('php://output', 'application/json'); +$exporter = new ConsoleSpanExporter($transport); + +$tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + new AlwaysOnSampler(), + ResourceInfoFactory::emptyResource(), +); + +Sdk::builder() + ->setTracerProvider($tracerProvider) + ->setPropagator(TraceContextPropagator::getInstance()) + ->setAutoShutdown(true) + ->buildAndRegisterGlobal(); + +$root = $tracerProvider->getTracer('reactphp-demo')->spanBuilder('root')->startSpan(); +$rootScope = $root->activate(); + +try { + $browser = new Browser(); + + $requests = [ + new Request('GET', 'https://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/stream/33554432'), + new Request('POST', 'https://postman-echo.com/post', ['Content-Type' => 'application/json'], '{}'), + new Request('CUSTOM', 'http://postman-echo.com:443/get'), + new Request('GET', 'unknown://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/delay/2'), + ]; + + foreach ($requests as $request) { + $browser + ->request($request->getMethod(), $request->getUri()) + ->then(function (ResponseInterface $response) use ($request) { + echo sprintf( + '[HTTP/%s %d %s] %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri(), + PHP_EOL + ); + }, function (Throwable $t) use ($request) { + if (is_a($t, ResponseException::class)) { + $response = $t->getResponse(); + echo sprintf( + '[HTTP/%s %d %s] %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri(), + PHP_EOL + ); + } else { + echo sprintf( + '[%d: %s] %s%s', + $t->getCode(), + $t->getMessage(), + $request->getUri(), + PHP_EOL + ); + } + }); + } +} finally { + $rootScope->detach(); + $root->end(); +} diff --git a/src/Instrumentation/ReactPHP/examples/http_requests_with_async.php b/src/Instrumentation/ReactPHP/examples/http_requests_with_async.php new file mode 100644 index 000000000..e1f2ab86a --- /dev/null +++ b/src/Instrumentation/ReactPHP/examples/http_requests_with_async.php @@ -0,0 +1,101 @@ +create('php://output', 'application/json'); +$exporter = new ConsoleSpanExporter($transport); + +$tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + new AlwaysOnSampler(), + ResourceInfoFactory::emptyResource(), +); + +Sdk::builder() + ->setTracerProvider($tracerProvider) + ->setPropagator(TraceContextPropagator::getInstance()) + ->setAutoShutdown(true) + ->buildAndRegisterGlobal(); + +$context = Context::getCurrent(); + +$root = $tracerProvider->getTracer('reactphp-demo')->spanBuilder('root')->startSpan(); + +$timer = Loop::addPeriodicTimer(1, function () { + echo 'Some other event loop event' . PHP_EOL; +}); + +Loop::futureTick(async(static function () use ($context, $root, $timer) { + $contextScope = $context->activate(); + $rootScope = $root->activate(); + + try { + sleep(1); + + $browser = new Browser(); + + $requests = [ + new Request('GET', 'https://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/stream/33554432'), + new Request('POST', 'https://postman-echo.com/post', ['Content-Type' => 'application/json'], '{}'), + new Request('CUSTOM', 'http://postman-echo.com:443/get'), + new Request('GET', 'unknown://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/delay/2'), + ]; + + foreach ($requests as $request) { + try { + $response = await($browser->request($request->getMethod(), $request->getUri())); + echo sprintf( + '[HTTP/%s %d %s] %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri(), + PHP_EOL + ); + } catch (ResponseException $e) { + $response = $e->getResponse(); + echo sprintf( + '[HTTP/%s %d %s] %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri(), + PHP_EOL + ); + } catch (Throwable $t) { + echo sprintf( + '[%d: %s] %s%s', + $t->getCode(), + $t->getMessage(), + $request->getUri(), + PHP_EOL + ); + } + } + } finally { + $rootScope->detach(); + $root->end(); + $contextScope->detach(); + Loop::cancelTimer($timer); + } +})); diff --git a/src/Instrumentation/ReactPHP/examples/http_streaming_requests.php b/src/Instrumentation/ReactPHP/examples/http_streaming_requests.php new file mode 100644 index 000000000..57ffa01ba --- /dev/null +++ b/src/Instrumentation/ReactPHP/examples/http_streaming_requests.php @@ -0,0 +1,107 @@ +create('php://output', 'application/json'); +$exporter = new ConsoleSpanExporter($transport); + +$tracerProvider = new TracerProvider( + new SimpleSpanProcessor($exporter), + new AlwaysOnSampler(), + ResourceInfoFactory::emptyResource(), +); + +Sdk::builder() + ->setTracerProvider($tracerProvider) + ->setPropagator(TraceContextPropagator::getInstance()) + ->setAutoShutdown(true) + ->buildAndRegisterGlobal(); + +$root = $tracerProvider->getTracer('reactphp-demo')->spanBuilder('root')->startSpan(); +$rootScope = $root->activate(); + +try { + $browser = new Browser(); + + $requests = [ + new Request('GET', 'https://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/stream/33554432'), + new Request('POST', 'https://postman-echo.com/post', ['Content-Type' => 'application/json'], '{}'), + new Request('CUSTOM', 'http://postman-echo.com:443/get'), + new Request('GET', 'unknown://postman-echo.com/get'), + new Request('GET', 'https://postman-echo.com/delay/2'), + ]; + + foreach ($requests as $request) { + $browser + ->requestStreaming($request->getMethod(), $request->getUri()) + ->then(function (ResponseInterface $response) use ($request) { + $prefix = sprintf( + '[HTTP/%s %d %s] %s: ', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri() + ); + echo $prefix . 'headers received.' . PHP_EOL; + + $stream = $response->getBody(); + assert($stream instanceof StreamInterface); + assert($stream instanceof ReadableStreamInterface); + + $stream->on('error', function (Throwable $t) use ($request) { + echo sprintf( + '[%d: %s] %s%s', + $t->getCode(), + $t->getMessage(), + $request->getUri(), + PHP_EOL + ); + }); + + $stream->on('close', function () use ($prefix) { + echo $prefix . 'body received.' . PHP_EOL; + }); + }, function (Throwable $t) use ($request) { + if (is_a($t, ResponseException::class)) { + $response = $t->getResponse(); + echo sprintf( + '[HTTP/%s %d %s] %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + $request->getUri(), + PHP_EOL + ); + } else { + echo sprintf( + '[%d: %s] %s%s', + $t->getCode(), + $t->getMessage(), + $request->getUri(), + PHP_EOL + ); + } + }); + } +} finally { + $rootScope->detach(); + $root->end(); +} diff --git a/src/Instrumentation/ReactPHP/phpstan.neon.dist b/src/Instrumentation/ReactPHP/phpstan.neon.dist new file mode 100644 index 000000000..ed94c13da --- /dev/null +++ b/src/Instrumentation/ReactPHP/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + tmpDir: var/cache/phpstan + level: 5 + paths: + - src + - tests diff --git a/src/Instrumentation/ReactPHP/phpunit.xml.dist b/src/Instrumentation/ReactPHP/phpunit.xml.dist new file mode 100644 index 000000000..50fd90fa8 --- /dev/null +++ b/src/Instrumentation/ReactPHP/phpunit.xml.dist @@ -0,0 +1,50 @@ + + + + + + + src + + + + + + + + + + + + + + + + tests/Unit + + + tests/Integration + + + + diff --git a/src/Instrumentation/ReactPHP/psalm.xml.dist b/src/Instrumentation/ReactPHP/psalm.xml.dist new file mode 100644 index 000000000..155711712 --- /dev/null +++ b/src/Instrumentation/ReactPHP/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/Instrumentation/ReactPHP/src/HeadersPropagator.php b/src/Instrumentation/ReactPHP/src/HeadersPropagator.php new file mode 100644 index 000000000..d03458e27 --- /dev/null +++ b/src/Instrumentation/ReactPHP/src/HeadersPropagator.php @@ -0,0 +1,30 @@ +withAddedHeader($key, $value); + } +} diff --git a/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php b/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php new file mode 100644 index 000000000..8e20a0dcb --- /dev/null +++ b/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php @@ -0,0 +1,281 @@ +url() + ); + + /** @psalm-suppress UnusedFunctionCall */ + hook( + Transaction::class, + 'send', + pre: static function (Transaction $transaction, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation): array { + /** @var \Psr\Http\Message\RequestInterface */ + $request = $params[0]; + + $propagator = Globals::propagator(); + $parentContext = Context::getCurrent(); + + foreach ($propagator->fields() as $field) { + $request = $request->withoutHeader($field); + } + + /** @var non-empty-string|null */ + $method = self::canonizeMethod($request->getMethod()); + + $spanBuilder = $instrumentation + ->tracer() + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span + ->spanBuilder($method ?? self::HTTP_REQUEST_METHOD_HTTP) + ->setParent($parentContext) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $method ?? TraceAttributeValues::HTTP_REQUEST_METHOD_OTHER) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, $request->getUri()->getHost()) + ->setAttribute(TraceAttributes::SERVER_PORT, $request->getUri()->getPort() ?? ($request->getUri()->getScheme() === 'https' ? 443 : 80)) + ->setAttribute(TraceAttributes::URL_FULL, self::sanitizeUrl($request->getUri())) + // https://opentelemetry.io/docs/specs/semconv/code/ + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, sprintf('%s::%s', $class, $function)); + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span + if ($method === null) { + $spanBuilder->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL, $request->getMethod()); + } + + // https://opentelemetry.io/docs/specs/semconv/code/ + /** @psalm-suppress RiskyTruthyFalsyComparison */ + if ($filename) { + $spanBuilder->setAttribute(TraceAttributes::CODE_FILE_PATH, $filename); + } + /** @psalm-suppress RiskyTruthyFalsyComparison */ + if ($lineno) { + $spanBuilder->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno); + } + + $span = $spanBuilder->startSpan(); + $context = $span->storeInContext($parentContext); + $propagator->inject($request, HeadersPropagator::instance(), $context); + + foreach (explode(',', $_ENV[self::ENV_HTTP_REQUEST_HEADERS] ?? '') as $header) { + if ($request->hasHeader($header)) { + $span->setAttribute( + sprintf('%s.%s', TraceAttributes::HTTP_REQUEST_HEADER, strtolower($header)), + $request->getHeader($header) + ); + } + } + + Context::storage()->attach($context); + + return [$request]; + }, + post: static function (Transaction $transaction, array $params, PromiseInterface $promise): PromiseInterface { + $scope = Context::storage()->scope(); + $scope?->detach(); + + if (!$scope) { + return $promise; + } + + $span = Span::fromContext($scope->context()); + + if (!$span->isRecording()) { + return $promise; + } + + return $promise->then( + onFulfilled: function (ResponseInterface $response) use ($span) { + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span + $span + ->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()) + ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $response->getProtocolVersion()); + + if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 600) { + $span + ->setStatus(StatusCode::STATUS_ERROR) + ->setAttribute(TraceAttributes::ERROR_TYPE, (string) $response->getStatusCode()); + } + + foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) { + if ($response->hasHeader($header)) { + $span->setAttribute( + sprintf('%s.%s', TraceAttributes::HTTP_RESPONSE_HEADER, strtolower($header)), + $response->getHeader($header) + ); + } + } + + $span->end(); + + return $response; + }, + onRejected: function (Throwable $t) use ($span) { + $span->recordException($t); + if (is_a($t, ResponseException::class)) { + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span + $span + ->setStatus(StatusCode::STATUS_ERROR) + ->setAttribute(TraceAttributes::ERROR_TYPE, (string) $t->getCode()) + ->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $t->getCode()) + ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $t->getResponse()->getProtocolVersion()); + + foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) { + if ($t->getResponse()->hasHeader($header)) { + $span->setAttribute( + sprintf('%s.%s', TraceAttributes::HTTP_RESPONSE_HEADER, strtolower($header)), + $t->getResponse()->getHeader($header) + ); + } + } + } else { + $span + ->setStatus(StatusCode::STATUS_ERROR, $t->getMessage()) + ->setAttribute(TraceAttributes::ERROR_TYPE, $t::class); + } + + $span->end(); + + throw $t; + } + ); + } + ); + } + + private static function canonizeMethod(string $method): ?string + { + // RFC9110, RFC5789 + $knownMethods = [ + TraceAttributeValues::HTTP_REQUEST_METHOD_GET, + TraceAttributeValues::HTTP_REQUEST_METHOD_HEAD, + TraceAttributeValues::HTTP_REQUEST_METHOD_POST, + TraceAttributeValues::HTTP_REQUEST_METHOD_PUT, + TraceAttributeValues::HTTP_REQUEST_METHOD_DELETE, + TraceAttributeValues::HTTP_REQUEST_METHOD_CONNECT, + TraceAttributeValues::HTTP_REQUEST_METHOD_OPTIONS, + TraceAttributeValues::HTTP_REQUEST_METHOD_TRACE, + TraceAttributeValues::HTTP_REQUEST_METHOD_PATCH, + ]; + + $overrideMethods = $_ENV[self::ENV_HTTP_KNOWN_METHODS] ?? ''; + if (!empty($overrideMethods)) { + $knownMethods = explode(',', $overrideMethods); + } + + if (in_array($method, $knownMethods)) { + return $method; + } + + return null; + } + + private static function sanitizeUrl(UriInterface $uri): string + { + $userInfo = $uri->getUserInfo(); + if (str_contains($userInfo, ':')) { + $uri = $uri->withUserInfo(self::URL_REDACTION, self::URL_REDACTION); + } elseif ($userInfo !== '') { + $uri = $uri->withUserInfo(self::URL_REDACTION); + } + + $queryString = $uri->getQuery(); + // http_build_query(parse_str()) is not idempotent, so using Guzzle’s Query class for now + if ($queryString !== '') { + $queryParameters = Query::parse($queryString); + $queryParameters = array_merge( + $queryParameters, + array_intersect_key( + array_fill_keys( + self::URL_QUERY_REDACT_KEYS, + self::URL_REDACTION + ), + $queryParameters + ) + ); + $uri = $uri->withQuery(Query::build($queryParameters)); + } + + return (string) $uri; + } +} diff --git a/src/Instrumentation/ReactPHP/tests/Integration/ReactPHPInstrumentationTest.php b/src/Instrumentation/ReactPHP/tests/Integration/ReactPHPInstrumentationTest.php new file mode 100644 index 000000000..586bb861c --- /dev/null +++ b/src/Instrumentation/ReactPHP/tests/Integration/ReactPHPInstrumentationTest.php @@ -0,0 +1,202 @@ +createMock(LoopInterface::class); + /** @var Sender&MockObject */ + $sender = $this->createMock(Sender::class); + $sender->method('send') + ->willReturnCallback(function (RequestInterface $request) { + /** @psalm-suppress InternalMethod */ + return match ($request->getUri()->getPath()) { + '/network_error' => resolve( + (new Response()) + ->withStatus(400) + ->withAddedHeader('Content-Type', 'text/plain; charset=utf-8') + ), + '/unknown_error' => reject(new \Exception('Unknown')), + default => resolve(Response::plaintext('Hello world')) + }; + }); + /** @psalm-suppress InternalClass,InternalMethod */ + $transaction = new Transaction($sender, $loop); + $this->browser = new Browser(null, $loop); + $ref = new \ReflectionProperty($this->browser, 'transaction'); + $ref->setValue($this->browser, $transaction); + + /** + * OpenTelemetry set up + */ + $this->storage = new ArrayObject(); + $this->tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + + $this->scope = Configurator::create() + ->withTracerProvider($this->tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) + ->activate(); + } + + public function tearDown(): void + { + $this->scope->detach(); + $this->tracerProvider->shutdown(); + } + + public function test_fulfilled_promise(): void + { + $this->assertCount(0, $this->storage); + + $this->browser->request('GET', 'http://example.com/success?query#fragment')->then(); + + $this->assertCount(1, $this->storage); + + $span = $this->storage->offsetGet(0); + assert($span instanceof ImmutableSpan); + $this->assertSame('GET', $span->getName()); + $this->assertSame('GET', $span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD)); + $this->assertSame('example.com', $span->getAttributes()->get(TraceAttributes::SERVER_ADDRESS)); + $this->assertSame('http://example.com/success?query#fragment', $span->getAttributes()->get(TraceAttributes::URL_FULL)); + $this->assertSame('React\Http\Io\Transaction::send', $span->getAttributes()->get(TraceAttributes::CODE_FUNCTION_NAME)); + $this->assertSame(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + $this->assertNotEmpty($span->getAttributes()->get(sprintf('%s.%s', TraceAttributes::HTTP_REQUEST_HEADER, 'traceparent'))); + $this->assertStringEndsWith('vendor/react/http/src/Io/Transaction.php', $span->getAttributes()->get(TraceAttributes::CODE_FILE_PATH)); + $this->assertSame(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertSame('1.1', $span->getAttributes()->get(TraceAttributes::NETWORK_PROTOCOL_VERSION)); + $this->assertSame(['text/plain; charset=utf-8'], $span->getAttributes()->get(sprintf('%s.%s', TraceAttributes::HTTP_RESPONSE_HEADER, 'content-type'))); + } + + public function test_fulfilled_promise_with_redactions(): void + { + $this->browser->request('GET', 'http://username@example.com/success')->then(); + + $span = $this->storage->offsetGet(0); + $this->assertSame('http://REDACTED@example.com/success', $span->getAttributes()->get(TraceAttributes::URL_FULL)); + + $this->browser->request('GET', 'http://username:password@example.com/success?Signature=private')->then(); + + $span = $this->storage->offsetGet(1); + $this->assertSame('http://REDACTED:REDACTED@example.com/success?Signature=REDACTED', $span->getAttributes()->get(TraceAttributes::URL_FULL)); + } + + public function test_fulfilled_promise_with_overridden_methods(): void + { + $this->browser->request('CUSTOM', 'http://example.com:8888/success')->then(); + + $span = $this->storage->offsetGet(0); + $this->assertSame('CUSTOM', $span->getName()); + $this->assertSame('CUSTOM', $span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD)); + $this->assertSame(8888, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + $this->assertNull($span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL)); + } + + public function test_fulfilled_promise_with_unknown_method(): void + { + $this->browser->request('UNKNOWN', 'http://example.com/success')->then(); + + $span = $this->storage->offsetGet(0); + $this->assertSame('HTTP', $span->getName()); + $this->assertSame('_OTHER', $span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD)); + $this->assertSame('UNKNOWN', $span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL)); + } + + public function test_fulfilled_promise_with_error(): void + { + $browser = $this->browser->withRejectErrorResponse(false); + $browser->request('GET', 'http://example.com/network_error')->then(); + + $span = $this->storage->offsetGet(0); + $this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame('400', $span->getAttributes()->get(TraceAttributes::ERROR_TYPE)); + $this->assertSame(400, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + } + + public function test_rejected_promise_with_response_exception(): void + { + $this->browser->request('GET', 'http://example.com/network_error')->then(null, function () {}); + + $span = $this->storage->offsetGet(0); + $this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame('400', $span->getAttributes()->get(TraceAttributes::ERROR_TYPE)); + $this->assertSame(400, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $event = $span->getEvents()[0]; + $this->assertSame('exception', $event->getName()); + $this->assertSame(ResponseException::class, $event->getAttributes()->get(TraceAttributes::EXCEPTION_TYPE)); + $this->assertSame('HTTP status code 400 (Bad Request)', $event->getAttributes()->get(TraceAttributes::EXCEPTION_MESSAGE)); + $this->assertSame(['text/plain; charset=utf-8'], $span->getAttributes()->get(sprintf('%s.%s', TraceAttributes::HTTP_RESPONSE_HEADER, 'content-type'))); + } + + public function test_rejected_promise_with_unknown_exception(): void + { + $this->browser->request('GET', 'http://example.com/unknown_error')->then(null, function () {}); + + $span = $this->storage->offsetGet(0); + $this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame('Exception', $span->getAttributes()->get(TraceAttributes::ERROR_TYPE)); + $event = $span->getEvents()[0]; + $this->assertSame('exception', $event->getName()); + $this->assertSame('Exception', $event->getAttributes()->get(TraceAttributes::EXCEPTION_TYPE)); + $this->assertSame('Unknown', $event->getAttributes()->get(TraceAttributes::EXCEPTION_MESSAGE)); + } + + public function test_can_register(): void + { + $this->expectNotToPerformAssertions(); + + ReactPHPInstrumentation::register(); + } + + public function test_bail_on_noop(): void + { + $scope = Configurator::createNoop()->activate(); + $this->browser->request('GET', 'http://example.com/success')->then(); + $scope->detach(); + + $this->assertCount(0, $this->storage); + } +} diff --git a/src/Instrumentation/ReactPHP/tests/Unit/.gitkeep b/src/Instrumentation/ReactPHP/tests/Unit/.gitkeep new file mode 100644 index 000000000..e69de29bb