Skip to content

Commit

Permalink
Feat: Add Redis watcher (#293)
Browse files Browse the repository at this point in the history
  • Loading branch information
MilesChou authored Sep 23, 2024
1 parent c549d0f commit 80afd21
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
},
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Redis\Connections\Connection;
use Illuminate\Redis\Connections\PhpRedisConnection;
use Illuminate\Redis\Connections\PredisConnection;
use Illuminate\Redis\Events\CommandExecuted;
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher;
use OpenTelemetry\SemConv\TraceAttributes;
use OpenTelemetry\SemConv\TraceAttributeValues;
use Throwable;

/**
* Watch the Redis Command event
*
* Call facade `Redis::enableEvents()` before using this watcher
*/
class RedisCommandWatcher extends Watcher
{
public function __construct(
private CachedInstrumentation $instrumentation,
) {
}

/** @psalm-suppress UndefinedInterfaceMethod */
public function register(Application $app): void
{
/** @phan-suppress-next-line PhanTypeArraySuspicious */
$app['events']->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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand;

/**
* @see https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/opentelemetry-redis-common/src/index.ts
*/
class Serializer
{
/**
* List of regexes and the number of arguments that should be serialized for matching commands.
* For example, HSET should serialize which key and field it's operating on, but not its value.
* Setting the subset to -1 will serialize all arguments.
* Commands without a match will have their first argument serialized.
*
* Refer to https://redis.io/commands/ for the full list.
*/
private const SERIALIZATION_SUBSETS = [
[
'regex' => '/^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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Unit\Watches\RedisCommand;

use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\RedisCommand\Serializer;
use PHPUnit\Framework\TestCase;

class SerializerTest extends TestCase
{
/**
* @dataProvider serializeCases
*/
public function testSerialize($command, $params, $expected): void
{
$this->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'];
}
}

0 comments on commit 80afd21

Please sign in to comment.