diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 554e2d42..30fce34c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -10,12 +10,11 @@ on: jobs: php: runs-on: ubuntu-latest - continue-on-error: true + continue-on-error: false strategy: fail-fast: false matrix: - operating-system: [ubuntu-latest] - php-version: ['7.4', '8.0', '8.1'] + php-version: ['7.4', '8.0', '8.1', '8.2'] project: [ 'Aws', 'Context/Swoole', @@ -65,6 +64,7 @@ jobs: - name: Check Style working-directory: src/${{ matrix.project }} + continue-on-error: ${{ matrix.php-version == 8.2 }} #temporary, until php-cs-fixer is happy with php8.2 run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php -v --dry-run --stop-on-violation --using-cache=no -vvv - name: Run Phan diff --git a/src/Instrumentation/HttpAsyncClient/.php-cs-fixer.php b/src/Instrumentation/HttpAsyncClient/.php-cs-fixer.php new file mode 100644 index 00000000..248b4b9a --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/.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, + 'function_typehint_space' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_braces' => 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, + 'single_blank_line_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); + diff --git a/src/Instrumentation/HttpAsyncClient/README.md b/src/Instrumentation/HttpAsyncClient/README.md new file mode 100644 index 00000000..8bdc42e7 --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/README.md @@ -0,0 +1,31 @@ +# OpenTelemetry HTTPlug async auto-instrumentation + +## Requirements + +* OpenTelemetry extension +* OpenTelemetry SDK an exporter (required to actually export traces) +* An HTTPlug async client +* (optional) OpenTelemetry [SDK Autoloading](https://github.com/open-telemetry/opentelemetry-php/blob/main/examples/autoload_sdk.php) configured + +## Overview +Auto-instrumentation hooks are registered via composer, which will: + +* create spans automatically for each async request that is sent + +## Manual configuration +If you are not using SDK autoloading, you will need to create and register a `TracerProvider` early in your application's lifecycle: + +```php +withTracerProvider($tracerProvider) + ->activate(); + +$client->sendAsyncRequest($request); + +$scope->detach(); +$tracerProvider->shutdown(); +``` diff --git a/src/Instrumentation/HttpAsyncClient/_register.php b/src/Instrumentation/HttpAsyncClient/_register.php new file mode 100644 index 00000000..5c620f3e --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/_register.php @@ -0,0 +1,5 @@ +withTracerProvider($tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) + ->activate(); + +$root = $tracerProvider->getTracer('async-requests-demo')->spanBuilder('root')->startSpan(); +$rootScope = $root->activate(); + +try { + $requests = [ + new Request('GET', 'https://postman-echo.com/get'), + new Request('POST', 'https://postman-echo.com/post'), + new Request('GET', 'https://httpbin.org/does-not-exist'), + new Request('GET', 'https://httpbin.org/get'), + ]; + $client = new Client(); + $promises = []; + foreach ($requests as $request) { + $promises[] = [$request, $client->sendAsyncRequest($request)]; + } + foreach ($promises as [$request, $promise]) { + try { + $response = $promise->wait(); + echo sprintf('[%d] ', $response->getStatusCode()) . json_encode(json_decode($response->getBody()->getContents()), JSON_PRETTY_PRINT), PHP_EOL; + } catch (\Exception $e) { + var_dump($e->getMessage()); + } + } + + $root->end(); +} finally { + $rootScope->detach(); + $scope->detach(); + $tracerProvider->shutdown(); +} diff --git a/src/Instrumentation/HttpAsyncClient/phpstan.neon.dist b/src/Instrumentation/HttpAsyncClient/phpstan.neon.dist new file mode 100644 index 00000000..ed94c13d --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/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/HttpAsyncClient/phpunit.xml.dist b/src/Instrumentation/HttpAsyncClient/phpunit.xml.dist new file mode 100644 index 00000000..d8548853 --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/phpunit.xml.dist @@ -0,0 +1,47 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + tests/Integration + + + + diff --git a/src/Instrumentation/HttpAsyncClient/psalm.xml.dist b/src/Instrumentation/HttpAsyncClient/psalm.xml.dist new file mode 100644 index 00000000..15571171 --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/Instrumentation/HttpAsyncClient/src/HttpAsyncClientInstrumentation.php b/src/Instrumentation/HttpAsyncClient/src/HttpAsyncClientInstrumentation.php new file mode 100644 index 00000000..dde342dd --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/src/HttpAsyncClientInstrumentation.php @@ -0,0 +1,120 @@ +attach(Context::getCurrent()); + + return null; + } + + $propagator = Instrumentation\Globals::propagator(); + $parentContext = Context::getCurrent(); + + /** @psalm-suppress ArgumentTypeCoercion */ + $spanBuilder = $instrumentation + ->tracer() + ->spanBuilder(sprintf('HTTP %s', $request->getMethod())) + ->setParent($parentContext) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::HTTP_URL, (string) $request->getUri()) + ->setAttribute(TraceAttributes::HTTP_METHOD, $request->getMethod()) + ->setAttribute(TraceAttributes::HTTP_FLAVOR, $request->getProtocolVersion()) + ->setAttribute(TraceAttributes::HTTP_USER_AGENT, $request->getHeaderLine('User-Agent')) + ->setAttribute(TraceAttributes::HTTP_REQUEST_CONTENT_LENGTH, $request->getHeaderLine('Content-Length')) + ->setAttribute(TraceAttributes::NET_PEER_NAME, $request->getUri()->getHost()) + ->setAttribute(TraceAttributes::NET_PEER_PORT, $request->getUri()->getPort()) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) + ; + + foreach ($propagator->fields() as $field) { + $request = $request->withoutHeader($field); + } + //@todo could we use SDK Configuration to retrieve this, and move into a key such as OTEL_PHP_xxx? + foreach ((array) (get_cfg_var('otel.instrumentation.http.request_headers') ?: []) as $header) { + if ($request->hasHeader($header)) { + $spanBuilder->setAttribute( + sprintf('http.request.header.%s', strtr(strtolower($header), ['-' => '_'])), + $request->getHeader($header) + ); + } + } + + $span = $spanBuilder->startSpan(); + $context = $span->storeInContext($parentContext); + //$propagator->inject($request, HeadersPropagator::instance(), $context); + + Context::storage()->attach($context); + + return [$request]; + }, + post: static function (HttpAsyncClient $client, array $params, Promise $promise, ?Throwable $exception): void { + $scope = Context::storage()->scope(); + $scope?->detach(); + + //@todo do we need the second part of this 'or'? + if (!$scope || $scope->context() === Context::getCurrent()) { + return; + } + + $span = Span::fromContext($scope->context()); + if ($exception) { + $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + $span->end(); + } + + $promise->then( + onFulfilled: function (ResponseInterface $response) use ($span) { + $span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $response->getStatusCode()); + $span->setAttribute(TraceAttributes::HTTP_FLAVOR, $response->getProtocolVersion()); + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH, $response->getHeaderLine('Content-Length')); + if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 600) { + $span->setStatus(StatusCode::STATUS_ERROR); + } + $span->end(); + + return $response; + }, + onRejected: function (\Throwable $t) use ($span) { + $span->recordException($t, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setStatus(StatusCode::STATUS_ERROR, $t->getMessage()); + $span->end(); + + throw $t; + } + ); + } + ); + } +} diff --git a/src/Instrumentation/HttpAsyncClient/tests/Integration/HttpAsyncClientInstrumentationTest.php b/src/Instrumentation/HttpAsyncClient/tests/Integration/HttpAsyncClientInstrumentationTest.php new file mode 100644 index 00000000..124cf57e --- /dev/null +++ b/src/Instrumentation/HttpAsyncClient/tests/Integration/HttpAsyncClientInstrumentationTest.php @@ -0,0 +1,117 @@ +client = $this->createMock(HttpAsyncClient::class); + + $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(); + } + + public function test_send_async_requests(): void + { + $request = new Request('GET', 'http://example.com'); + $p1 = $this->promise(new Response(200)); + $p2 = $this->promise(new \Exception()); + $this->client->method('sendAsyncRequest')->willReturnOnConsecutiveCalls( + $p1, + $p2, + ); + $this->assertCount(0, $this->storage); + $this->client->sendAsyncRequest($request); + $this->client->sendAsyncRequest($request); + $this->assertCount(0, $this->storage, 'no spans exporter since promises are not resolved yet'); + //resolve promises + try { + $p2->wait(); + } catch (\Exception $e) { + //expected exception + } + $this->assertCount(1, $this->storage); + $p1->wait(); + $this->assertCount(2, $this->storage); + } + + /** + * @param ResponseInterface|Throwable $response + */ + private function promise($response): Promise + { + return new class($response) implements Promise { + private $response; + private $onFulfilled = null; + private $onRejected = null; + + public function __construct($response) + { + $this->response = $response; + } + + public function then(callable $onFulfilled = null, callable $onRejected = null): Promise + { + $this->onFulfilled = $onFulfilled; + $this->onRejected = $onRejected; + + return $this; + } + + public function getState(): string + { + return 'unused'; + } + + public function wait($unwrap = true) + { + $c = ($this->response instanceof \Throwable) ? $this->onRejected : $this->onFulfilled; + if (is_callable($c)) { + return $c($this->response); + } + + return new FulfilledPromise($this->response); + } + }; + } +} diff --git a/src/Instrumentation/HttpAsyncClient/tests/Unit/.gitkeep b/src/Instrumentation/HttpAsyncClient/tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b