whoisonline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +91 -0
- data/lib/whoisonline/configuration.rb +44 -0
- data/lib/whoisonline/controller.rb +32 -0
- data/lib/whoisonline/engine.rb +16 -0
- data/lib/whoisonline/redis_store.rb +47 -0
- data/lib/whoisonline/tracker.rb +70 -0
- data/lib/whoisonline/version.rb +5 -0
- data/lib/whoisonline.rb +33 -0
- data/logowho.png +0 -0
- metadata +114 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 94f2c31318e63780cdee77f18a8aaad0ff39133c1334205a50016713d9aa2de6
|
|
4
|
+
data.tar.gz: b52a4bc959ffdfc54449cbee0cd27c74d2a5930d47487fb4f024d62e2a9c4e85
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1fb7d27b02f1c7d897b412387cc8fd3236f3ad2851f0e3d164b521b54f8f51357ee0fde5cac5e0a87ace9a12776fab2c20d35ce904fc6924dc5bf1195e534444
|
|
7
|
+
data.tar.gz: 38d09284b895047ca3107c22bfc3c7407781a2f6fe8bfd30ed48eb5e56ae0c8ba2fc9b18cbd09c20acea8ed1ab41029be8280eaa458dfa320a748771188df407
|
data/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# WhoIsOnline
|
|
2
|
+
|
|
3
|
+
Track “who is online right now?” in Rails 7/8 using Redis TTL. No database writes, production-safe, and auto-hooks into controllers via a Rails Engine.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Rails Engine auto-includes a controller concern to mark users online.
|
|
7
|
+
- Works with `current_user` from any auth system (Devise, custom, etc.).
|
|
8
|
+
- TTL-based presence in Redis, no tables required.
|
|
9
|
+
- Throttled Redis writes to reduce load (configurable).
|
|
10
|
+
- Safe SCAN-based counting; no `KEYS`.
|
|
11
|
+
- Configurable Redis client, TTL, throttle duration, user id method, controller accessor, and namespace.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
Add to your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "whoisonline", github: "rails-to-rescue/whoisonline"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install directly:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle add whoisonline
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
Create an initializer `config/initializers/whoisonline.rb`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
WhoIsOnline.configure do |config|
|
|
31
|
+
config.redis = -> { Redis.new(url: ENV.fetch("REDIS_URL")) }
|
|
32
|
+
config.ttl = 5.minutes
|
|
33
|
+
config.throttle = 60.seconds
|
|
34
|
+
config.user_id_method = :id
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The engine auto-adds a concern that runs after each controller action to mark the `current_user` as online. Nothing else is required.
|
|
39
|
+
|
|
40
|
+
## Public API
|
|
41
|
+
- `WhoIsOnline.track(user)` – mark a user online (auto-called by the controller concern).
|
|
42
|
+
- `WhoIsOnline.online?(user)` – boolean.
|
|
43
|
+
- `WhoIsOnline.count` – number of online users (via SCAN).
|
|
44
|
+
- `WhoIsOnline.user_ids` – array of ids (strings by default).
|
|
45
|
+
- `WhoIsOnline.users(User)` – ActiveRecord relation for convenience.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
```ruby
|
|
49
|
+
WhoIsOnline.configure do |config|
|
|
50
|
+
config.redis = -> { Redis.new(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0")) }
|
|
51
|
+
config.ttl = 5.minutes # how long a user stays online without activity
|
|
52
|
+
config.throttle = 60.seconds # minimum time between Redis writes per user
|
|
53
|
+
config.user_id_method = :id # how to pull an ID from the user object
|
|
54
|
+
config.current_user_method = :current_user # method on controllers
|
|
55
|
+
config.namespace = "whoisonline:user"
|
|
56
|
+
config.auto_hook = true # disable if you prefer manual tracking
|
|
57
|
+
config.logger = Rails.logger if defined?(Rails)
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Performance Notes
|
|
62
|
+
- Uses `SET key value EX ttl` for O(1) writes.
|
|
63
|
+
- Throttling prevents hot users from spamming Redis.
|
|
64
|
+
- Counting and user listing use `SCAN` to avoid blocking Redis (`KEYS` is not used).
|
|
65
|
+
- Namespace keeps presence keys isolated; use a dedicated Redis db/cluster for large scale.
|
|
66
|
+
|
|
67
|
+
## Example Usage in Rails
|
|
68
|
+
```ruby
|
|
69
|
+
# Somewhere in your controller you can also call manually:
|
|
70
|
+
WhoIsOnline.track(current_user)
|
|
71
|
+
|
|
72
|
+
# In a background job
|
|
73
|
+
if WhoIsOnline.online?(user)
|
|
74
|
+
# notify
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# In a dashboard
|
|
78
|
+
@online_users = WhoIsOnline.users(User).order(last_sign_in_at: :desc)
|
|
79
|
+
@online_count = WhoIsOnline.count
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Extensibility
|
|
83
|
+
- Engine-based hook is easy to extend (e.g., add ActionCable broadcast).
|
|
84
|
+
- Tracker service is isolated and unit-testable.
|
|
85
|
+
- Configuration is thread-safe and lazy-instantiated.
|
|
86
|
+
|
|
87
|
+
## Author
|
|
88
|
+
- Kapil Dev Pal – dev.kapildevpal@gmail.com / @rails_to_rescue
|
|
89
|
+
- Project: rails_to_rescue
|
|
90
|
+
|
|
91
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module WhoIsOnline
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_NAMESPACE = "whoisonline:user".freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :ttl, :throttle, :user_id_method, :namespace, :auto_hook,
|
|
8
|
+
:logger, :current_user_method
|
|
9
|
+
attr_writer :redis
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@ttl = 5.minutes
|
|
13
|
+
@throttle = 60.seconds
|
|
14
|
+
@user_id_method = :id
|
|
15
|
+
@current_user_method = :current_user
|
|
16
|
+
@namespace = DEFAULT_NAMESPACE
|
|
17
|
+
@auto_hook = true
|
|
18
|
+
@logger = default_logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def redis
|
|
22
|
+
@redis ||= lambda do
|
|
23
|
+
Redis.new(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def redis_connection
|
|
28
|
+
client = redis.respond_to?(:call) ? redis.call : redis
|
|
29
|
+
raise ArgumentError, "config.redis must return a Redis-compatible client" unless client.respond_to?(:set)
|
|
30
|
+
|
|
31
|
+
client
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def default_logger
|
|
37
|
+
return Rails.logger if defined?(Rails)
|
|
38
|
+
|
|
39
|
+
Logger.new($stdout, progname: "WhoIsOnline")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module WhoIsOnline
|
|
4
|
+
module Controller
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_action :who_is_online_track_user
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def who_is_online_track_user
|
|
14
|
+
return unless WhoIsOnline.configuration.auto_hook
|
|
15
|
+
|
|
16
|
+
user = resolve_whoisonline_user
|
|
17
|
+
return unless user
|
|
18
|
+
|
|
19
|
+
WhoIsOnline.track(user)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
WhoIsOnline.configuration.logger&.warn("whoisonline track failed: #{e.class} #{e.message}")
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve_whoisonline_user
|
|
26
|
+
method = WhoIsOnline.configuration.current_user_method
|
|
27
|
+
return public_send(method) if respond_to?(method, true)
|
|
28
|
+
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
require_relative "controller"
|
|
3
|
+
|
|
4
|
+
module WhoIsOnline
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace WhoIsOnline
|
|
7
|
+
|
|
8
|
+
initializer "whoisonline.controller" do
|
|
9
|
+
ActiveSupport.on_load(:action_controller) do
|
|
10
|
+
include WhoIsOnline::Controller
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "redis"
|
|
2
|
+
|
|
3
|
+
module WhoIsOnline
|
|
4
|
+
class RedisStore
|
|
5
|
+
def initialize(configuration)
|
|
6
|
+
@configuration = configuration
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def connection
|
|
10
|
+
@connection ||= @configuration.redis_connection
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write_presence(key, value, ttl_seconds)
|
|
14
|
+
connection.set(key, value, ex: ttl_seconds)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
log(:warn, "whoisonline write failed: #{e.class} #{e.message}")
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exists?(key)
|
|
21
|
+
connection.exists?(key)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
log(:warn, "whoisonline exists? failed: #{e.class} #{e.message}")
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def scan_keys(match:)
|
|
28
|
+
return enum_for(:scan_keys, match: match) unless block_given?
|
|
29
|
+
|
|
30
|
+
connection.scan_each(match: match) { |key| yield key }
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
log(:warn, "whoisonline scan failed: #{e.class} #{e.message}")
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def log(level, message)
|
|
39
|
+
logger = @configuration.logger
|
|
40
|
+
return unless logger
|
|
41
|
+
|
|
42
|
+
logger.public_send(level, message)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "concurrent/hash"
|
|
2
|
+
|
|
3
|
+
module WhoIsOnline
|
|
4
|
+
class Tracker
|
|
5
|
+
def initialize(configuration, redis_store)
|
|
6
|
+
@configuration = configuration
|
|
7
|
+
@redis_store = redis_store
|
|
8
|
+
@last_write_by_user = Concurrent::Hash.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def track(user)
|
|
12
|
+
uid = extract_id(user)
|
|
13
|
+
return unless uid
|
|
14
|
+
return if throttled?(uid)
|
|
15
|
+
|
|
16
|
+
key = presence_key(uid)
|
|
17
|
+
now = Time.now.to_i
|
|
18
|
+
result = @redis_store.write_presence(key, now, ttl_seconds)
|
|
19
|
+
@last_write_by_user[uid] = Time.now if result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def online?(user)
|
|
23
|
+
uid = extract_id(user)
|
|
24
|
+
return false unless uid
|
|
25
|
+
|
|
26
|
+
@redis_store.exists?(presence_key(uid))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def count
|
|
30
|
+
user_ids.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def user_ids
|
|
34
|
+
keys = @redis_store.scan_keys(match: "#{@configuration.namespace}:*")
|
|
35
|
+
keys.lazy.map { |key| key.split(":").last }.to_a
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def users(model_class)
|
|
39
|
+
model_class.where(id: user_ids)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def extract_id(user)
|
|
45
|
+
return nil unless user
|
|
46
|
+
return user if user.is_a?(String) || user.is_a?(Numeric)
|
|
47
|
+
return user.public_send(@configuration.user_id_method) if user.respond_to?(@configuration.user_id_method)
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def presence_key(uid)
|
|
53
|
+
"#{@configuration.namespace}:#{uid}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ttl_seconds
|
|
57
|
+
@configuration.ttl.to_i
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def throttled?(uid)
|
|
61
|
+
return false unless @configuration.throttle
|
|
62
|
+
|
|
63
|
+
last = @last_write_by_user[uid]
|
|
64
|
+
return false unless last
|
|
65
|
+
|
|
66
|
+
(Time.now - last) < @configuration.throttle
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
data/lib/whoisonline.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_support/core_ext/numeric/time"
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
|
|
5
|
+
require_relative "whoisonline/version"
|
|
6
|
+
require_relative "whoisonline/configuration"
|
|
7
|
+
require_relative "whoisonline/redis_store"
|
|
8
|
+
require_relative "whoisonline/tracker"
|
|
9
|
+
require_relative "whoisonline/engine"
|
|
10
|
+
|
|
11
|
+
module WhoIsOnline
|
|
12
|
+
class << self
|
|
13
|
+
delegate :track, :online?, :count, :user_ids, :users, to: :tracker
|
|
14
|
+
|
|
15
|
+
def tracker
|
|
16
|
+
@_tracker ||= Tracker.new(configuration, redis_store)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def redis_store
|
|
20
|
+
@_redis_store ||= RedisStore.new(configuration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configuration
|
|
24
|
+
@configuration ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure
|
|
28
|
+
yield(configuration)
|
|
29
|
+
configuration
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
data/logowho.png
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: whoisonline
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kapil Dev Pal
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-12-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: railties
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '7.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: redis
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '4.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '4.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: concurrent-ruby
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.2'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.2'
|
|
69
|
+
description: Production-ready Rails 7/8 online presence tracking using Redis TTL and
|
|
70
|
+
controller auto-hook.
|
|
71
|
+
email:
|
|
72
|
+
- dev.kapildevpal@gmail.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/whoisonline.rb
|
|
79
|
+
- lib/whoisonline/configuration.rb
|
|
80
|
+
- lib/whoisonline/controller.rb
|
|
81
|
+
- lib/whoisonline/engine.rb
|
|
82
|
+
- lib/whoisonline/redis_store.rb
|
|
83
|
+
- lib/whoisonline/tracker.rb
|
|
84
|
+
- lib/whoisonline/version.rb
|
|
85
|
+
- logowho.png
|
|
86
|
+
homepage: https://github.com/KapilDevPal/WhoIsOnline
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
homepage_uri: https://github.com/KapilDevPal/WhoIsOnline
|
|
91
|
+
source_code_uri: https://github.com/KapilDevPal/WhoIsOnline
|
|
92
|
+
changelog_uri: https://github.com/KapilDevPal/WhoIsOnline/blob/main/CHANGELOG.md
|
|
93
|
+
bug_tracker_uri: https://github.com/KapilDevPal/WhoIsOnline/issues
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
post_install_message:
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- lib
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.1'
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 3.4.19
|
|
111
|
+
signing_key:
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: Redis-backed online presence for Rails without database writes
|
|
114
|
+
test_files: []
|