Skip to content

Commit 3432ae4

Browse files
authored
Symfony - add Http Client instrumentation (#130)
* Symfony - add Http Client instrumentation Extract base tests * add some description about lazy responses * use named variable * add info about ending span * code style fix
1 parent c1737e1 commit 3432ae4

File tree

7 files changed

+290
-45
lines changed

7 files changed

+290
-45
lines changed

_register.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use OpenTelemetry\Contrib\Instrumentation\Symfony\HttpClientInstrumentation;
56
use OpenTelemetry\Contrib\Instrumentation\Symfony\SymfonyInstrumentation;
67

78
if (extension_loaded('otel_instrumentation') === false) {
@@ -11,3 +12,4 @@
1112
}
1213

1314
SymfonyInstrumentation::register();
15+
HttpClientInstrumentation::register();

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"php": "^8.0",
1212
"ext-otel_instrumentation": "*",
1313
"open-telemetry/api": "^1",
14-
"symfony/http-kernel": "*"
14+
"symfony/http-kernel": "*",
15+
"symfony/http-client-contracts": "*"
1516
},
1617
"require-dev": {
1718
"friendsofphp/php-cs-fixer": "^3",
@@ -22,7 +23,8 @@
2223
"psalm/plugin-phpunit": "^0.16",
2324
"open-telemetry/sdk": "^1.0",
2425
"phpunit/phpunit": "^9.5",
25-
"vimeo/psalm": "^4.0"
26+
"vimeo/psalm": "^4.0",
27+
"symfony/http-client": "^5.4||^6.0"
2628
},
2729
"autoload": {
2830
"psr-4": {

src/HttpClientInstrumentation.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Symfony;
6+
7+
use OpenTelemetry\API\Common\Instrumentation\CachedInstrumentation;
8+
use OpenTelemetry\API\Common\Instrumentation\Globals;
9+
use OpenTelemetry\API\Trace\Span;
10+
use OpenTelemetry\API\Trace\SpanKind;
11+
use OpenTelemetry\API\Trace\StatusCode;
12+
use OpenTelemetry\Context\Context;
13+
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
14+
use function OpenTelemetry\Instrumentation\hook;
15+
use OpenTelemetry\SemConv\TraceAttributes;
16+
use Symfony\Contracts\HttpClient\HttpClientInterface;
17+
use Symfony\Contracts\HttpClient\ResponseInterface;
18+
19+
final class HttpClientInstrumentation
20+
{
21+
public static function register(): void
22+
{
23+
$instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.symfony_http');
24+
25+
hook(
26+
HttpClientInterface::class,
27+
'request',
28+
pre: static function (
29+
HttpClientInterface $client,
30+
array $params,
31+
string $class,
32+
string $function,
33+
?string $filename,
34+
?int $lineno,
35+
) use ($instrumentation): array {
36+
$requestOptions = $params[2];
37+
/** @psalm-suppress ArgumentTypeCoercion */
38+
$builder = $instrumentation
39+
->tracer()
40+
->spanBuilder(\sprintf('HTTP %s', $params[0]))
41+
->setSpanKind(SpanKind::KIND_CLIENT)
42+
->setAttribute(TraceAttributes::HTTP_URL, (string) $params[1])
43+
->setAttribute(TraceAttributes::HTTP_METHOD, $params[0])
44+
->setAttribute(TraceAttributes::CODE_FUNCTION, $function)
45+
->setAttribute(TraceAttributes::CODE_NAMESPACE, $class)
46+
->setAttribute(TraceAttributes::CODE_FILEPATH, $filename)
47+
->setAttribute(TraceAttributes::CODE_LINENO, $lineno);
48+
49+
$propagator = Globals::propagator();
50+
$parent = Context::getCurrent();
51+
52+
$span = $builder
53+
->setParent($parent)
54+
->startSpan();
55+
56+
if (!isset($requestOptions['headers'])) {
57+
$requestOptions['headers'] = [];
58+
}
59+
60+
$previousOnProgress = $requestOptions['on_progress'] ?? null;
61+
62+
//As Response are lazy we end span when status code was received
63+
$requestOptions['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (
64+
$previousOnProgress,
65+
$span
66+
): void {
67+
if (null !== $previousOnProgress) {
68+
$previousOnProgress($dlNow, $dlSize, $info);
69+
}
70+
71+
$statusCode = $info['http_code'];
72+
73+
if (0 !== $statusCode && null !== $statusCode && $span->isRecording()) {
74+
$span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $statusCode);
75+
76+
if ($statusCode >= 400 && $statusCode < 600) {
77+
$span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $statusCode);
78+
$span->setStatus(StatusCode::STATUS_ERROR);
79+
}
80+
81+
$span->end();
82+
}
83+
};
84+
85+
$context = $span->storeInContext($parent);
86+
$propagator->inject($requestOptions['headers'], ArrayAccessGetterSetter::getInstance(), $context);
87+
88+
Context::storage()->attach($context);
89+
$params[2] = $requestOptions;
90+
91+
return $params;
92+
},
93+
post: static function (
94+
HttpClientInterface $client,
95+
array $params,
96+
?ResponseInterface $response,
97+
?\Throwable $exception
98+
): void {
99+
$scope = Context::storage()->scope();
100+
if (null === $scope) {
101+
return;
102+
}
103+
$scope->detach();
104+
$span = Span::fromContext($scope->context());
105+
106+
if (null !== $exception) {
107+
$span->recordException($exception, [
108+
TraceAttributes::EXCEPTION_ESCAPED => true,
109+
]);
110+
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
111+
$span->end();
112+
}
113+
114+
//As Response are lazy we end span after response is received,
115+
//it's added in on_progress callback, see line 63
116+
},
117+
);
118+
}
119+
}

src/SymfonyInstrumentation.php

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,35 @@
1616
use Symfony\Component\HttpFoundation\Request;
1717
use Symfony\Component\HttpFoundation\Response;
1818
use Symfony\Component\HttpKernel\HttpKernel;
19-
use Throwable;
2019

2120
final class SymfonyInstrumentation
2221
{
2322
public static function register(): void
2423
{
2524
$instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.symfony');
25+
2626
hook(
2727
HttpKernel::class,
2828
'handle',
29-
pre: static function (HttpKernel $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) {
29+
pre: static function (
30+
HttpKernel $kernel,
31+
array $params,
32+
string $class,
33+
string $function,
34+
?string $filename,
35+
?int $lineno,
36+
) use ($instrumentation): array {
3037
$request = ($params[0] instanceof Request) ? $params[0] : null;
3138
/** @psalm-suppress ArgumentTypeCoercion */
32-
$builder = $instrumentation->tracer()
33-
->spanBuilder(sprintf('HTTP %s', $request?->getMethod() ?? 'unknown'))
39+
$builder = $instrumentation
40+
->tracer()
41+
->spanBuilder(\sprintf('HTTP %s', $request?->getMethod() ?? 'unknown'))
3442
->setSpanKind(SpanKind::KIND_SERVER)
35-
->setAttribute('code.function', $function)
36-
->setAttribute('code.namespace', $class)
37-
->setAttribute('code.filepath', $filename)
38-
->setAttribute('code.lineno', $lineno);
43+
->setAttribute(TraceAttributes::CODE_FUNCTION, $function)
44+
->setAttribute(TraceAttributes::CODE_NAMESPACE, $class)
45+
->setAttribute(TraceAttributes::CODE_FILEPATH, $filename)
46+
->setAttribute(TraceAttributes::CODE_LINENO, $lineno);
47+
3948
$parent = Context::getCurrent();
4049
if ($request) {
4150
$parent = Globals::propagator()->extract($request, HeadersPropagator::instance());
@@ -54,9 +63,14 @@ public static function register(): void
5463

5564
return [$request];
5665
},
57-
post: static function (HttpKernel $kernel, array $params, ?Response $response, ?Throwable $exception) {
66+
post: static function (
67+
HttpKernel $kernel,
68+
array $params,
69+
?Response $response,
70+
?\Throwable $exception
71+
): void {
5872
$scope = Context::storage()->scope();
59-
if (!$scope) {
73+
if (null === $scope) {
6074
return;
6175
}
6276
$scope->detach();
@@ -71,10 +85,13 @@ public static function register(): void
7185
}
7286
}
7387

74-
if ($exception) {
75-
$span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]);
88+
if (null !== $exception) {
89+
$span->recordException($exception, [
90+
TraceAttributes::EXCEPTION_ESCAPED => true,
91+
]);
7692
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
7793
}
94+
7895
if ($response) {
7996
if ($response->getStatusCode() >= Response::HTTP_BAD_REQUEST) {
8097
$span->setStatus(StatusCode::STATUS_ERROR);

tests/Integration/AbstractTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Instrumentation\Symfony\tests\Integration;
6+
7+
use ArrayObject;
8+
use OpenTelemetry\API\Common\Instrumentation\Configurator;
9+
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
10+
use OpenTelemetry\Context\ScopeInterface;
11+
use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter;
12+
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
13+
use OpenTelemetry\SDK\Trace\TracerProvider;
14+
use PHPUnit\Framework\TestCase;
15+
16+
abstract class AbstractTest extends TestCase
17+
{
18+
private ScopeInterface $scope;
19+
protected ArrayObject $storage;
20+
21+
public function setUp(): void
22+
{
23+
$this->storage = new ArrayObject();
24+
$tracerProvider = new TracerProvider(
25+
new SimpleSpanProcessor(
26+
new InMemoryExporter($this->storage)
27+
)
28+
);
29+
30+
$this->scope = Configurator::create()
31+
->withTracerProvider($tracerProvider)
32+
->withPropagator(TraceContextPropagator::getInstance())
33+
->activate();
34+
}
35+
36+
public function tearDown(): void
37+
{
38+
$this->scope->detach();
39+
}
40+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Instrumentation\Symfony\tests\Integration;
6+
7+
use OpenTelemetry\API\Trace\StatusCode;
8+
use OpenTelemetry\SDK\Trace\EventInterface;
9+
use OpenTelemetry\SDK\Trace\ImmutableSpan;
10+
use OpenTelemetry\SemConv\TraceAttributes;
11+
use Symfony\Component\HttpClient\CurlHttpClient;
12+
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
13+
use Symfony\Component\HttpFoundation\Response;
14+
use Symfony\Contracts\HttpClient\HttpClientInterface;
15+
use Symfony\Contracts\HttpClient\Test\TestHttpServer;
16+
17+
final class HttpClientInstrumentationTest extends AbstractTest
18+
{
19+
public static function setUpBeforeClass(): void
20+
{
21+
TestHttpServer::start();
22+
}
23+
24+
protected function getHttpClient(string $testCase): HttpClientInterface
25+
{
26+
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
27+
}
28+
29+
/**
30+
* @dataProvider requestProvider
31+
*/
32+
public function test_send_request(string $method, string $uri, int $statusCode, string $spanStatus): void
33+
{
34+
$client = $this->getHttpClient(__FUNCTION__);
35+
$this->assertCount(0, $this->storage);
36+
37+
$response = $client->request($method, $uri, ['bindto' => '127.0.0.1:9876']);
38+
$response->getStatusCode();
39+
$this->assertCount(1, $this->storage);
40+
41+
/** @var ImmutableSpan $span */
42+
$span = $this->storage[0];
43+
44+
$this->assertStringContainsString($method, $span->getName());
45+
if ($method === 'GET') {
46+
$requestHeaders = $response->toArray(false);
47+
$this->assertArrayHasKey('HTTP_TRACEPARENT', $requestHeaders);
48+
$this->assertNotNull($requestHeaders['HTTP_TRACEPARENT']);
49+
}
50+
51+
$this->assertTrue($span->getAttributes()->has(TraceAttributes::HTTP_URL));
52+
$this->assertSame($uri, $span->getAttributes()->get(TraceAttributes::HTTP_URL));
53+
$this->assertTrue($span->getAttributes()->has(TraceAttributes::HTTP_METHOD));
54+
$this->assertSame($method, $span->getAttributes()->get(TraceAttributes::HTTP_METHOD));
55+
$this->assertTrue($span->getAttributes()->has(TraceAttributes::HTTP_STATUS_CODE));
56+
$this->assertSame($spanStatus, $span->getStatus()->getCode());
57+
$this->assertSame($statusCode, $span->getAttributes()->get(TraceAttributes::HTTP_STATUS_CODE));
58+
}
59+
60+
public function test_throw_exception(): void
61+
{
62+
$client = $this->getHttpClient(__FUNCTION__);
63+
$this->assertCount(0, $this->storage);
64+
65+
try {
66+
$response = $client->request('GET', 'http://localhost:8057', [
67+
'bindto' => '127.0.0.1:9876',
68+
'auth_ntlm' => [],
69+
]);
70+
} catch (InvalidArgumentException) {
71+
}
72+
73+
$this->assertCount(1, $this->storage);
74+
75+
/** @var ImmutableSpan $span */
76+
$span = $this->storage[0];
77+
/** @var EventInterface $event */
78+
$event = $span->getEvents()[0];
79+
80+
$this->assertTrue($span->getAttributes()->has(TraceAttributes::HTTP_URL));
81+
$this->assertTrue($span->getAttributes()->has(TraceAttributes::HTTP_METHOD));
82+
$this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode());
83+
$this->assertSame(InvalidArgumentException::class, $event->getAttributes()->get('exception.type'));
84+
}
85+
86+
public function requestProvider(): array
87+
{
88+
return [
89+
['GET', 'http://localhost:8057', Response::HTTP_OK, StatusCode::STATUS_UNSET],
90+
['GET','http://localhost:8057/404', Response::HTTP_NOT_FOUND, StatusCode::STATUS_ERROR],
91+
['POST','http://localhost:8057/json', Response::HTTP_OK, StatusCode::STATUS_UNSET],
92+
['DELETE', 'http://localhost:8057/1', Response::HTTP_OK, StatusCode::STATUS_UNSET],
93+
];
94+
}
95+
}

0 commit comments

Comments
 (0)