Merge pull request #492 from beyondcode/feature/redis-statistics-driver

[2.x] Redis Statistics Driver
This commit is contained in:
rennokki 2020-08-27 23:21:35 +03:00 committed by GitHub
commit f3b0608595
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 296 additions and 5 deletions

View File

@ -214,6 +214,7 @@ return [
'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class, 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class,
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class, // 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class,
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -32,3 +32,23 @@ Now, when your app broadcasts the message, it will make sure the connection reac
The available drivers for replication are: The available drivers for replication are:
- [Redis](redis) - [Redis](redis)
## Configure the Statistics driver
If you work with multi-node environments, beside replication, you shall take a look at the statistics logger. Each time your user connects, disconnects or send a message, you can track the statistics. However, these are centralized in one place before they are dumped in the database.
Unfortunately, you might end up with multiple rows when multiple servers run in parallel.
To fix this, just change the `statistics.logger` class with a logger that is able to centralize the statistics in one place. For example, you might want to store them into a Redis instance:
```php
'statistics' => [
'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,
...
],
```
Check the `websockets.php` config file for more details.

View File

@ -34,4 +34,3 @@ You can set the connection name to the Redis database under `redis`:
``` ```
The connections can be found in your `config/database.php` file, under the `redis` key. It defaults to connection `default`. The connections can be found in your `config/database.php` file, under the `redis` key. It defaults to connection `default`.

View File

@ -0,0 +1,206 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Illuminate\Cache\RedisLock;
use Illuminate\Support\Facades\Cache;
class RedisStatisticsLogger implements StatisticsLogger
{
/**
* The Channel manager.
*
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager
*/
protected $channelManager;
/**
* The statistics driver instance.
*
* @var \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
protected $driver;
/**
* The Redis manager instance.
*
* @var \Illuminate\Redis\RedisManager
*/
protected $redis;
/**
* Initialize the logger.
*
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @return void
*/
public function __construct(ChannelManager $channelManager, StatisticsDriver $driver)
{
$this->channelManager = $channelManager;
$this->driver = $driver;
$this->redis = Cache::getRedis();
}
/**
* Handle the incoming websocket message.
*
* @param mixed $appId
* @return void
*/
public function webSocketMessage($appId)
{
$this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'websocket_message_count', 1);
}
/**
* Handle the incoming API message.
*
* @param mixed $appId
* @return void
*/
public function apiMessage($appId)
{
$this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'api_message_count', 1);
}
/**
* Handle the new conection.
*
* @param mixed $appId
* @return void
*/
public function connection($appId)
{
$currentConnectionCount = $this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'current_connection_count', 1);
$currentPeakConnectionCount = $this->redis->hget($this->getHash($appId), 'peak_connection_count');
$peakConnectionCount = is_null($currentPeakConnectionCount)
? 1
: max($currentPeakConnectionCount, $currentConnectionCount);
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
}
/**
* Handle disconnections.
*
* @param mixed $appId
* @return void
*/
public function disconnection($appId)
{
$currentConnectionCount = $this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'current_connection_count', -1);
$currentPeakConnectionCount = $this->redis->hget($this->getHash($appId), 'peak_connection_count');
$peakConnectionCount = is_null($currentPeakConnectionCount)
? 0
: max($currentPeakConnectionCount, $currentConnectionCount);
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
$this->lock()->get(function () {
foreach ($this->redis->smembers('laravel-websockets:apps') as $appId) {
if (! $statistic = $this->redis->hgetall($this->getHash($appId))) {
continue;
}
$this->driver::create([
'app_id' => $appId,
'peak_connection_count' => $statistic['peak_connection_count'] ?? 0,
'websocket_message_count' => $statistic['websocket_message_count'] ?? 0,
'api_message_count' => $statistic['api_message_count'] ?? 0,
]);
$currentConnectionCount = $this->channelManager->getConnectionCount($appId);
$currentConnectionCount === 0
? $this->resetAppTraces($appId)
: $this->resetStatistics($appId, $currentConnectionCount);
}
});
}
/**
* Ensure the app id is stored in the Redis database.
*
* @param mixed $appId
* @return \Illuminate\Redis\RedisManager
*/
protected function ensureAppIsSet($appId)
{
$this->redis->sadd('laravel-websockets:apps', $appId);
return $this->redis;
}
/**
* Reset the statistics to a specific connection count.
*
* @param mixed $appId
* @param int $currentConnectionCount
* @return void
*/
public function resetStatistics($appId, int $currentConnectionCount)
{
$this->redis->hset($this->getHash($appId), 'current_connection_count', $currentConnectionCount);
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $currentConnectionCount);
$this->redis->hset($this->getHash($appId), 'websocket_message_count', 0);
$this->redis->hset($this->getHash($appId), 'api_message_count', 0);
}
/**
* Remove all app traces from the database if no connections have been set
* in the meanwhile since last save.
*
* @param mixed $appId
* @return void
*/
public function resetAppTraces($appId)
{
$this->redis->hdel($this->getHash($appId), 'current_connection_count');
$this->redis->hdel($this->getHash($appId), 'peak_connection_count');
$this->redis->hdel($this->getHash($appId), 'websocket_message_count');
$this->redis->hdel($this->getHash($appId), 'api_message_count');
$this->redis->srem('laravel-websockets:apps', $appId);
}
/**
* Get the Redis hash name for the app.
*
* @param mixed $appId
* @return string
*/
protected function getHash($appId): string
{
return "laravel-websockets:app:{$appId}";
}
/**
* Get a new RedisLock instance to avoid race conditions.
*
* @return \Illuminate\Cache\CacheLock
*/
protected function lock()
{
return new RedisLock($this->redis, 'laravel-websockets:lock', 0);
}
}

View File

@ -1,10 +1,10 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Logger; namespace BeyondCode\LaravelWebSockets\Tests\Mocks;
use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;
class FakeStatisticsLogger extends MemoryStatisticsLogger class FakeMemoryStatisticsLogger extends MemoryStatisticsLogger
{ {
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -5,6 +5,7 @@ namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Controllers;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry; use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry;
use BeyondCode\LaravelWebSockets\Tests\TestCase; use BeyondCode\LaravelWebSockets\Tests\TestCase;
@ -92,4 +93,68 @@ class StatisticsLoggerTest extends TestCase
$this->assertCount(0, WebSocketsStatisticsEntry::all()); $this->assertCount(0, WebSocketsStatisticsEntry::all());
} }
/** @test */
public function it_counts_connections_with_redis_logger_with_no_data()
{
$this->runOnlyOnRedisReplication();
config(['cache.default' => 'redis']);
$connection = $this->getConnectedWebSocketConnection(['channel-1']);
$logger = new RedisStatisticsLogger(
$this->channelManager,
$this->statisticsDriver
);
$logger->resetAppTraces('1234');
$logger->webSocketMessage($connection->app->id);
$logger->apiMessage($connection->app->id);
$logger->connection($connection->app->id);
$logger->disconnection($connection->app->id);
$logger->save();
$this->assertCount(1, WebSocketsStatisticsEntry::all());
$entry = WebSocketsStatisticsEntry::first();
$this->assertEquals(1, $entry->peak_connection_count);
$this->assertEquals(1, $entry->websocket_message_count);
$this->assertEquals(1, $entry->api_message_count);
}
/** @test */
public function it_counts_connections_with_redis_logger_with_existing_data()
{
$this->runOnlyOnRedisReplication();
config(['cache.default' => 'redis']);
$connection = $this->getConnectedWebSocketConnection(['channel-1']);
$logger = new RedisStatisticsLogger(
$this->channelManager,
$this->statisticsDriver
);
$logger->resetStatistics('1234', 0);
$logger->webSocketMessage($connection->app->id);
$logger->apiMessage($connection->app->id);
$logger->connection($connection->app->id);
$logger->disconnection($connection->app->id);
$logger->save();
$this->assertCount(1, WebSocketsStatisticsEntry::all());
$entry = WebSocketsStatisticsEntry::first();
$this->assertEquals(1, $entry->peak_connection_count);
$this->assertEquals(1, $entry->websocket_message_count);
$this->assertEquals(1, $entry->api_message_count);
}
} }

View File

@ -8,8 +8,8 @@ use BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection; use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection;
use BeyondCode\LaravelWebSockets\Tests\Mocks\FakeMemoryStatisticsLogger;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\Statistics\Logger\FakeStatisticsLogger;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Request;
use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase; use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase;
@ -58,7 +58,7 @@ abstract class TestCase extends BaseTestCase
$this->statisticsDriver = $this->app->make(StatisticsDriver::class); $this->statisticsDriver = $this->app->make(StatisticsDriver::class);
StatisticsLogger::swap(new FakeStatisticsLogger( StatisticsLogger::swap(new FakeMemoryStatisticsLogger(
$this->channelManager, $this->channelManager,
app(StatisticsDriver::class) app(StatisticsDriver::class)
)); ));