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

Commit f3b0608

Browse files
authored
Merge pull request #492 from beyondcode/feature/redis-statistics-driver
[2.x] Redis Statistics Driver
2 parents 66252c1 + a5af8b5 commit f3b0608

File tree

7 files changed

+296
-5
lines changed

7 files changed

+296
-5
lines changed

config/websockets.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@
214214

215215
'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class,
216216
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class,
217+
// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,
217218

218219
/*
219220
|--------------------------------------------------------------------------

docs/horizontal-scaling/getting-started.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,23 @@ Now, when your app broadcasts the message, it will make sure the connection reac
3232
The available drivers for replication are:
3333

3434
- [Redis](redis)
35+
36+
## Configure the Statistics driver
37+
38+
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.
39+
40+
Unfortunately, you might end up with multiple rows when multiple servers run in parallel.
41+
42+
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:
43+
44+
```php
45+
'statistics' => [
46+
47+
'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,
48+
49+
...
50+
51+
],
52+
```
53+
54+
Check the `websockets.php` config file for more details.

docs/horizontal-scaling/redis.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,3 @@ You can set the connection name to the Redis database under `redis`:
3434
```
3535

3636
The connections can be found in your `config/database.php` file, under the `redis` key. It defaults to connection `default`.
37-
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
4+
5+
use BeyondCode\LaravelWebSockets\Apps\App;
6+
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
7+
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
8+
use Illuminate\Cache\RedisLock;
9+
use Illuminate\Support\Facades\Cache;
10+
11+
class RedisStatisticsLogger implements StatisticsLogger
12+
{
13+
/**
14+
* The Channel manager.
15+
*
16+
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager
17+
*/
18+
protected $channelManager;
19+
20+
/**
21+
* The statistics driver instance.
22+
*
23+
* @var \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
24+
*/
25+
protected $driver;
26+
27+
/**
28+
* The Redis manager instance.
29+
*
30+
* @var \Illuminate\Redis\RedisManager
31+
*/
32+
protected $redis;
33+
34+
/**
35+
* Initialize the logger.
36+
*
37+
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager
38+
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
39+
* @return void
40+
*/
41+
public function __construct(ChannelManager $channelManager, StatisticsDriver $driver)
42+
{
43+
$this->channelManager = $channelManager;
44+
$this->driver = $driver;
45+
$this->redis = Cache::getRedis();
46+
}
47+
48+
/**
49+
* Handle the incoming websocket message.
50+
*
51+
* @param mixed $appId
52+
* @return void
53+
*/
54+
public function webSocketMessage($appId)
55+
{
56+
$this->ensureAppIsSet($appId)
57+
->hincrby($this->getHash($appId), 'websocket_message_count', 1);
58+
}
59+
60+
/**
61+
* Handle the incoming API message.
62+
*
63+
* @param mixed $appId
64+
* @return void
65+
*/
66+
public function apiMessage($appId)
67+
{
68+
$this->ensureAppIsSet($appId)
69+
->hincrby($this->getHash($appId), 'api_message_count', 1);
70+
}
71+
72+
/**
73+
* Handle the new conection.
74+
*
75+
* @param mixed $appId
76+
* @return void
77+
*/
78+
public function connection($appId)
79+
{
80+
$currentConnectionCount = $this->ensureAppIsSet($appId)
81+
->hincrby($this->getHash($appId), 'current_connection_count', 1);
82+
83+
$currentPeakConnectionCount = $this->redis->hget($this->getHash($appId), 'peak_connection_count');
84+
85+
$peakConnectionCount = is_null($currentPeakConnectionCount)
86+
? 1
87+
: max($currentPeakConnectionCount, $currentConnectionCount);
88+
89+
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
90+
}
91+
92+
/**
93+
* Handle disconnections.
94+
*
95+
* @param mixed $appId
96+
* @return void
97+
*/
98+
public function disconnection($appId)
99+
{
100+
$currentConnectionCount = $this->ensureAppIsSet($appId)
101+
->hincrby($this->getHash($appId), 'current_connection_count', -1);
102+
103+
$currentPeakConnectionCount = $this->redis->hget($this->getHash($appId), 'peak_connection_count');
104+
105+
$peakConnectionCount = is_null($currentPeakConnectionCount)
106+
? 0
107+
: max($currentPeakConnectionCount, $currentConnectionCount);
108+
109+
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
110+
}
111+
112+
/**
113+
* Save all the stored statistics.
114+
*
115+
* @return void
116+
*/
117+
public function save()
118+
{
119+
$this->lock()->get(function () {
120+
foreach ($this->redis->smembers('laravel-websockets:apps') as $appId) {
121+
if (! $statistic = $this->redis->hgetall($this->getHash($appId))) {
122+
continue;
123+
}
124+
125+
$this->driver::create([
126+
'app_id' => $appId,
127+
'peak_connection_count' => $statistic['peak_connection_count'] ?? 0,
128+
'websocket_message_count' => $statistic['websocket_message_count'] ?? 0,
129+
'api_message_count' => $statistic['api_message_count'] ?? 0,
130+
]);
131+
132+
$currentConnectionCount = $this->channelManager->getConnectionCount($appId);
133+
134+
$currentConnectionCount === 0
135+
? $this->resetAppTraces($appId)
136+
: $this->resetStatistics($appId, $currentConnectionCount);
137+
}
138+
});
139+
}
140+
141+
/**
142+
* Ensure the app id is stored in the Redis database.
143+
*
144+
* @param mixed $appId
145+
* @return \Illuminate\Redis\RedisManager
146+
*/
147+
protected function ensureAppIsSet($appId)
148+
{
149+
$this->redis->sadd('laravel-websockets:apps', $appId);
150+
151+
return $this->redis;
152+
}
153+
154+
/**
155+
* Reset the statistics to a specific connection count.
156+
*
157+
* @param mixed $appId
158+
* @param int $currentConnectionCount
159+
* @return void
160+
*/
161+
public function resetStatistics($appId, int $currentConnectionCount)
162+
{
163+
$this->redis->hset($this->getHash($appId), 'current_connection_count', $currentConnectionCount);
164+
$this->redis->hset($this->getHash($appId), 'peak_connection_count', $currentConnectionCount);
165+
$this->redis->hset($this->getHash($appId), 'websocket_message_count', 0);
166+
$this->redis->hset($this->getHash($appId), 'api_message_count', 0);
167+
}
168+
169+
/**
170+
* Remove all app traces from the database if no connections have been set
171+
* in the meanwhile since last save.
172+
*
173+
* @param mixed $appId
174+
* @return void
175+
*/
176+
public function resetAppTraces($appId)
177+
{
178+
$this->redis->hdel($this->getHash($appId), 'current_connection_count');
179+
$this->redis->hdel($this->getHash($appId), 'peak_connection_count');
180+
$this->redis->hdel($this->getHash($appId), 'websocket_message_count');
181+
$this->redis->hdel($this->getHash($appId), 'api_message_count');
182+
183+
$this->redis->srem('laravel-websockets:apps', $appId);
184+
}
185+
186+
/**
187+
* Get the Redis hash name for the app.
188+
*
189+
* @param mixed $appId
190+
* @return string
191+
*/
192+
protected function getHash($appId): string
193+
{
194+
return "laravel-websockets:app:{$appId}";
195+
}
196+
197+
/**
198+
* Get a new RedisLock instance to avoid race conditions.
199+
*
200+
* @return \Illuminate\Cache\CacheLock
201+
*/
202+
protected function lock()
203+
{
204+
return new RedisLock($this->redis, 'laravel-websockets:lock', 0);
205+
}
206+
}

tests/Statistics/Logger/FakeStatisticsLogger.php renamed to tests/Mocks/FakeMemoryStatisticsLogger.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<?php
22

3-
namespace BeyondCode\LaravelWebSockets\Tests\Statistics\Logger;
3+
namespace BeyondCode\LaravelWebSockets\Tests\Mocks;
44

55
use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;
66

7-
class FakeStatisticsLogger extends MemoryStatisticsLogger
7+
class FakeMemoryStatisticsLogger extends MemoryStatisticsLogger
88
{
99
/**
1010
* {@inheritdoc}

tests/Statistics/Logger/StatisticsLoggerTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
66
use BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger;
77
use BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger;
8+
use BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger;
89
use BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry;
910
use BeyondCode\LaravelWebSockets\Tests\TestCase;
1011

@@ -92,4 +93,68 @@ public function it_counts_connections_with_null_logger()
9293

9394
$this->assertCount(0, WebSocketsStatisticsEntry::all());
9495
}
96+
97+
/** @test */
98+
public function it_counts_connections_with_redis_logger_with_no_data()
99+
{
100+
$this->runOnlyOnRedisReplication();
101+
102+
config(['cache.default' => 'redis']);
103+
104+
$connection = $this->getConnectedWebSocketConnection(['channel-1']);
105+
106+
$logger = new RedisStatisticsLogger(
107+
$this->channelManager,
108+
$this->statisticsDriver
109+
);
110+
111+
$logger->resetAppTraces('1234');
112+
113+
$logger->webSocketMessage($connection->app->id);
114+
$logger->apiMessage($connection->app->id);
115+
$logger->connection($connection->app->id);
116+
$logger->disconnection($connection->app->id);
117+
118+
$logger->save();
119+
120+
$this->assertCount(1, WebSocketsStatisticsEntry::all());
121+
122+
$entry = WebSocketsStatisticsEntry::first();
123+
124+
$this->assertEquals(1, $entry->peak_connection_count);
125+
$this->assertEquals(1, $entry->websocket_message_count);
126+
$this->assertEquals(1, $entry->api_message_count);
127+
}
128+
129+
/** @test */
130+
public function it_counts_connections_with_redis_logger_with_existing_data()
131+
{
132+
$this->runOnlyOnRedisReplication();
133+
134+
config(['cache.default' => 'redis']);
135+
136+
$connection = $this->getConnectedWebSocketConnection(['channel-1']);
137+
138+
$logger = new RedisStatisticsLogger(
139+
$this->channelManager,
140+
$this->statisticsDriver
141+
);
142+
143+
$logger->resetStatistics('1234', 0);
144+
145+
$logger->webSocketMessage($connection->app->id);
146+
$logger->apiMessage($connection->app->id);
147+
$logger->connection($connection->app->id);
148+
$logger->disconnection($connection->app->id);
149+
150+
$logger->save();
151+
152+
$this->assertCount(1, WebSocketsStatisticsEntry::all());
153+
154+
$entry = WebSocketsStatisticsEntry::first();
155+
156+
$this->assertEquals(1, $entry->peak_connection_count);
157+
$this->assertEquals(1, $entry->websocket_message_count);
158+
$this->assertEquals(1, $entry->api_message_count);
159+
}
95160
}

tests/TestCase.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
99
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
1010
use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection;
11+
use BeyondCode\LaravelWebSockets\Tests\Mocks\FakeMemoryStatisticsLogger;
1112
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
12-
use BeyondCode\LaravelWebSockets\Tests\Statistics\Logger\FakeStatisticsLogger;
1313
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
1414
use GuzzleHttp\Psr7\Request;
1515
use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase;
@@ -58,7 +58,7 @@ public function setUp(): void
5858

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

61-
StatisticsLogger::swap(new FakeStatisticsLogger(
61+
StatisticsLogger::swap(new FakeMemoryStatisticsLogger(
6262
$this->channelManager,
6363
app(StatisticsDriver::class)
6464
));

0 commit comments

Comments
 (0)