Skip to content

Commit 138d1f1

Browse files
smaddockNevay
andauthored
[ReactPHP] Add required HTTP Client Metric (#380)
* add required metric http.client.request.duration * bug fixes * doc update * doc formatting * keeping phan happy * allow for metric-only instrumentation, fix for duplicate instrument registration * Update src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php Co-authored-by: Tobias Bachert <[email protected]> * remove unused import --------- Co-authored-by: Tobias Bachert <[email protected]>
1 parent 9c3ecc9 commit 138d1f1

File tree

3 files changed

+165
-27
lines changed

3 files changed

+165
-27
lines changed

src/Instrumentation/ReactPHP/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@ This is a read-only subtree split of https://github.com/open-telemetry/opentelem
99

1010
# OpenTelemetry ReactPHP HTTP Browser auto-instrumentation
1111

12+
This is an OpenTelemetry auto-instrumentation package for the [ReactPHP HTTP library](https://reactphp.org/http/). Currently only the Browser (client) component is instrumented.
13+
1214
Please read https://opentelemetry.io/docs/instrumentation/php/automatic/ for instructions on how to
1315
install and configure the extension and SDK.
1416

1517
## Overview
1618

17-
Auto-instrumentation hooks are registered via composer, which will:
19+
This library is provides the following:
1820

19-
* create spans automatically for each ReactPHP HTTP Browser request that is sent
20-
* add a `traceparent` header to the request to facilitate distributed tracing
21+
- OpenTelemetry Semantic Conventions v1.32.0:
22+
- [HTTP Client Spans](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span) - required and HTTP header (opt-in) attributes only
23+
- [HTTP Client Metrics](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client) - required attributes only
24+
- W3C Trace Context:
25+
- [Trace Context HTTP Request Headers](https://www.w3.org/TR/trace-context/#trace-context-http-headers-format)
2126

22-
Note that span lifetime behavior differs based on how ReactPHP is utilized; see [examples/README.md](examples/README.md) for more information.
27+
> [!NOTE]
28+
> HTTP Client Span lifetime behavior differs based on how ReactPHP is utilized; see [examples/README.md](examples/README.md) for more information.
2329
2430
## Configuration
2531

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use OpenTelemetry\SDK\Metrics\Data\Temporality;
6+
use OpenTelemetry\SDK\Metrics\MeterProvider;
7+
use OpenTelemetry\SDK\Metrics\MetricExporter\ConsoleMetricExporter;
8+
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;
9+
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
10+
use OpenTelemetry\SDK\Sdk;
11+
use Psr\Http\Message\ResponseInterface;
12+
use React\Http\Browser;
13+
use React\Http\Message\Request;
14+
use React\Http\Message\ResponseException;
15+
16+
require_once dirname(__DIR__) . '/vendor/autoload.php';
17+
18+
$exporter = new ConsoleMetricExporter(Temporality::CUMULATIVE);
19+
20+
$meterProvider = MeterProvider::builder()
21+
->addReader(new ExportingReader($exporter))
22+
->setResource(ResourceInfoFactory::emptyResource())
23+
->build();
24+
25+
Sdk::builder()
26+
->setMeterProvider($meterProvider)
27+
->setAutoShutdown(true)
28+
->buildAndRegisterGlobal();
29+
30+
try {
31+
$browser = new Browser();
32+
33+
$requests = [
34+
new Request('GET', 'https://postman-echo.com/get'),
35+
new Request('GET', 'https://postman-echo.com/stream/33554432'),
36+
new Request('POST', 'https://postman-echo.com/post', ['Content-Type' => 'application/json'], '{}'),
37+
new Request('CUSTOM', 'http://postman-echo.com:443/get'),
38+
new Request('GET', 'unknown://postman-echo.com/get'),
39+
new Request('GET', 'https://postman-echo.com/delay/2'),
40+
];
41+
42+
foreach ($requests as $request) {
43+
$browser
44+
->request($request->getMethod(), $request->getUri())
45+
->then(function (ResponseInterface $response) use ($request) {
46+
echo sprintf(
47+
'[HTTP/%s %d %s] %s%s',
48+
$response->getProtocolVersion(),
49+
$response->getStatusCode(),
50+
$response->getReasonPhrase(),
51+
$request->getUri(),
52+
PHP_EOL
53+
);
54+
}, function (Throwable $t) use ($request) {
55+
if (is_a($t, ResponseException::class)) {
56+
$response = $t->getResponse();
57+
echo sprintf(
58+
'[HTTP/%s %d %s] %s%s',
59+
$response->getProtocolVersion(),
60+
$response->getStatusCode(),
61+
$response->getReasonPhrase(),
62+
$request->getUri(),
63+
PHP_EOL
64+
);
65+
} else {
66+
echo sprintf(
67+
'[%d: %s] %s%s',
68+
$t->getCode(),
69+
$t->getMessage(),
70+
$request->getUri(),
71+
PHP_EOL
72+
);
73+
}
74+
});
75+
}
76+
} finally {
77+
$meterProvider->forceFlush();
78+
}

src/Instrumentation/ReactPHP/src/ReactPHPInstrumentation.php

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Composer\InstalledVersions;
88
use GuzzleHttp\Psr7\Query;
9+
use OpenTelemetry\API\Common\Time\Clock;
10+
use OpenTelemetry\API\Common\Time\ClockInterface;
911
use OpenTelemetry\API\Globals;
1012
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
1113
use OpenTelemetry\API\Trace\Span;
@@ -81,6 +83,9 @@ class ReactPHPInstrumentation
8183

8284
public static function register(): void
8385
{
86+
/** @var \OpenTelemetry\API\Metrics\HistogramInterface|null */
87+
static $histogram;
88+
8489
$instrumentation = new CachedInstrumentation(
8590
self::INSTRUMENTATION_LIBRARY_NAME,
8691
InstalledVersions::getPrettyVersion(self::COMPOSER_NAME),
@@ -102,24 +107,28 @@ public static function register(): void
102107
$request = $request->withoutHeader($field);
103108
}
104109

105-
/** @var non-empty-string|null */
106-
$method = self::canonizeMethod($request->getMethod());
110+
/** @var array{'http.request.method':non-empty-string|null,'server.address':non-empty-string,'server.port':int} $requestMeta */
111+
$requestMeta = [
112+
'http.request.method' => self::canonizeMethod($request->getMethod()),
113+
'server.address' => $request->getUri()->getHost(),
114+
'server.port' => $request->getUri()->getPort() ?? ($request->getUri()->getScheme() === 'https' ? 443 : 80),
115+
];
107116

108117
$spanBuilder = $instrumentation
109118
->tracer()
110119
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
111-
->spanBuilder($method ?? self::HTTP_REQUEST_METHOD_HTTP)
120+
->spanBuilder($requestMeta['http.request.method'] ?? self::HTTP_REQUEST_METHOD_HTTP)
112121
->setParent($parentContext)
113122
->setSpanKind(SpanKind::KIND_CLIENT)
114-
->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $method ?? TraceAttributeValues::HTTP_REQUEST_METHOD_OTHER)
115-
->setAttribute(TraceAttributes::SERVER_ADDRESS, $request->getUri()->getHost())
116-
->setAttribute(TraceAttributes::SERVER_PORT, $request->getUri()->getPort() ?? ($request->getUri()->getScheme() === 'https' ? 443 : 80))
123+
->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $requestMeta['http.request.method'] ?? TraceAttributeValues::HTTP_REQUEST_METHOD_OTHER)
124+
->setAttribute(TraceAttributes::SERVER_ADDRESS, $requestMeta['server.address'])
125+
->setAttribute(TraceAttributes::SERVER_PORT, $requestMeta['server.port'])
117126
->setAttribute(TraceAttributes::URL_FULL, self::sanitizeUrl($request->getUri()))
118127
// https://opentelemetry.io/docs/specs/semconv/code/
119128
->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, sprintf('%s::%s', $class, $function));
120129

121130
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
122-
if ($method === null) {
131+
if ($requestMeta['http.request.method'] === null) {
123132
$spanBuilder->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD_ORIGINAL, $request->getMethod());
124133
}
125134

@@ -133,7 +142,8 @@ public static function register(): void
133142
$spanBuilder->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno);
134143
}
135144

136-
$span = $spanBuilder->startSpan();
145+
$requestStart = Clock::getDefault()->now();
146+
$span = $spanBuilder->setStartTimestamp($requestStart)->startSpan();
137147
$context = $span->storeInContext($parentContext);
138148
$propagator->inject($request, HeadersPropagator::instance(), $context);
139149

@@ -146,11 +156,13 @@ public static function register(): void
146156
}
147157
}
148158

149-
Context::storage()->attach($context);
159+
$scope = Context::storage()->attach($context);
160+
$scope->offsetSet('requestMeta', $requestMeta);
161+
$scope->offsetSet('requestStart', $requestStart);
150162

151163
return [$request];
152164
},
153-
post: static function (Transaction $transaction, array $params, PromiseInterface $promise): PromiseInterface {
165+
post: static function (Transaction $transaction, array $params, PromiseInterface $promise) use (&$histogram, $instrumentation): PromiseInterface {
154166
$scope = Context::storage()->scope();
155167
$scope?->detach();
156168

@@ -160,21 +172,42 @@ public static function register(): void
160172

161173
$span = Span::fromContext($scope->context());
162174

163-
if (!$span->isRecording()) {
175+
//https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client
176+
$histogram ??= $instrumentation->meter()->createHistogram(
177+
'http.client.request.duration',
178+
's',
179+
'Duration of HTTP client requests.',
180+
['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]]
181+
);
182+
183+
if (!$span->isRecording() && !$histogram->isEnabled()) {
164184
return $promise;
165185
}
166186

187+
/** @var array{'http.request.method':non-empty-string|null,'server.address':non-empty-string,'server.port':int} $requestMeta */
188+
$requestMeta = $scope->offsetGet('requestMeta');
189+
$requestMeta['http.request.method'] ??= '_OTHER';
190+
/** @var int $requestStart */
191+
$requestStart = $scope->offsetGet('requestStart');
192+
167193
return $promise->then(
168-
onFulfilled: function (ResponseInterface $response) use ($span) {
194+
onFulfilled: function (ResponseInterface $response) use ($histogram, $requestMeta, $requestStart, $span) {
195+
$requestEnd = Clock::getDefault()->now();
196+
/** @var array{'http.response.status_code':int,'network.protocol.version':non-empty-string,'error.type'?:non-empty-string} $responseMeta */
197+
$responseMeta = [
198+
'http.response.status_code' => $response->getStatusCode(),
199+
'network.protocol.version' => $response->getProtocolVersion(),
200+
];
169201
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
170202
$span
171-
->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode())
172-
->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $response->getProtocolVersion());
203+
->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $responseMeta['http.response.status_code'])
204+
->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $responseMeta['network.protocol.version']);
173205

174-
if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 600) {
206+
if ($responseMeta['http.response.status_code'] >= 400 && $responseMeta['http.response.status_code'] < 600) {
175207
$span
176208
->setStatus(StatusCode::STATUS_ERROR)
177-
->setAttribute(TraceAttributes::ERROR_TYPE, (string) $response->getStatusCode());
209+
->setAttribute(TraceAttributes::ERROR_TYPE, (string) $responseMeta['http.response.status_code']);
210+
$responseMeta['error.type'] = (string) $responseMeta['http.response.status_code'];
178211
}
179212

180213
foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) {
@@ -186,19 +219,31 @@ public static function register(): void
186219
}
187220
}
188221

189-
$span->end();
222+
$span->end($requestEnd);
223+
224+
$histogram->record(
225+
(float) (($requestEnd - $requestStart) / ClockInterface::NANOS_PER_SECOND),
226+
array_merge($requestMeta, $responseMeta)
227+
);
190228

191229
return $response;
192230
},
193-
onRejected: function (Throwable $t) use ($span) {
231+
onRejected: function (Throwable $t) use ($histogram, $requestMeta, $requestStart, $span) {
232+
$requestEnd = Clock::getDefault()->now();
194233
$span->recordException($t);
195234
if (is_a($t, ResponseException::class)) {
235+
/** @var array{'http.response.status_code':int,'network.protocol.version':non-empty-string,'error.type':non-empty-string} $responseMeta */
236+
$responseMeta = [
237+
'error.type' => (string) $t->getCode(),
238+
'http.response.status_code' => $t->getCode(),
239+
'network.protocol.version' => $t->getResponse()->getProtocolVersion(),
240+
];
196241
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
197242
$span
198243
->setStatus(StatusCode::STATUS_ERROR)
199-
->setAttribute(TraceAttributes::ERROR_TYPE, (string) $t->getCode())
200-
->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $t->getCode())
201-
->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $t->getResponse()->getProtocolVersion());
244+
->setAttribute(TraceAttributes::ERROR_TYPE, $responseMeta['error.type'])
245+
->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $responseMeta['http.response.status_code'])
246+
->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $responseMeta['network.protocol.version']);
202247

203248
foreach (explode(',', $_ENV[self::ENV_HTTP_RESPONSE_HEADERS] ?? '') as $header) {
204249
if ($t->getResponse()->hasHeader($header)) {
@@ -209,12 +254,21 @@ public static function register(): void
209254
}
210255
}
211256
} else {
257+
/** @var array{'error.type':non-empty-string} $responseMeta */
258+
$responseMeta = [
259+
'error.type' => $t::class,
260+
];
212261
$span
213262
->setStatus(StatusCode::STATUS_ERROR, $t->getMessage())
214-
->setAttribute(TraceAttributes::ERROR_TYPE, $t::class);
263+
->setAttribute(TraceAttributes::ERROR_TYPE, $responseMeta['error.type']);
215264
}
216265

217-
$span->end();
266+
$span->end($requestEnd);
267+
268+
$histogram->record(
269+
(float) (($requestEnd - $requestStart) / ClockInterface::NANOS_PER_SECOND),
270+
array_merge($requestMeta, $responseMeta)
271+
);
218272

219273
throw $t;
220274
}

0 commit comments

Comments
 (0)