Skip to content

Commit 6502441

Browse files
authored
Laravel boot hooking (using event system for observability) (#128)
* initial boot, __construct hooks * adding ClientRequestWatcher * phan fixes * use TraceAttributes * extract ClientRequestWatcher to separate file * refactor remaining TraceAttributes * on the way for proper integration tests * move laravel stuff into tests * exclude tests * adding Watcher abstract class * adding ExceptionWatcher * add some env variables * adding CacheWatcher * add CacheWatcher test * adding LogWatcher * adding QueryWatcher * add QueryWatcher test * fix ExceptionWatcher * update tests * using TraceAttributes * fixing cardinality
1 parent 6d152ee commit 6502441

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+3050
-16
lines changed

composer.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"open-telemetry/api": "^1"
1515
},
1616
"require-dev": {
17+
"laravel/sanctum": "*",
18+
"nunomaduro/collision": "*",
1719
"friendsofphp/php-cs-fixer": "^3",
1820
"phan/phan": "^5.0",
1921
"php-http/mock-client": "*",
@@ -22,7 +24,11 @@
2224
"psalm/plugin-phpunit": "^0.16",
2325
"open-telemetry/sdk": "^1.0",
2426
"phpunit/phpunit": "^9.5",
25-
"vimeo/psalm": "^4.0"
27+
"vimeo/psalm": "^4.0",
28+
"spatie/laravel-ignition": "*",
29+
"laravel/sail": "*",
30+
"laravel/tinker": "*",
31+
"guzzlehttp/guzzle": "*"
2632
},
2733
"autoload": {
2834
"psr-4": {
@@ -34,6 +40,7 @@
3440
},
3541
"autoload-dev": {
3642
"psr-4": {
43+
"App\\": "tests/app/",
3744
"OpenTelemetry\\Tests\\Instrumentation\\Laravel\\": "tests/"
3845
}
3946
},

phpstan.neon.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ includes:
22
- vendor/phpstan/phpstan-phpunit/extension.neon
33

44
parameters:
5+
excludePaths:
6+
- 'tests/*'
57
tmpDir: var/cache/phpstan
68
level: 5
79
paths:

phpunit.xml.dist

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@
3333
<ini name="display_errors" value="On" />
3434
<ini name="display_startup_errors" value="On" />
3535
<ini name="error_reporting" value="E_ALL" />
36+
<env name="APP_ENV" value="testing"/>
37+
<env name="BCRYPT_ROUNDS" value="4"/>
38+
<env name="CACHE_DRIVER" value="file"/>
39+
<env name="DB_CONNECTION" value="sqlite_testing"/>
40+
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
41+
<env name="MAIL_MAILER" value="array"/>
42+
<env name="QUEUE_CONNECTION" value="sync"/>
43+
<env name="SESSION_DRIVER" value="array"/>
44+
<env name="TELESCOPE_ENABLED" value="false"/>
3645
</php>
3746

3847
<testsuites>

psalm.xml.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
<projectFiles>
99
<ignoreFiles>
1010
<directory name="vendor"/>
11+
<directory name="tests/app"/>
12+
<directory name="tests/bootstrap"/>
13+
<directory name="tests/routes"/>
14+
<directory name="tests/storage"/>
1115
</ignoreFiles>
1216
<directory name="src"/>
1317
<directory name="tests"/>

src/CacheWatcher.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Laravel;
6+
7+
use Illuminate\Cache\Events\CacheHit;
8+
use Illuminate\Cache\Events\CacheMissed;
9+
use Illuminate\Cache\Events\KeyForgotten;
10+
use Illuminate\Cache\Events\KeyWritten;
11+
use Illuminate\Contracts\Foundation\Application;
12+
use OpenTelemetry\API\Trace\Span;
13+
use OpenTelemetry\Context\Context;
14+
15+
class CacheWatcher extends Watcher
16+
{
17+
/** @psalm-suppress UndefinedInterfaceMethod */
18+
public function register(Application $app): void
19+
{
20+
$app['events']->listen(CacheHit::class, [$this, 'recordCacheHit']);
21+
$app['events']->listen(CacheMissed::class, [$this, 'recordCacheMiss']);
22+
23+
$app['events']->listen(KeyWritten::class, [$this, 'recordCacheSet']);
24+
$app['events']->listen(KeyForgotten::class, [$this, 'recordCacheForget']);
25+
}
26+
27+
public function recordCacheHit(CacheHit $event): void
28+
{
29+
$this->addEvent('cache hit', [
30+
'key' => $event->key,
31+
'tags' => json_encode($event->tags),
32+
]);
33+
}
34+
35+
public function recordCacheMiss(CacheMissed $event): void
36+
{
37+
$this->addEvent('cache miss', [
38+
'key' => $event->key,
39+
'tags' => json_encode($event->tags),
40+
]);
41+
}
42+
/** @psalm-suppress UndefinedPropertyFetch */
43+
public function recordCacheSet(KeyWritten $event): void
44+
{
45+
$ttl = property_exists($event, 'minutes')
46+
? $event->minutes * 60
47+
: $event->seconds;
48+
49+
$this->addEvent('cache set', [
50+
'key' => $event->key,
51+
'tags' => json_encode($event->tags),
52+
'expires_at' => $ttl > 0
53+
? now()->addSeconds($ttl)->getTimestamp()
54+
: 'never',
55+
'expires_in_seconds' => $ttl > 0
56+
? $ttl
57+
: 'never',
58+
'expires_in_human' => $ttl > 0
59+
? now()->addSeconds($ttl)->diffForHumans()
60+
: 'never',
61+
]);
62+
}
63+
64+
public function recordCacheForget(KeyForgotten $event): void
65+
{
66+
$this->addEvent('cache forget', [
67+
'key' => $event->key,
68+
'tags' => json_encode($event->tags),
69+
]);
70+
}
71+
72+
private function addEvent(string $name, iterable $attributes = []): void
73+
{
74+
$scope = Context::storage()->scope();
75+
if (!$scope) {
76+
return;
77+
}
78+
$span = Span::fromContext($scope->context());
79+
$span->addEvent($name, $attributes);
80+
}
81+
}

src/ClientRequestWatcher.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Laravel;
6+
7+
use Illuminate\Contracts\Foundation\Application;
8+
use Illuminate\Http\Client\Events\ConnectionFailed;
9+
use Illuminate\Http\Client\Events\RequestSending;
10+
use Illuminate\Http\Client\Events\ResponseReceived;
11+
use OpenTelemetry\API\Common\Instrumentation\CachedInstrumentation;
12+
use OpenTelemetry\API\Trace\SpanInterface;
13+
use OpenTelemetry\API\Trace\SpanKind;
14+
use OpenTelemetry\API\Trace\StatusCode;
15+
use OpenTelemetry\SemConv\TraceAttributes;
16+
use Symfony\Component\HttpFoundation\Response as HttpResponse;
17+
18+
class ClientRequestWatcher extends Watcher
19+
{
20+
private CachedInstrumentation $instrumentation;
21+
/**
22+
* @var array<string, SpanInterface>
23+
*/
24+
protected array $spans = [];
25+
26+
public function __construct(CachedInstrumentation $instr)
27+
{
28+
$this->instrumentation = $instr;
29+
}
30+
31+
/** @psalm-suppress UndefinedInterfaceMethod */
32+
public function register(Application $app): void
33+
{
34+
$app['events']->listen(RequestSending::class, [$this, 'recordRequest']);
35+
$app['events']->listen(ConnectionFailed::class, [$this, 'recordConnectionFailed']);
36+
$app['events']->listen(ResponseReceived::class, [$this, 'recordResponse']);
37+
}
38+
39+
public function recordRequest(RequestSending $request): void
40+
{
41+
$parsedUrl = collect(parse_url($request->request->url()));
42+
$processedUrl = $parsedUrl->get('scheme') . '://' . $parsedUrl->get('host') . $parsedUrl->get('path', '');
43+
44+
if ($parsedUrl->has('query')) {
45+
$processedUrl .= '?' . $parsedUrl->get('query');
46+
}
47+
$span = $this->instrumentation->tracer()->spanBuilder('HTTP ' . $request->request->method())
48+
->setSpanKind(SpanKind::KIND_CLIENT)
49+
->setAttributes([
50+
TraceAttributes::HTTP_METHOD => $request->request->method(),
51+
TraceAttributes::HTTP_URL => $processedUrl,
52+
TraceAttributes::HTTP_TARGET => $parsedUrl['path'] ?? '',
53+
TraceAttributes::HTTP_HOST => $parsedUrl['host'] ?? '',
54+
TraceAttributes::HTTP_SCHEME => $parsedUrl['scheme'] ?? '',
55+
TraceAttributes::NET_PEER_NAME => $parsedUrl['host'] ?? '',
56+
TraceAttributes::NET_PEER_PORT => $parsedUrl['port'] ?? '',
57+
])
58+
->startSpan();
59+
$this->spans[$this->createRequestComparisonHash($request->request)] = $span;
60+
}
61+
62+
public function recordConnectionFailed(ConnectionFailed $request): void
63+
{
64+
$requestHash = $this->createRequestComparisonHash($request->request);
65+
66+
$span = $this->spans[$requestHash] ?? null;
67+
if (null === $span) {
68+
return;
69+
}
70+
71+
$span->setStatus(StatusCode::STATUS_ERROR, 'Connection failed');
72+
$span->end();
73+
74+
unset($this->spans[$requestHash]);
75+
}
76+
77+
public function recordResponse(ResponseReceived $request): void
78+
{
79+
$requestHash = $this->createRequestComparisonHash($request->request);
80+
81+
$span = $this->spans[$requestHash] ?? null;
82+
if (null === $span) {
83+
return;
84+
}
85+
86+
$span->setAttributes([
87+
TraceAttributes::HTTP_STATUS_CODE => $request->response->status(),
88+
TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH => $request->response->header('Content-Length'),
89+
]);
90+
91+
$this->maybeRecordError($span, $request->response);
92+
$span->end();
93+
94+
unset($this->spans[$requestHash]);
95+
}
96+
private function createRequestComparisonHash(\Illuminate\Http\Client\Request $request): string
97+
{
98+
return sha1($request->method() . '|' . $request->url() . '|' . $request->body());
99+
}
100+
101+
private function maybeRecordError(SpanInterface $span, \Illuminate\Http\Client\Response $response): void
102+
{
103+
if ($response->successful()) {
104+
return;
105+
}
106+
107+
$span->setStatus(
108+
StatusCode::STATUS_ERROR,
109+
HttpResponse::$statusTexts[$response->status()] ?? $response->status()
110+
);
111+
}
112+
}

src/ExceptionWatcher.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Laravel;
6+
7+
use Illuminate\Contracts\Foundation\Application;
8+
use Illuminate\Log\Events\MessageLogged;
9+
use OpenTelemetry\API\Trace\Span;
10+
use OpenTelemetry\API\Trace\StatusCode;
11+
use OpenTelemetry\Context\Context;
12+
use OpenTelemetry\SemConv\TraceAttributes;
13+
use Throwable;
14+
15+
class ExceptionWatcher extends Watcher
16+
{
17+
/** @psalm-suppress UndefinedInterfaceMethod */
18+
public function register(Application $app): void
19+
{
20+
$app['events']->listen(MessageLogged::class, [$this, 'recordException']);
21+
}
22+
/**
23+
* Record an exception.
24+
*/
25+
public function recordException(MessageLogged $log): void
26+
{
27+
if (! isset($log->context['exception']) ||
28+
! $log->context['exception'] instanceof Throwable) {
29+
return;
30+
}
31+
32+
$exception = $log->context['exception'];
33+
34+
$attributes = [
35+
TraceAttributes::CODE_NAMESPACE => get_class($exception),
36+
TraceAttributes::CODE_FILEPATH => $exception->getFile(),
37+
TraceAttributes::CODE_LINENO => $exception->getLine(),
38+
];
39+
$scope = Context::storage()->scope();
40+
if (!$scope) {
41+
return;
42+
}
43+
$span = Span::fromContext($scope->context());
44+
$span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]);
45+
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
46+
}
47+
}

src/LaravelInstrumentation.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace OpenTelemetry\Contrib\Instrumentation\Laravel;
66

7+
use Illuminate\Foundation\Application;
78
use Illuminate\Foundation\Http\Kernel;
89
use Illuminate\Http\Request;
910
use Illuminate\Http\Response;
11+
use Illuminate\Support\ServiceProvider;
1012
use OpenTelemetry\API\Common\Instrumentation\CachedInstrumentation;
1113
use OpenTelemetry\API\Common\Instrumentation\Globals;
1214
use OpenTelemetry\API\Trace\Span;
@@ -20,6 +22,14 @@
2022

2123
class LaravelInstrumentation
2224
{
25+
private static $watchersInstalled = false;
26+
private static $application;
27+
28+
public static function registerWatchers(Application $app, Watcher $watcher)
29+
{
30+
$watcher->register($app);
31+
}
32+
2333
public static function register(): void
2434
{
2535
$instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.laravel');
@@ -77,5 +87,29 @@ public static function register(): void
7787
$span->end();
7888
}
7989
);
90+
hook(
91+
ServiceProvider::class,
92+
'boot',
93+
pre: static function (ServiceProvider $provider, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) {
94+
if (!self::$watchersInstalled) {
95+
self::registerWatchers(self::$application, new ClientRequestWatcher($instrumentation));
96+
self::registerWatchers(self::$application, new ExceptionWatcher());
97+
self::registerWatchers(self::$application, new CacheWatcher());
98+
self::registerWatchers(self::$application, new LogWatcher());
99+
self::registerWatchers(self::$application, new QueryWatcher($instrumentation));
100+
self::$watchersInstalled = true;
101+
}
102+
},
103+
post: null
104+
);
105+
hook(
106+
ServiceProvider::class,
107+
'__construct',
108+
pre: static function (ServiceProvider $provider, array $params, string $class, string $function, ?string $filename, ?int $lineno) {
109+
self::$watchersInstalled = false;
110+
self::$application = $params[0];
111+
},
112+
post: null
113+
);
80114
}
81115
}

0 commit comments

Comments
 (0)