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

[2.x] Redis Statistics Driver #492

Merged
merged 8 commits into from
Aug 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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