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