Skip to content

Commit be6cafb

Browse files
committed
Feat: Add Redis watcher
1 parent 9b97522 commit be6cafb

File tree

4 files changed

+236
-0
lines changed

4 files changed

+236
-0
lines changed

src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\ExceptionWatcher;
1414
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\LogWatcher;
1515
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\QueryWatcher;
16+
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand\RedisCommandWatcher;
1617
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher;
1718
use function OpenTelemetry\Instrumentation\hook;
1819
use Throwable;
@@ -32,6 +33,7 @@ public function instrument(): void
3233
$this->registerWatchers($application, new ExceptionWatcher());
3334
$this->registerWatchers($application, new LogWatcher($this->instrumentation));
3435
$this->registerWatchers($application, new QueryWatcher($this->instrumentation));
36+
$this->registerWatchers($application, new RedisCommandWatcher($this->instrumentation));
3537
},
3638
);
3739
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand;
6+
7+
use Illuminate\Contracts\Foundation\Application;
8+
use Illuminate\Redis\Connections\Connection;
9+
use Illuminate\Redis\Connections\PhpRedisConnection;
10+
use Illuminate\Redis\Connections\PredisConnection;
11+
use Illuminate\Redis\Events\CommandExecuted;
12+
use Illuminate\Support\Str;
13+
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
14+
use OpenTelemetry\API\Trace\SpanKind;
15+
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher;
16+
use OpenTelemetry\SemConv\TraceAttributes;
17+
use OpenTelemetry\SemConv\TraceAttributeValues;
18+
use RangeException;
19+
use RuntimeException;
20+
21+
/**
22+
* Watch the Redis Command event
23+
*
24+
* Call facade `Redis::enableEvents()` before using this watcher
25+
*/
26+
class RedisCommandWatcher extends Watcher
27+
{
28+
public function __construct(
29+
private CachedInstrumentation $instrumentation,
30+
) {
31+
}
32+
33+
/** @psalm-suppress UndefinedInterfaceMethod */
34+
public function register(Application $app): void
35+
{
36+
/** @phan-suppress-next-line PhanTypeArraySuspicious */
37+
$app['events']->listen(CommandExecuted::class, [$this, 'recordRedisCommand']);
38+
}
39+
40+
/**
41+
* Record a query.
42+
*/
43+
/** @psalm-suppress UndefinedThisPropertyFetch */
44+
public function recordRedisCommand(CommandExecuted $event): void
45+
{
46+
$nowInNs = (int) (microtime(true) * 1E9);
47+
48+
$operationName = Str::upper($event->command);
49+
50+
/** @psalm-suppress ArgumentTypeCoercion */
51+
$span = $this->instrumentation->tracer()
52+
->spanBuilder($operationName)
53+
->setSpanKind(SpanKind::KIND_CLIENT)
54+
->setStartTimestamp($this->calculateQueryStartTime($nowInNs, $event->time))
55+
->startSpan();
56+
57+
// See https://opentelemetry.io/docs/specs/semconv/database/redis/
58+
$attributes = [
59+
TraceAttributes::DB_SYSTEM => TraceAttributeValues::DB_SYSTEM_REDIS,
60+
TraceAttributes::DB_NAME => $this->fetchDbIndex($event->connection),
61+
TraceAttributes::DB_OPERATION => $operationName,
62+
TraceAttributes::DB_QUERY_TEXT => Serializer::serializeCommand($event->command, $event->parameters),
63+
TraceAttributes::SERVER_ADDRESS => $this->fetchDbHost($event->connection),
64+
];
65+
66+
/** @psalm-suppress PossiblyInvalidArgument */
67+
$span->setAttributes($attributes);
68+
$span->end($nowInNs);
69+
}
70+
71+
private function calculateQueryStartTime(int $nowInNs, float $queryTimeMs): int
72+
{
73+
return (int) ($nowInNs - ($queryTimeMs * 1E6));
74+
}
75+
76+
private function fetchDbIndex(Connection $connection): int
77+
{
78+
if ($connection instanceof PhpRedisConnection) {
79+
$index = $connection->client()->getDbNum();
80+
81+
if ($index === false) {
82+
throw new RuntimeException('Cannot fetch database index.');
83+
}
84+
85+
return $index;
86+
} elseif ($connection instanceof PredisConnection) {
87+
/** @psalm-suppress PossiblyUndefinedMethod */
88+
$index = $connection->client()->getConnection()->getParameters()->database;
89+
90+
if (is_int($index)) {
91+
throw new RuntimeException('Cannot fetch database index.');
92+
}
93+
94+
return $index;
95+
}
96+
97+
throw new RangeException('Unknown Redis connection instance: ' . get_class($connection));
98+
99+
}
100+
101+
private function fetchDbHost(Connection $connection): string
102+
{
103+
if ($connection instanceof PhpRedisConnection) {
104+
$host = $connection->client()->getHost();
105+
106+
if ($host === false) {
107+
throw new RuntimeException('Cannot fetch database host.');
108+
}
109+
110+
return $host;
111+
} elseif ($connection instanceof PredisConnection) {
112+
/** @psalm-suppress PossiblyUndefinedMethod */
113+
$host = $connection->client()->getConnection()->getParameters()->host;
114+
115+
if (is_int($host)) {
116+
throw new RuntimeException('Cannot fetch database index.');
117+
}
118+
119+
return $host;
120+
}
121+
122+
throw new RangeException('Unknown Redis connection instance: ' . get_class($connection));
123+
124+
}
125+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand;
6+
7+
/**
8+
* @see https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/opentelemetry-redis-common/src/index.ts
9+
*/
10+
class Serializer
11+
{
12+
/**
13+
* List of regexes and the number of arguments that should be serialized for matching commands.
14+
* For example, HSET should serialize which key and field it's operating on, but not its value.
15+
* Setting the subset to -1 will serialize all arguments.
16+
* Commands without a match will have their first argument serialized.
17+
*
18+
* Refer to https://redis.io/commands/ for the full list.
19+
*/
20+
private const SERIALIZATION_SUBSETS = [
21+
[
22+
'regex' => '/^ECHO/i',
23+
'args' => 0,
24+
],
25+
[
26+
'regex' => '/^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i',
27+
'args' => 1,
28+
],
29+
[
30+
'regex' => '/^(HSET|HMSET|LSET|LINSERT)/i',
31+
'args' => 2,
32+
],
33+
[
34+
'regex' => '/^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i',
35+
'args' => -1,
36+
],
37+
];
38+
39+
/**
40+
* Given the redis command name and arguments, return a combination of the
41+
* command name + the allowed arguments according to `SERIALIZATION_SUBSETS`.
42+
*
43+
* @param string $command The redis command name
44+
* @param array $params The redis command arguments
45+
* @return string A combination of the command name + args according to `SERIALIZATION_SUBSETS`.
46+
*/
47+
public static function serializeCommand(string $command, array $params): string
48+
{
49+
if (count($params) === 0) {
50+
return $command;
51+
}
52+
53+
$paramsToSerializeNum = 0;
54+
55+
// Find the number of arguments to serialize for the given command
56+
foreach (self::SERIALIZATION_SUBSETS as $subset) {
57+
if (preg_match($subset['regex'], $command)) {
58+
$paramsToSerializeNum = $subset['args'];
59+
60+
break;
61+
}
62+
}
63+
64+
// Serialize the allowed number of arguments
65+
$paramsWillToSerialize = ($paramsToSerializeNum >= 0) ? array_slice($params, 0, $paramsToSerializeNum) : $params;
66+
67+
// If there are more arguments than serialized, add a placeholder
68+
if (count($params) > count($paramsWillToSerialize)) {
69+
$paramsWillToSerialize[] = '[' . (count($params) - $paramsToSerializeNum) . ' other arguments]';
70+
}
71+
72+
return $command . ' ' . implode(' ', $paramsWillToSerialize);
73+
}
74+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Unit\Watches\RedisCommand;
6+
7+
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand\Serializer;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class SerializerTest extends TestCase
11+
{
12+
/**
13+
* @dataProvider serializeCases
14+
*/
15+
public function testSerialize($command, $params, $expected): void
16+
{
17+
$this->assertSame($expected, Serializer::serializeCommand($command, $params));
18+
}
19+
20+
public function serializeCases(): iterable
21+
{
22+
// Only serialize command
23+
yield ['ECHO', ['param1'], 'ECHO [1 other arguments]'];
24+
25+
// Only serialize 1 params
26+
yield ['SET', ['param1', 'param2'], 'SET param1 [1 other arguments]'];
27+
yield ['SET', ['param1', 'param2', 'param3'], 'SET param1 [2 other arguments]'];
28+
29+
// Only serialize 2 params
30+
yield ['HSET', ['param1', 'param2', 'param3'], 'HSET param1 param2 [1 other arguments]'];
31+
32+
// Serialize all params
33+
yield ['DEL', ['param1', 'param2', 'param3', 'param4'], 'DEL param1 param2 param3 param4'];
34+
}
35+
}

0 commit comments

Comments
 (0)