Skip to content
This repository has been archived by the owner on Feb 7, 2024. It is now read-only.

Commit

Permalink
Merge pull request #492 from beyondcode/feature/redis-statistics-driver
Browse files Browse the repository at this point in the history
[2.x] Redis Statistics Driver
  • Loading branch information
rennokki authored Aug 27, 2020
2 parents 66252c1 + a5af8b5 commit f3b0608
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 5 deletions.
1 change: 1 addition & 0 deletions config/websockets.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@

'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class,
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class,
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,

/*
|--------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions docs/horizontal-scaling/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,23 @@ Now, when your app broadcasts the message, it will make sure the connection reac
The available drivers for replication are:

- [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.
1 change: 0 additions & 1 deletion docs/horizontal-scaling/redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

206 changes: 206 additions & 0 deletions src/Statistics/Logger/RedisStatisticsLogger.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?php

namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Logger;
namespace BeyondCode\LaravelWebSockets\Tests\Mocks;

use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;

class FakeStatisticsLogger extends MemoryStatisticsLogger
class FakeMemoryStatisticsLogger extends MemoryStatisticsLogger
{
/**
* {@inheritdoc}
Expand Down
65 changes: 65 additions & 0 deletions tests/Statistics/Logger/StatisticsLoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger;
use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry;
use BeyondCode\LaravelWebSockets\Tests\TestCase;

Expand Down Expand Up @@ -92,4 +93,68 @@ public function it_counts_connections_with_null_logger()

$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);
}
}
4 changes: 2 additions & 2 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection;
use BeyondCode\LaravelWebSockets\Tests\Mocks\FakeMemoryStatisticsLogger;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\Statistics\Logger\FakeStatisticsLogger;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use GuzzleHttp\Psr7\Request;
use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase;
Expand Down Expand Up @@ -58,7 +58,7 @@ public function setUp(): void

$this->statisticsDriver = $this->app->make(StatisticsDriver::class);

StatisticsLogger::swap(new FakeStatisticsLogger(
StatisticsLogger::swap(new FakeMemoryStatisticsLogger(
$this->channelManager,
app(StatisticsDriver::class)
));
Expand Down

0 comments on commit f3b0608

Please sign in to comment.