From 80afd217ea84f2035157432b3c9d573ba856f2c3 Mon Sep 17 00:00:00 2001 From: MilesChou Date: Mon, 23 Sep 2024 10:12:46 +0800 Subject: [PATCH] Feat: Add Redis watcher (#293) --- .../Illuminate/Foundation/Application.php | 2 + .../RedisCommand/RedisCommandWatcher.php | 105 ++++++++++++++++++ .../src/Watchers/RedisCommand/Serializer.php | 74 ++++++++++++ .../Watches/RedisCommand/SerializerTest.php | 35 ++++++ 4 files changed, 216 insertions(+) create mode 100644 src/Instrumentation/Laravel/src/Watchers/RedisCommand/RedisCommandWatcher.php create mode 100644 src/Instrumentation/Laravel/src/Watchers/RedisCommand/Serializer.php create mode 100644 src/Instrumentation/Laravel/tests/Unit/Watches/RedisCommand/SerializerTest.php diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php index ebae628c..54aa70d8 100644 --- a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php @@ -13,6 +13,7 @@ use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\ExceptionWatcher; use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\LogWatcher; use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\QueryWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand\RedisCommandWatcher; use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher; use function OpenTelemetry\Instrumentation\hook; use Throwable; @@ -32,6 +33,7 @@ public function instrument(): void $this->registerWatchers($application, new ExceptionWatcher()); $this->registerWatchers($application, new LogWatcher($this->instrumentation)); $this->registerWatchers($application, new QueryWatcher($this->instrumentation)); + $this->registerWatchers($application, new RedisCommandWatcher($this->instrumentation)); }, ); } diff --git a/src/Instrumentation/Laravel/src/Watchers/RedisCommand/RedisCommandWatcher.php b/src/Instrumentation/Laravel/src/Watchers/RedisCommand/RedisCommandWatcher.php new file mode 100644 index 00000000..6e798d4e --- /dev/null +++ b/src/Instrumentation/Laravel/src/Watchers/RedisCommand/RedisCommandWatcher.php @@ -0,0 +1,105 @@ +listen(CommandExecuted::class, [$this, 'recordRedisCommand']); + } + + /** + * Record a Redis command. + */ + /** @psalm-suppress UndefinedThisPropertyFetch */ + public function recordRedisCommand(CommandExecuted $event): void + { + $nowInNs = (int) (microtime(true) * 1E9); + + $operationName = strtoupper($event->command); + + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation->tracer() + ->spanBuilder($operationName) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setStartTimestamp($this->calculateQueryStartTime($nowInNs, $event->time)) + ->startSpan(); + + // See https://opentelemetry.io/docs/specs/semconv/database/redis/ + $attributes = [ + TraceAttributes::DB_SYSTEM => TraceAttributeValues::DB_SYSTEM_REDIS, + TraceAttributes::DB_NAME => $this->fetchDbIndex($event->connection), + TraceAttributes::DB_OPERATION => $operationName, + TraceAttributes::DB_STATEMENT => Serializer::serializeCommand($event->command, $event->parameters), + TraceAttributes::SERVER_ADDRESS => $this->fetchDbHost($event->connection), + ]; + + /** @psalm-suppress PossiblyInvalidArgument */ + $span->setAttributes($attributes); + $span->end($nowInNs); + } + + private function calculateQueryStartTime(int $nowInNs, float $queryTimeMs): int + { + return (int) ($nowInNs - ($queryTimeMs * 1E6)); + } + + private function fetchDbIndex(Connection $connection): ?int + { + try { + if ($connection instanceof PhpRedisConnection) { + return $connection->client()->getDbNum(); + } elseif ($connection instanceof PredisConnection) { + /** @psalm-suppress PossiblyUndefinedMethod */ + return $connection->client()->getConnection()->getParameters()->database; + } + + return null; + } catch (Throwable $e) { + return null; + } + } + + private function fetchDbHost(Connection $connection): ?string + { + try { + if ($connection instanceof PhpRedisConnection) { + return $connection->client()->getHost(); + } elseif ($connection instanceof PredisConnection) { + /** @psalm-suppress PossiblyUndefinedMethod */ + return $connection->client()->getConnection()->getParameters()->host; + } + + return null; + } catch (Throwable $e) { + return null; + } + } +} diff --git a/src/Instrumentation/Laravel/src/Watchers/RedisCommand/Serializer.php b/src/Instrumentation/Laravel/src/Watchers/RedisCommand/Serializer.php new file mode 100644 index 00000000..9e76f7ce --- /dev/null +++ b/src/Instrumentation/Laravel/src/Watchers/RedisCommand/Serializer.php @@ -0,0 +1,74 @@ + '/^ECHO/i', + 'args' => 0, + ], + [ + 'regex' => '/^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i', + 'args' => 1, + ], + [ + 'regex' => '/^(HSET|HMSET|LSET|LINSERT)/i', + 'args' => 2, + ], + [ + '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', + 'args' => -1, + ], + ]; + + /** + * Given the redis command name and arguments, return a combination of the + * command name + the allowed arguments according to `SERIALIZATION_SUBSETS`. + * + * @param string $command The redis command name + * @param array $params The redis command arguments + * @return string A combination of the command name + args according to `SERIALIZATION_SUBSETS`. + */ + public static function serializeCommand(string $command, array $params): string + { + if (count($params) === 0) { + return $command; + } + + $paramsToSerializeNum = 0; + + // Find the number of arguments to serialize for the given command + foreach (self::SERIALIZATION_SUBSETS as $subset) { + if (preg_match($subset['regex'], $command)) { + $paramsToSerializeNum = $subset['args']; + + break; + } + } + + // Serialize the allowed number of arguments + $paramsToSerialize = ($paramsToSerializeNum >= 0) ? array_slice($params, 0, $paramsToSerializeNum) : $params; + + // If there are more arguments than serialized, add a placeholder + if (count($params) > count($paramsToSerialize)) { + $paramsToSerialize[] = '[' . (count($params) - $paramsToSerializeNum) . ' other arguments]'; + } + + return $command . ' ' . implode(' ', $paramsToSerialize); + } +} diff --git a/src/Instrumentation/Laravel/tests/Unit/Watches/RedisCommand/SerializerTest.php b/src/Instrumentation/Laravel/tests/Unit/Watches/RedisCommand/SerializerTest.php new file mode 100644 index 00000000..69c831f3 --- /dev/null +++ b/src/Instrumentation/Laravel/tests/Unit/Watches/RedisCommand/SerializerTest.php @@ -0,0 +1,35 @@ +assertSame($expected, Serializer::serializeCommand($command, $params)); + } + + public function serializeCases(): iterable + { + // Only serialize command + yield ['ECHO', ['param1'], 'ECHO [1 other arguments]']; + + // Only serialize 1 params + yield ['SET', ['param1', 'param2'], 'SET param1 [1 other arguments]']; + yield ['SET', ['param1', 'param2', 'param3'], 'SET param1 [2 other arguments]']; + + // Only serialize 2 params + yield ['HSET', ['param1', 'param2', 'param3'], 'HSET param1 param2 [1 other arguments]']; + + // Serialize all params + yield ['DEL', ['param1', 'param2', 'param3', 'param4'], 'DEL param1 param2 param3 param4']; + } +}