How to track online users with Redis

  • avatar
  • 78 Views
  • 11 mins read

The number of people using a website right now is a common metric. It shows how active the site is at a given moment, and it makes traffic visible while it happens instead of after the fact. Analytics services can report it, but with a delay and outside the application. Redis can keep it inside your own code: each page view records the visitor, and the count is read for the last X minutes, where X is a value from the configuration file that can be changed at any time.

What counts as an online user

A browser does not tell the server when a tab is closed, so a visitor counts as online if a request from them arrived during the last X minutes. Every page view refreshes the visitor, and anyone who stops browsing leaves the count after X minutes.

Logged in users are identified by their user id and guests by their session id. Redis fits this job because it is fast, shared by all application servers and able to delete data by itself after a given time.

Two Redis structures fit this problem.

  • Sorted set. One key holds a member per visitor, with the time of the last request as its score. Any window works, down to the second. Redis cannot expire single members of a sorted set, so old visitors are removed by a task that runs periodically.

  • One set per minute. Every minute gets its own set with the ids of the visitors seen during that minute. Counting the last X minutes means joining X sets. The window moves in whole minutes, and each set deletes itself through a Redis expiration.

With the sorted set, the member is the visitor id and the score is the Unix timestamp of the last request. ZADD runs on every page view, ZCOUNT reads the metric, and ZREMRANGEBYSCORE drops the members older than the longest window.

# Add the visitor or move its score forward ([TIMESTAMP] is the current Unix time)
ZADD online:users [TIMESTAMP] user:42

# Count the members seen during the last 5 minutes
ZCOUNT online:users [TIMESTAMP-5m] +inf

# Remove members older than the longest window (20 minutes in this example)
ZREMRANGEBYSCORE online:users -inf [TIMESTAMP-20m]

A sorted set expires as a whole key, never member by member. For this reason ZREMRANGEBYSCORE takes the role of the expiration: it deletes the members older than the longest window and is called regularly, for example every minute. With four members whose last requests were 30, 85, 400 and 900 seconds ago, only the lower limit passed to ZCOUNT changes with the window:

  • [TIMESTAMP-5m]: 2 online users

  • [TIMESTAMP-10m]: 3 online users

  • [TIMESTAMP-20m]: 4 online users

The per-minute approach gives each minute its own set, named after that minute. When a visitor makes a request, the visitor id goes into the set of the current minute. These two commands run on every page view, where [MINUTE] is the number of minutes since 1970 (the Unix timestamp divided by 60), which avoids time zones and daylight saving changes.

# Add the visitor to the set of the current minute
SADD online:users:[MINUTE] user:42

# Delete the set after 61 minutes, which is the longest window plus one minute
EXPIRE online:users:[MINUTE] 3660

A set keeps every id only once, so a visitor who opens fifty pages in the same minute is still one member. The minute is part of the key because a Redis expiration applies to a whole key, never to a single member of a set. This is also what replaces any cleanup job, since Redis removes each set by itself when the time is over.

Reading the metric is a union of the sets of the last X minutes. For a window of 3 minutes, the command looks like this:

SUNION online:users:[MINUTE] online:users:[MINUTE-1] online:users:[MINUTE-2]

The union returns each visitor once, even if the visitor appears in several minutes. With these sets:

  • [MINUTE] contains user:42 and guest:a81f

  • [MINUTE-1] contains user:42

  • [MINUTE-2] contains guest:c9d2

  • [MINUTE-5] contains user:7

The results change only with the window:

  • 1 minute: 2 online users

  • 3 minutes: 3 online users, because user:42 is in two sets but counts once

  • 6 minutes: 4 online users

The Laravel code in the next section implements both approaches behind the same interface.

Implementation in Laravel

The implementation starts with the configuration and continues with a repository interface that has two Redis implementations, a middleware that records page views and an endpoint that returns the number. It assumes a Laravel application with Redis already configured.

Configuration file

The visitors.online array in config/custom.php holds every setting of the metric, which makes the window configurable without touching any class.

<?php

return [
'visitors' => [
'online' => [
// Name of the key (the per-minute sets add the minute to it)
'key' => env('ONLINE_USERS_KEY', 'users:online'),

// Window in minutes used when the caller does not pass one
'window' => (int) env('ONLINE_USERS_WINDOW', 5),

// Longest window in minutes the application will ever ask for.
// Data older than this is removed
'retention' => (int) env('ONLINE_USERS_RETENTION', 60),
],
],
];

To change the default window to 15 minutes, set ONLINE_USERS_WINDOW=15 in .env. The retention value is how long the data is kept, so it is also the longest window that can be requested.

Repository interface

Create app/Repositories/OnlineUsersRepository.php. The interface describes what the application needs and says nothing about Redis, so middleware and controllers do not depend on where the data is stored.

<?php

namespace App\\Repositories;

interface OnlineUsersRepository
{
// Registers activity for a visitor
public function persist(string $visitorId): void;

// Removes the data that is older than the retention period
public function purge(): void;

// Number of visitors active during the last $minutes (config value when null)
public function count(?int $minutes = null): int;

// Identifiers of the visitors active during the last $minutes
public function ids(?int $minutes = null): array;
}

The purge() method exists because some storages, such as the sorted set, need the application to remove their old data.

Sorted set implementation

Create app/Repositories/RedisSortedSetOnlineUsersRepository.php

  • persist() adds the visitor or moves its score forward

  • purge() removes the members older than the retention period

<?php

namespace App\\Repositories;

use Illuminate\\Support\\Facades\\Redis;

class RedisSortedSetOnlineUsersRepository implements OnlineUsersRepository
{
public function persist(string $visitorId): void
{
// Adds the visitor or moves its score forward
Redis::connection()->zadd($this->key(), time(), $visitorId);
}

public function purge(): void
{
$cutoff = time() - config('custom.visitors.online.retention') * 60;

// Redis cannot expire single members, so the old ones are removed here
Redis::connection()->zremrangebyscore($this->key(), '-inf', (string) $cutoff);
}

public function count(?int $minutes = null): int
{
$minutes ??= config('custom.visitors.online.window');
$cutoff = time() - $minutes * 60;

return (int) Redis::connection()->zcount($this->key(), (string) $cutoff, '+inf');
}

public function ids(?int $minutes = null): array
{
$minutes ??= config('custom.visitors.online.window');
$cutoff = time() - $minutes * 60;

return Redis::connection()->zrangebyscore($this->key(), (string) $cutoff, '+inf');
}

private function key(): string
{
return config('custom.visitors.online.key');
}
}

Schedule the removal in routes/console.php:

use App\\Repositories\\OnlineUsersRepository;
use Illuminate\\Support\\Facades\\Schedule;

Schedule::call(fn () => app(OnlineUsersRepository::class)->purge())->everyMinute();

The Laravel scheduler needs a single cron entry on the server that runs php artisan schedule:run every minute. The per-minute implementation below has nothing to purge, since Redis expiration already removes its sets.

Per-minute sets implementation

Create app/Repositories/RedisMinuteSetsOnlineUsersRepository.php

  • persist() adds the visitor to the set of the current minute and renews the expiration of that set

  • ids() joins the sets of the last X minutes, where the current minute counts as the first one.

<?php

namespace App\\Repositories;

use Illuminate\\Support\\Facades\\Redis;

class RedisMinuteSetsOnlineUsersRepository implements OnlineUsersRepository
{
public function persist(string $visitorId): void
{
$key = $this->bucket($this->currentMinute());

Redis::connection()->pipeline(function ($pipe) use ($key, $visitorId) {
$pipe->sadd($key, $visitorId);

// The set deletes itself once it is older than the longest window
$pipe->expire($key, (config('custom.visitors.online.retention') + 1) * 60);
});
}

public function purge(): void
{
// Nothing to do, Redis expiration removes the sets
}

public function count(?int $minutes = null): int
{
return count($this->ids($minutes));
}

public function ids(?int $minutes = null): array
{
$window = $minutes ?? config('custom.visitors.online.window');
$currentMinute = $this->currentMinute();
$keys = [];

// The current minute plus the previous ones until the window is covered
for ($i = 0; $i < $window; $i++) {
$keys[] = $this->bucket($currentMinute - $i);
}

return Redis::connection()->sunion(...$keys);
}

// Minutes since 1970, so the key does not depend on time zones
private function currentMinute(): int
{
return intdiv(time(), 60);
}

private function bucket(int $minute): string
{
return config('custom.visitors.online.key') . ':' . $minute;
}
}

Since the unit is a whole minute, a window of 5 covers the current minute and the 4 before it. Sets for minutes with no visitors simply do not exist, and the union ignores them.

Binding the interface

Tell Laravel which class to use in app/Providers/AppServiceProvider.php:

use App\\Repositories\\OnlineUsersRepository;
use App\\Repositories\\RedisSortedSetOnlineUsersRepository;

public function register(): void
{
// Use RedisMinuteSetsOnlineUsersRepository here to switch to the per-minute sets
$this->app->bind(OnlineUsersRepository::class, RedisSortedSetOnlineUsersRepository::class);
}

Any class that type hints OnlineUsersRepository now receives the chosen implementation. Changing the storage, or using a fake one in tests, means changing the class in this line only.

Registering online users

Generate a middleware with php artisan make:middleware TrackOnlineUsers and replace its content. Logged in users are identified by their id and guests by their session id. The try block makes sure that a Redis outage never breaks a page, since the metric is secondary to the request itself.

<?php

namespace App\\Http\\Middleware;

use App\\Repositories\\OnlineUsersRepository;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
use Throwable;
use Closure;

readonly class TrackOnlineUsers
{
public function __construct(private OnlineUsersRepository $onlineUsersRepository)
{
}

public function handle(Request $request, Closure $next): Response
{
// Only page views count as activity, not form submissions or JSON calls
if ($request->isMethod('GET') && ! $request->expectsJson()) {
try {
$this->onlineUsersRepository->persist($this->visitorId($request));
} catch (Throwable) {
}
}

return $next($request);
}

private function visitorId(Request $request): string
{
if ($user = $request->user()) {
return sprintf('user:%s', $user->getAuthIdentifier());
}

return sprintf('guest:%s', $request->session()->getId());
}
}

Register the middleware in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
App\\Http\\Middleware\\TrackOnlineUsers::class,
]);
})

From this moment every page view in the web group records its visitor.

Retrieving online users

Reading the metric is a call to count(). It uses the window from config/custom.php and accepts a number of minutes to use a different one. A route in routes/web.php reads the number and passes it to the view:

use App\\Repositories\\OnlineUsersRepository;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', function (OnlineUsersRepository $onlineUsers) {
// Visitors active during the last 5 minutes
return view('home', [
'online' => $onlineUsers->count(5),
]);
});

The home view only prints the value it receives:

Online now: {{ $online }}

Calling count() without an argument uses the window from the configuration file, while count(5) forces a 5 minute window.

Conclusion

An online users metric rests on one idea: remember when each visitor was last seen and look only at the recent ones. The window that decides what recent means is a configuration value, and the storage sits behind an interface, so both can change without touching the rest of the application.

The number approximates real activity. Crawlers visit like anyone else, a person who logs in changes identifier along the way, and every page view costs a write, so the metric grows with the traffic of the site. Taking it as an estimate is enough for what it is usually used for, and the same idea of a last seen time filtered by age works for any count of recent activity.

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.