diff --git a/src/Instrumentation/ReactPHP/README.md b/src/Instrumentation/ReactPHP/README.md index f621f646a..951e4feb8 100644 --- a/src/Instrumentation/ReactPHP/README.md +++ b/src/Instrumentation/ReactPHP/README.md @@ -9,17 +9,23 @@ This is a read-only subtree split of https://github.com/open-telemetry/opentelem # OpenTelemetry ReactPHP HTTP Browser auto-instrumentation +This is an OpenTelemetry auto-instrumentation package for the [ReactPHP HTTP library](https://reactphp.org/http/). Currently only the Browser (client) component is instrumented. + 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: +This library is provides the following: -* create spans automatically for each ReactPHP HTTP Browser request that is sent -* add a `traceparent` header to the request to facilitate distributed tracing +- OpenTelemetry Semantic Conventions v1.32.0: + - [HTTP Client Spans](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span) - required and HTTP header (opt-in) attributes only + - [HTTP Client Metrics](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client) - required attributes only +- W3C Trace Context: + - [Trace Context HTTP Request Headers](https://www.w3.org/TR/trace-context/#trace-context-http-headers-format) -Note that span lifetime behavior differs based on how ReactPHP is utilized; see [examples/README.md](examples/README.md) for more information. +> [!NOTE] +> HTTP Client Span lifetime behavior differs based on how ReactPHP is utilized; see [examples/README.md](examples/README.md) for more information. ## Configuration diff --git a/src/Instrumentation/ReactPHP/examples/http_requests_metric_only.php b/src/Instrumentation/ReactPHP/examples/http_requests_metric_only.php new file mode 100644 index 000000000..c2a8f71fc --- /dev/null +++ b/src/Instrumentation/ReactPHP/examples/http_requests_metric_only.php @@ -0,0 +1,78 @@ +addReader(new ExportingReader($exporter)) + ->setResource(ResourceInfoFactory::emptyResource()) + ->build(); + +Sdk::builder() + ->setMeterProvider($meterProvider) + ->setAutoShutdown(true) + ->buildAndRegisterGlobal(); + +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 { + $meterProvider->forceFlush(); +} diff --git a/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php b/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php index 8e20a0dcb..36f5987d4 100644 --- a/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php +++ b/src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php @@ -6,6 +6,8 @@ use Composer\InstalledVersions; use GuzzleHttp\Psr7\Query; +use OpenTelemetry\API\Common\Time\Clock; +use OpenTelemetry\API\Common\Time\ClockInterface; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Instrumentation\CachedInstrumentation; use OpenTelemetry\API\Trace\Span; @@ -81,6 +83,9 @@ class ReactPHPInstrumentation public static function register(): void { + /** @var \OpenTelemetry\API\Metrics\HistogramInterface|null */ + static $histogram; + $instrumentation = new CachedInstrumentation( self::INSTRUMENTATION_LIBRARY_NAME, InstalledVersions::getPrettyVersion(self::COMPOSER_NAME), @@ -102,24 +107,28 @@ public static function register(): void $request = $request->withoutHeader($field); } - /** @var non-empty-string|null */ - $method = self::canonizeMethod($request->getMethod()); + /** @var array{'http.request.method':non-empty-string|null,'server.address':non-empty-string,'server.port':int} $requestMeta */ + $requestMeta = [ + 'http.request.method' => self::canonizeMethod($request->getMethod()), + 'server.address' => $request->getUri()->getHost(), + 'server.port' => $request->getUri()->getPort() ?? ($request->getUri()->getScheme() === 'https' ? 443 : 80), + ]; $spanBuilder = $instrumentation ->tracer() // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span - ->spanBuilder($method ?? self::HTTP_REQUEST_METHOD_HTTP) + ->spanBuilder($requestMeta['http.request.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::HTTP_REQUEST_METHOD, $requestMeta['http.request.method'] ?? TraceAttributeValues::HTTP_REQUEST_METHOD_OTHER) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, $requestMeta['server.address']) + ->setAttribute(TraceAttributes::SERVER_PORT, $requestMeta['server.port']) ->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) { + if ($requestMeta['http.request.method'] === null) { $spanBuilder->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL, $request->getMethod()); } @@ -133,7 +142,8 @@ public static function register(): void $spanBuilder->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno); } - $span = $spanBuilder->startSpan(); + $requestStart = Clock::getDefault()->now(); + $span = $spanBuilder->setStartTimestamp($requestStart)->startSpan(); $context = $span->storeInContext($parentContext); $propagator->inject($request, HeadersPropagator::instance(), $context); @@ -146,11 +156,13 @@ public static function register(): void } } - Context::storage()->attach($context); + $scope = Context::storage()->attach($context); + $scope->offsetSet('requestMeta', $requestMeta); + $scope->offsetSet('requestStart', $requestStart); return [$request]; }, - post: static function (Transaction $transaction, array $params, PromiseInterface $promise): PromiseInterface { + post: static function (Transaction $transaction, array $params, PromiseInterface $promise) use (&$histogram, $instrumentation): PromiseInterface { $scope = Context::storage()->scope(); $scope?->detach(); @@ -160,21 +172,42 @@ public static function register(): void $span = Span::fromContext($scope->context()); - if (!$span->isRecording()) { + //https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client + $histogram ??= $instrumentation->meter()->createHistogram( + 'http.client.request.duration', + 's', + 'Duration of HTTP client requests.', + ['ExplicitBucketBoundaries' => [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10]] + ); + + if (!$span->isRecording() && !$histogram->isEnabled()) { return $promise; } + /** @var array{'http.request.method':non-empty-string|null,'server.address':non-empty-string,'server.port':int} $requestMeta */ + $requestMeta = $scope->offsetGet('requestMeta'); + $requestMeta['http.request.method'] ??= '_OTHER'; + /** @var int $requestStart */ + $requestStart = $scope->offsetGet('requestStart'); + return $promise->then( - onFulfilled: function (ResponseInterface $response) use ($span) { + onFulfilled: function (ResponseInterface $response) use ($histogram, $requestMeta, $requestStart, $span) { + $requestEnd = Clock::getDefault()->now(); + /** @var array{'http.response.status_code':int,'network.protocol.version':non-empty-string,'error.type'?:non-empty-string} $responseMeta */ + $responseMeta = [ + 'http.response.status_code' => $response->getStatusCode(), + 'network.protocol.version' => $response->getProtocolVersion(), + ]; // 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()); + ->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $responseMeta['http.response.status_code']) + ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $responseMeta['network.protocol.version']); - if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 600) { + if ($responseMeta['http.response.status_code'] >= 400 && $responseMeta['http.response.status_code'] < 600) { $span ->setStatus(StatusCode::STATUS_ERROR) - ->setAttribute(TraceAttributes::ERROR_TYPE, (string) $response->getStatusCode()); + ->setAttribute(TraceAttributes::ERROR_TYPE, (string) $responseMeta['http.response.status_code']); + $responseMeta['error.type'] = (string) $responseMeta['http.response.status_code']; } foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) { @@ -186,19 +219,31 @@ public static function register(): void } } - $span->end(); + $span->end($requestEnd); + + $histogram->record( + (float) (($requestEnd - $requestStart) / ClockInterface::NANOS_PER_SECOND), + array_merge($requestMeta, $responseMeta) + ); return $response; }, - onRejected: function (Throwable $t) use ($span) { + onRejected: function (Throwable $t) use ($histogram, $requestMeta, $requestStart, $span) { + $requestEnd = Clock::getDefault()->now(); $span->recordException($t); if (is_a($t, ResponseException::class)) { + /** @var array{'http.response.status_code':int,'network.protocol.version':non-empty-string,'error.type':non-empty-string} $responseMeta */ + $responseMeta = [ + 'error.type' => (string) $t->getCode(), + 'http.response.status_code' => $t->getCode(), + 'network.protocol.version' => $t->getResponse()->getProtocolVersion(), + ]; // 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()); + ->setAttribute(TraceAttributes::ERROR_TYPE, $responseMeta['error.type']) + ->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $responseMeta['http.response.status_code']) + ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $responseMeta['network.protocol.version']); foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) { if ($t->getResponse()->hasHeader($header)) { @@ -209,12 +254,21 @@ public static function register(): void } } } else { + /** @var array{'error.type':non-empty-string} $responseMeta */ + $responseMeta = [ + 'error.type' => $t::class, + ]; $span ->setStatus(StatusCode::STATUS_ERROR, $t->getMessage()) - ->setAttribute(TraceAttributes::ERROR_TYPE, $t::class); + ->setAttribute(TraceAttributes::ERROR_TYPE, $responseMeta['error.type']); } - $span->end(); + $span->end($requestEnd); + + $histogram->record( + (float) (($requestEnd - $requestStart) / ClockInterface::NANOS_PER_SECOND), + array_merge($requestMeta, $responseMeta) + ); throw $t; }