From 70eeb005ef6bceab06219de80c503915655576c4 Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 22:13:19 +0300 Subject: [PATCH 1/7] Added redis logger --- phpunit.xml.dist | 1 + .../Logger/RedisStatisticsLogger.php | 195 ++++++++++++++++++ .../Logger/StatisticsLoggerTest.php | 57 +++++ 3 files changed, 253 insertions(+) create mode 100644 src/Statistics/Logger/RedisStatisticsLogger.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 179f0b3..ef9bea0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,5 +21,6 @@ + diff --git a/src/Statistics/Logger/RedisStatisticsLogger.php b/src/Statistics/Logger/RedisStatisticsLogger.php new file mode 100644 index 0000000..a5067ca --- /dev/null +++ b/src/Statistics/Logger/RedisStatisticsLogger.php @@ -0,0 +1,195 @@ +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() + { + 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}"; + } +} diff --git a/tests/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php index c7f2365..1ae28e7 100644 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ b/tests/Statistics/Logger/StatisticsLoggerTest.php @@ -4,6 +4,7 @@ namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Controllers; use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger; +use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry; use BeyondCode\LaravelWebSockets\Tests\TestCase; @@ -92,4 +93,60 @@ class StatisticsLoggerTest extends TestCase $this->assertCount(0, WebSocketsStatisticsEntry::all()); } + + /** @test */ + public function it_counts_connections_with_redis_logger_with_no_data() + { + $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() + { + $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); + } } From 0c8c5c0d9b0acc4f0001c6107548734e83e19d38 Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 22:13:30 +0300 Subject: [PATCH 2/7] Moved the mocks statistics loggers to Mocks/ --- .../FakeMemoryStatisticsLogger.php} | 4 ++-- tests/TestCase.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename tests/{Statistics/Logger/FakeStatisticsLogger.php => Mocks/FakeMemoryStatisticsLogger.php} (83%) diff --git a/tests/Statistics/Logger/FakeStatisticsLogger.php b/tests/Mocks/FakeMemoryStatisticsLogger.php similarity index 83% rename from tests/Statistics/Logger/FakeStatisticsLogger.php rename to tests/Mocks/FakeMemoryStatisticsLogger.php index 629e627..88f1e11 100644 --- a/tests/Statistics/Logger/FakeStatisticsLogger.php +++ b/tests/Mocks/FakeMemoryStatisticsLogger.php @@ -1,10 +1,10 @@ statisticsDriver = $this->app->make(StatisticsDriver::class); - StatisticsLogger::swap(new FakeStatisticsLogger( + StatisticsLogger::swap(new FakeMemoryStatisticsLogger( $this->channelManager, app(StatisticsDriver::class) )); From edcc2f958265f46f23ebc9ee5a296b8059668576 Mon Sep 17 00:00:00 2001 From: rennokki Date: Thu, 27 Aug 2020 22:13:50 +0300 Subject: [PATCH 3/7] Apply fixes from StyleCI (#491) --- src/Statistics/Logger/RedisStatisticsLogger.php | 2 -- tests/Statistics/Logger/StatisticsLoggerTest.php | 2 +- tests/TestCase.php | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Statistics/Logger/RedisStatisticsLogger.php b/src/Statistics/Logger/RedisStatisticsLogger.php index a5067ca..a347df6 100644 --- a/src/Statistics/Logger/RedisStatisticsLogger.php +++ b/src/Statistics/Logger/RedisStatisticsLogger.php @@ -85,7 +85,6 @@ class RedisStatisticsLogger implements StatisticsLogger ? 1 : max($currentPeakConnectionCount, $currentConnectionCount); - $this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount); } @@ -106,7 +105,6 @@ class RedisStatisticsLogger implements StatisticsLogger ? 0 : max($currentPeakConnectionCount, $currentConnectionCount); - $this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount); } diff --git a/tests/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php index 1ae28e7..f8ab0c4 100644 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ b/tests/Statistics/Logger/StatisticsLoggerTest.php @@ -4,8 +4,8 @@ namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Controllers; use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger; -use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger; +use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger; use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry; use BeyondCode\LaravelWebSockets\Tests\TestCase; diff --git a/tests/TestCase.php b/tests/TestCase.php index 417b6b1..e817fb8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,8 +8,8 @@ use BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient; use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection; -use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\Mocks\FakeMemoryStatisticsLogger; +use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use GuzzleHttp\Psr7\Request; use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase; From 68a3a74dfa599cc8dcfab0910afdc1f24ec78709 Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 22:18:27 +0300 Subject: [PATCH 4/7] Added docs --- config/websockets.php | 1 + docs/horizontal-scaling/getting-started.md | 20 ++++++++++++++++++++ docs/horizontal-scaling/redis.md | 1 - 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/config/websockets.php b/config/websockets.php index b7ffe7c..28b14b4 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -214,6 +214,7 @@ return [ 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class, // 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class, + // 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class, /* |-------------------------------------------------------------------------- diff --git a/docs/horizontal-scaling/getting-started.md b/docs/horizontal-scaling/getting-started.md index fffd7fa..3408fff 100644 --- a/docs/horizontal-scaling/getting-started.md +++ b/docs/horizontal-scaling/getting-started.md @@ -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. diff --git a/docs/horizontal-scaling/redis.md b/docs/horizontal-scaling/redis.md index ee6c758..55020fe 100644 --- a/docs/horizontal-scaling/redis.md +++ b/docs/horizontal-scaling/redis.md @@ -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`. - From ee8681a459f82e5a416bfbe383ba3af0e91deb3f Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 22:28:22 +0300 Subject: [PATCH 5/7] Use a RedisLock to avoid race conditions. --- .../Logger/RedisStatisticsLogger.php | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/Statistics/Logger/RedisStatisticsLogger.php b/src/Statistics/Logger/RedisStatisticsLogger.php index a347df6..e6125b2 100644 --- a/src/Statistics/Logger/RedisStatisticsLogger.php +++ b/src/Statistics/Logger/RedisStatisticsLogger.php @@ -5,6 +5,7 @@ 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 @@ -115,24 +116,26 @@ class RedisStatisticsLogger implements StatisticsLogger */ public function save() { - foreach ($this->redis->smembers('laravel-websockets:apps') as $appId) { - if (! $statistic = $this->redis->hgetall($this->getHash($appId))) { - continue; + $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); } - - $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); - } + }); } /** @@ -190,4 +193,14 @@ class RedisStatisticsLogger implements StatisticsLogger { 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); + } } From 62fc523cfcc4a06feb7a9b24df13915bbf64e14a Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 22:40:10 +0300 Subject: [PATCH 6/7] Fixed tests --- phpunit.xml.dist | 1 - tests/Statistics/Logger/StatisticsLoggerTest.php | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ef9bea0..179f0b3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,5 @@ - diff --git a/tests/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php index f8ab0c4..c9033d2 100644 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ b/tests/Statistics/Logger/StatisticsLoggerTest.php @@ -97,6 +97,8 @@ class StatisticsLoggerTest extends TestCase /** @test */ public function it_counts_connections_with_redis_logger_with_no_data() { + config(['cache.default' => 'redis']); + $connection = $this->getConnectedWebSocketConnection(['channel-1']); $logger = new RedisStatisticsLogger( @@ -125,6 +127,8 @@ class StatisticsLoggerTest extends TestCase /** @test */ public function it_counts_connections_with_redis_logger_with_existing_data() { + config(['cache.default' => 'redis']); + $connection = $this->getConnectedWebSocketConnection(['channel-1']); $logger = new RedisStatisticsLogger( From a5af8b5afa5af1f9834125a80a6dfc51e58fb6e7 Mon Sep 17 00:00:00 2001 From: Alex Renoki Date: Thu, 27 Aug 2020 23:04:22 +0300 Subject: [PATCH 7/7] Fixed tests --- tests/Statistics/Logger/StatisticsLoggerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php index c9033d2..8374609 100644 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ b/tests/Statistics/Logger/StatisticsLoggerTest.php @@ -97,6 +97,8 @@ class StatisticsLoggerTest extends TestCase /** @test */ public function it_counts_connections_with_redis_logger_with_no_data() { + $this->runOnlyOnRedisReplication(); + config(['cache.default' => 'redis']); $connection = $this->getConnectedWebSocketConnection(['channel-1']); @@ -127,6 +129,8 @@ class StatisticsLoggerTest extends TestCase /** @test */ public function it_counts_connections_with_redis_logger_with_existing_data() { + $this->runOnlyOnRedisReplication(); + config(['cache.default' => 'redis']); $connection = $this->getConnectedWebSocketConnection(['channel-1']);