F websockets:watch -v — per-connection rows (socket id, user, duration)

Verbose mode reuses Symfony's built-in -v flag (the short option is
reserved, so a custom one can't be added). Each channel summary is
followed by sub-rows showing the socket id, the authed user (falling
back to "Guest" or the user id if the user blob expired), and how long
the connection has been open.

Connection start times are written by Handler::establishConnection() into
ws_connection_<slug(socketId)> on open and forgotten on close. Also
fixed a latent bug in finalizeConnectionClose() where the forget key was
missing the slug() call that the reader has always used — previously
invisible because nothing wrote the key, now load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-04-27 13:42:26 +02:00
parent e1abef4194
commit e45d8dff20
2 changed files with 116 additions and 26 deletions

View File

@ -14,23 +14,31 @@ use Illuminate\Console\Command;
* authenticated users, active channels, per-channel breakdown) and refreshes * authenticated users, active channels, per-channel breakdown) and refreshes
* every second by default. * every second by default.
* *
* With -v / --verbose, the channels table expands to include one sub-row per
* connection showing the socket id, the authenticated user (or "Guest"), and
* how long the connection has been open. Connection start times are read from
* the per-socket cache key written by Handler::establishConnection().
*
* Polling vs. event-driven: stats live in the cache store (Redis in prod) and * Polling vs. event-driven: stats live in the cache store (Redis in prod) and
* are written by the running websocket process. There is no pub/sub channel * are written by the running websocket process. There is no pub/sub channel
* that emits a "stats changed" event, so a true event-driven approach would * that emits a "stats changed" event today, so a true event-driven approach
* require either tailing the cache writes or adding pub/sub plumbing into * would require threading pub/sub publishes through every connection-mutation
* WebsocketService. A 1-second poll is well under the granularity at which * call site. A 1-second poll is well under the granularity at which connection
* connection counts are interesting and avoids that infrastructure cost. * counts are interesting and avoids that infrastructure cost.
*/ */
class WatchStats extends Command class WatchStats extends Command
{ {
protected $signature = 'websockets:watch protected $signature = 'websockets:watch
{--interval=1 : Refresh interval in seconds (minimum 1)}'; {--interval=1 : Refresh interval in seconds (minimum 1)}';
protected $description = 'Live WebSocket stats display — refreshes every second until Ctrl+C.'; protected $description = 'Live WebSocket stats display — refreshes every second until Ctrl+C. Pass -v to expand each channel into per-connection rows (socket id, user, duration).';
public function handle(): int public function handle(): int
{ {
$interval = max(1, (int) $this->option('interval')); $interval = max(1, (int) $this->option('interval'));
// Verbose mode piggybacks on Symfony's built-in -v / --verbose flag
// because that short option is reserved and can't be redeclared.
$verbose = $this->output->isVerbose();
// Restore cursor visibility on Ctrl+C / kill so the terminal isn't // Restore cursor visibility on Ctrl+C / kill so the terminal isn't
// left in a broken state if the user interrupts mid-render. // left in a broken state if the user interrupts mid-render.
@ -51,11 +59,12 @@ class WatchStats extends Command
while (true) { while (true) {
$this->output->write("\033[2J\033[H"); // clear screen + home cursor $this->output->write("\033[2J\033[H"); // clear screen + home cursor
$this->line('<fg=cyan;options=bold>WebSocket Server — Live Stats</>'); $modeLabel = $verbose ? ' [verbose]' : '';
$this->line('<fg=cyan;options=bold>WebSocket Server — Live Stats' . $modeLabel . '</>');
$this->line('<fg=gray>' . now()->format('Y-m-d H:i:s') . ' — refreshing every ' . $interval . 's (Ctrl+C to exit)</>'); $this->line('<fg=gray>' . now()->format('Y-m-d H:i:s') . ' — refreshing every ' . $interval . 's (Ctrl+C to exit)</>');
$this->newLine(); $this->newLine();
$this->renderStats(); $this->renderStats($verbose);
sleep($interval); sleep($interval);
} }
@ -66,25 +75,18 @@ class WatchStats extends Command
return 0; // @phpstan-ignore-line return 0; // @phpstan-ignore-line
} }
/** protected function renderStats(bool $verbose): void
* Identical to ServerInfo::renderStats duplicated rather than shared via
* trait so that the two commands can evolve independently if needed (e.g.
* the live view may eventually want sparkline-style deltas that the
* one-shot view doesn't).
*/
protected function renderStats(): void
{ {
$channels = WebsocketService::getActiveChannels(); $channels = WebsocketService::getActiveChannels();
$authedUsers = WebsocketService::getAuthedUsers(); $authedUsers = WebsocketService::getAuthedUsers(); // [socketId => userId]
$totalConnections = 0; $totalConnections = 0;
$channelData = []; $perChannel = []; // [channelName => [socketId, ...]]
foreach ($channels as $channel) { foreach ($channels as $channel) {
$connections = WebsocketService::getChannelConnections($channel); $sockets = WebsocketService::getChannelConnections($channel);
$count = count($connections); $perChannel[$channel] = $sockets;
$totalConnections += $count; $totalConnections += count($sockets);
$channelData[] = [$channel, $count];
} }
$uniqueUsers = count(array_unique(array_values($authedUsers))); $uniqueUsers = count(array_unique(array_values($authedUsers)));
@ -94,12 +96,89 @@ class WatchStats extends Command
$this->components->twoColumnDetail('Authenticated users', "<fg=white;options=bold>{$uniqueUsers}</>"); $this->components->twoColumnDetail('Authenticated users', "<fg=white;options=bold>{$uniqueUsers}</>");
$this->components->twoColumnDetail('Active channels', '<fg=white;options=bold>' . count($channels) . '</>'); $this->components->twoColumnDetail('Active channels', '<fg=white;options=bold>' . count($channels) . '</>');
if (count($channelData) > 0) { if (empty($perChannel)) {
$this->newLine();
$this->table(['Channel', 'Connections'], $channelData);
} else {
$this->newLine(); $this->newLine();
$this->line(' <fg=gray>No active channels.</>'); $this->line(' <fg=gray>No active channels.</>');
return;
}
$this->newLine();
if (! $verbose) {
$rows = [];
foreach ($perChannel as $channel => $sockets) {
$rows[] = [$channel, count($sockets)];
}
$this->table(['Channel', 'Connections'], $rows);
return;
}
// Verbose: one summary row per channel, then one sub-row per connection.
// The sub-rows fill the User and Duration columns; the summary fills
// Channel and Connections. Dashes mark "not applicable on this row".
$now = time();
$rows = [];
foreach ($perChannel as $channel => $sockets) {
$rows[] = [
"<fg=white;options=bold>{$channel}</>",
"<fg=white;options=bold>" . count($sockets) . "</>",
'-',
'-',
];
foreach ($sockets as $socketId) {
$rows[] = [
' <fg=gray>↳</>',
"<fg=gray>{$socketId}</>",
$this->formatUser($socketId, $authedUsers),
$this->formatDuration($socketId, $now),
];
} }
} }
$this->table(['Channel', 'Connections', 'User', 'Duration'], $rows);
}
private function formatUser(string $socketId, array $authedUsers): string
{
if (! isset($authedUsers[$socketId])) {
return '<fg=gray>Guest</>';
}
$user = WebsocketService::getAuth($socketId);
if (! $user) {
// Authed but the per-socket user blob expired / was evicted.
return '<fg=yellow>#' . $authedUsers[$socketId] . '</>';
}
// Try common identity fields in order of usefulness. Fall back to the
// user id so we always have something to display.
$label = $user->name
?? $user->display_name
?? $user->username
?? $user->email
?? ('#' . ($user->id ?? '?'));
return (string) $label;
}
private function formatDuration(string $socketId, int $now): string
{
$info = WebsocketService::getConnection($socketId);
$connectedAt = is_array($info) ? ($info['connected_at'] ?? null) : null;
if (! is_int($connectedAt)) {
// Connection predates the connected_at tracking, or the cache
// entry was lost. Show a placeholder rather than 00:00:00 which
// would falsely imply a brand-new connection.
return '<fg=gray>—</>';
}
$secs = max(0, $now - $connectedAt);
$h = intdiv($secs, 3600);
$m = intdiv($secs % 3600, 60);
$s = $secs % 60;
return sprintf('%02d:%02d:%02d', $h, $m, $s);
}
} }

View File

@ -1015,7 +1015,7 @@ class Handler implements MessageComponentInterface
$this->channelManager->unsubscribeFromApp($connection->app->id); $this->channelManager->unsubscribeFromApp($connection->app->id);
ConnectionClosed::dispatch($connection->app->id, $connection->socketId); ConnectionClosed::dispatch($connection->app->id, $connection->socketId);
cache()->forget('ws_connection_' . $connection->socketId); cache()->forget('ws_connection_' . str()->slug($connection->socketId));
}); });
} }
@ -1139,6 +1139,17 @@ class Handler implements MessageComponentInterface
]), ]),
])); ]));
// Track connection start time so admin tooling (e.g.
// `php artisan websockets:watch -v`) can report how long a socket
// has been open. Slug the socketId to match WebsocketService::getConnection()
// (which has always slugged its reads). Cleaned up by
// finalizeConnectionClose().
cache()->forever('ws_connection_' . str()->slug($connection->socketId), [
'socket_id' => $connection->socketId,
'connected_at' => time(),
'remote_addr' => $connection->remoteAddress ?? null,
]);
return $this; return $this;
} }