where_is_waldo 0.1.3 → 0.1.4
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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +10 -0
- data/VERSION +1 -1
- data/app/channels/where_is_waldo/presence_channel.rb +27 -0
- data/app/services/where_is_waldo/adapters/base_adapter.rb +19 -0
- data/app/services/where_is_waldo/adapters/database_adapter.rb +11 -0
- data/app/services/where_is_waldo/adapters/redis_adapter.rb +20 -1
- data/app/services/where_is_waldo/presence_service.rb +9 -0
- data/app/services/where_is_waldo/roster.rb +12 -13
- data/lib/where_is_waldo/configuration.rb +18 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29833d47ae21a1aa5a1882e515152ddde9f7201eb9754cff81545495a0cd6d63
|
|
4
|
+
data.tar.gz: e7d41c790a9abbd4f3bdbd3ced6db38ce335629c189c0bd6404c40ce3874f82d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7634cd0688c8f56bb3292ae1f8c9db9146d1775552afb7f7801a5c898a97ab257bdbb80aa012c3a7ef79bfdd714bce7c1bb9a9c486d859e2f7bff5dd70891cc
|
|
7
|
+
data.tar.gz: 59335dc215e4666a577294fd9f383cd178102bf1c8f649b0bf30a738ec63be5078e0d541cb3f6d11b6ed9765b561fa45c17665fdc2fb4a8f8df23f59fc486815
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,53 @@
|
|
|
3
3
|
Notable changes to where_is_waldo. Format loosely follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
|
|
6
|
+
## 0.1.4
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Roster reads now route through the configured adapter.** Previously
|
|
11
|
+
`Roster.states_for` (which powers `state_for`, `snapshot`, `members_for`,
|
|
12
|
+
every roster delta) queried the `Presence` ActiveRecord model directly.
|
|
13
|
+
Under `adapter = :redis`, writes went to Redis but reads hit an empty (or
|
|
14
|
+
absent) database table — presence dots stayed grey no matter how many
|
|
15
|
+
heartbeats came in. `Roster` now calls `PresenceService.sessions_for_subjects`
|
|
16
|
+
which delegates to whichever adapter is configured, so writes and reads
|
|
17
|
+
share one store on every adapter.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `Adapters::Base#sessions_for_subjects(subject_ids, timeout:)` — bulk-read
|
|
22
|
+
live sessions grouped by subject id, with a correct-but-unoptimized default
|
|
23
|
+
implementation that fans out over `sessions_for_subject`. `DatabaseAdapter`
|
|
24
|
+
overrides it with a single bulk query (`Presence.where(subject_col => ids)`
|
|
25
|
+
+ `includes(:subject)`); `RedisAdapter` overrides with per-subject reads
|
|
26
|
+
filtered by heartbeat threshold. Timeout defaults to `config.timeout`.
|
|
27
|
+
- `PresenceService.sessions_for_subjects` — public delegate so callers stay
|
|
28
|
+
off the adapter directly.
|
|
29
|
+
- `Configuration#suppress_presence_proc` — callable that decides, per
|
|
30
|
+
connection, whether to skip presence registration for that subscriber.
|
|
31
|
+
Receives the ActionCable connection; returns truthy to suppress. A
|
|
32
|
+
suppressed subscriber still subscribes normally (streams from
|
|
33
|
+
`where_is_waldo:subject:<id>`, receives broadcasts, can invoke channel
|
|
34
|
+
actions) — they just don't register a Presence row / heartbeat / roster
|
|
35
|
+
transition. Use for cases where the WebSocket session is legitimate but
|
|
36
|
+
shouldn't be counted as "the subject is present" (e.g. support-user
|
|
37
|
+
impersonation tabs).
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
config.suppress_presence_proc = ->(connection) {
|
|
41
|
+
connection.request.session[:su_user].present?
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `Roster.aggregate` / `session_level` / `platform` now consume presence
|
|
48
|
+
hashes (`session[:tab_visible]`, `session[:metadata]`) — matching the
|
|
49
|
+
shape every adapter already returned — instead of ActiveRecord `Presence`
|
|
50
|
+
method calls. No behavior change for callers; existing per-device and
|
|
51
|
+
per-subject aggregation semantics preserved.
|
|
52
|
+
|
|
6
53
|
## 0.1.3
|
|
7
54
|
|
|
8
55
|
### Fixed
|
data/README.md
CHANGED
|
@@ -120,6 +120,16 @@ WhereIsWaldo.configure do |config|
|
|
|
120
120
|
config.roster_org = ->(user) { user.account }
|
|
121
121
|
config.roster_members = ->(org) { org.users.active }
|
|
122
122
|
|
|
123
|
+
# Optional: skip presence registration for specific connections while
|
|
124
|
+
# keeping them subscribed for broadcasts. Return truthy to suppress. The
|
|
125
|
+
# subscriber still streams from where_is_waldo:subject:<id> and receives
|
|
126
|
+
# WhereIsWaldo.broadcast_to messages — they just don't count as present.
|
|
127
|
+
# Typical use: support-user impersonation tabs shouldn't light up as the
|
|
128
|
+
# impersonated user in teammates' rosters.
|
|
129
|
+
config.suppress_presence_proc = ->(connection) {
|
|
130
|
+
connection.request.session[:su_user].present?
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
# Redis adapter
|
|
124
134
|
# config.redis_client = Redis.new(url: ENV["REDIS_URL"])
|
|
125
135
|
end
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.4
|
|
@@ -5,6 +5,13 @@ module WhereIsWaldo
|
|
|
5
5
|
def subscribed
|
|
6
6
|
stream_from subject_stream
|
|
7
7
|
|
|
8
|
+
# The subject_stream subscription above is unconditional: even when the
|
|
9
|
+
# subscriber's presence is suppressed (see suppress_presence_proc), the
|
|
10
|
+
# tab is a legitimate consumer of WhereIsWaldo.broadcast_to signaling
|
|
11
|
+
# and shouldn't be cut off from messages just because it doesn't count
|
|
12
|
+
# as "present."
|
|
13
|
+
return if presence_suppressed?
|
|
14
|
+
|
|
8
15
|
register_presence
|
|
9
16
|
|
|
10
17
|
# Seed the local transition gate, resolve the roster delivery strategy for
|
|
@@ -16,6 +23,8 @@ module WhereIsWaldo
|
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def unsubscribed
|
|
26
|
+
return if presence_suppressed?
|
|
27
|
+
|
|
19
28
|
WhereIsWaldo.disconnect(session_id: waldo_session_id)
|
|
20
29
|
|
|
21
30
|
# Recompute the subject's aggregate (they may still be present in another
|
|
@@ -24,6 +33,8 @@ module WhereIsWaldo
|
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def heartbeat(data)
|
|
36
|
+
return if presence_suppressed?
|
|
37
|
+
|
|
27
38
|
data = data.with_indifferent_access
|
|
28
39
|
|
|
29
40
|
tab_visible = data[:tab_visible] != false
|
|
@@ -48,6 +59,22 @@ module WhereIsWaldo
|
|
|
48
59
|
|
|
49
60
|
private
|
|
50
61
|
|
|
62
|
+
# Memoized per-subscription. Host apps configure suppress_presence_proc
|
|
63
|
+
# to gate WHICH subscriptions register presence — e.g. a support-user
|
|
64
|
+
# impersonation tab is a legitimate WS consumer but shouldn't count as
|
|
65
|
+
# the impersonated subject being "here." Nil proc → default behavior
|
|
66
|
+
# (presence always registered).
|
|
67
|
+
def presence_suppressed?
|
|
68
|
+
# Ivars are @wiw_-prefixed throughout this channel to avoid clobbering
|
|
69
|
+
# host-app ActionCable state; keep the prefix over the cop's rename.
|
|
70
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
71
|
+
return @wiw_presence_suppressed if defined?(@wiw_presence_suppressed)
|
|
72
|
+
|
|
73
|
+
proc = WhereIsWaldo.config.suppress_presence_proc
|
|
74
|
+
@wiw_presence_suppressed = proc ? !!proc.call(connection) : false
|
|
75
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
76
|
+
end
|
|
77
|
+
|
|
51
78
|
def register_presence
|
|
52
79
|
WhereIsWaldo.connect(
|
|
53
80
|
session_id: waldo_session_id,
|
|
@@ -44,6 +44,25 @@ module WhereIsWaldo
|
|
|
44
44
|
raise NotImplementedError
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Get live sessions for many subjects in one call, grouped by subject id.
|
|
48
|
+
# Subjects with no live sessions are omitted from the returned hash.
|
|
49
|
+
# Adapters should override for efficiency; this default is a correct but
|
|
50
|
+
# unoptimized fan-out over `sessions_for_subject`.
|
|
51
|
+
# @param subject_ids [Array<Integer/String>] Subject identifiers
|
|
52
|
+
# @param timeout [Integer, nil] Seconds threshold; sessions with
|
|
53
|
+
# `last_heartbeat` older than `Time.current - timeout` are excluded
|
|
54
|
+
# @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
|
|
55
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
56
|
+
ids = Array(subject_ids).compact.uniq
|
|
57
|
+
return {} if ids.empty?
|
|
58
|
+
|
|
59
|
+
threshold = Time.current - (timeout || default_timeout)
|
|
60
|
+
ids.each_with_object({}) do |sid, memo|
|
|
61
|
+
live = sessions_for_subject(sid).select { |s| s[:last_heartbeat] && s[:last_heartbeat] >= threshold }
|
|
62
|
+
memo[sid] = live if live.any?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
47
66
|
# Get session status
|
|
48
67
|
# @param session_id [String] Session identifier
|
|
49
68
|
# @return [Hash, nil] Presence record or nil
|
|
@@ -77,6 +77,17 @@ module WhereIsWaldo
|
|
|
77
77
|
scope.map(&:as_presence_hash)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
81
|
+
ids = Array(subject_ids).compact.uniq
|
|
82
|
+
return {} if ids.empty?
|
|
83
|
+
|
|
84
|
+
threshold = (timeout || default_timeout).seconds.ago
|
|
85
|
+
scope = Presence.where(subject_column => ids).where("last_heartbeat > ?", threshold)
|
|
86
|
+
scope = scope.includes(:subject) if config.subject_class_constant
|
|
87
|
+
scope.group_by { |row| row[subject_column] }
|
|
88
|
+
.transform_values { |rows| rows.map(&:as_presence_hash) }
|
|
89
|
+
end
|
|
90
|
+
|
|
80
91
|
def session_status(session_id)
|
|
81
92
|
scope = Presence.where(session_column => session_id)
|
|
82
93
|
scope = scope.includes(:subject) if config.subject_class_constant
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module WhereIsWaldo
|
|
4
4
|
module Adapters
|
|
5
|
-
|
|
5
|
+
# Redis backing needs more plumbing (keying, TTLs, pipelines) than the
|
|
6
|
+
# DB adapter, so it legitimately runs past the default class-length limit.
|
|
7
|
+
class RedisAdapter < BaseAdapter # rubocop:disable Metrics/ClassLength
|
|
6
8
|
def connect(session_id:, subject_id:, metadata: {})
|
|
7
9
|
now = Time.current.to_i
|
|
8
10
|
|
|
@@ -93,6 +95,23 @@ module WhereIsWaldo
|
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
|
|
98
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
99
|
+
ids = Array(subject_ids).compact.uniq
|
|
100
|
+
return {} if ids.empty?
|
|
101
|
+
|
|
102
|
+
threshold = Time.current.to_i - (timeout || default_timeout)
|
|
103
|
+
ids.each_with_object({}) do |sid, memo|
|
|
104
|
+
session_ids = redis.smembers(subject_sessions_key(sid))
|
|
105
|
+
live = session_ids.filter_map do |cid|
|
|
106
|
+
data = get_presence_data(cid)
|
|
107
|
+
next unless data && data["last_heartbeat"].to_i >= threshold
|
|
108
|
+
|
|
109
|
+
build_presence_hash(data)
|
|
110
|
+
end
|
|
111
|
+
memo[sid] = live if live.any?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
96
115
|
def session_status(session_id)
|
|
97
116
|
data = get_presence_data(session_id)
|
|
98
117
|
return nil unless data
|
|
@@ -74,6 +74,15 @@ module WhereIsWaldo
|
|
|
74
74
|
# @return [Array<Hash>] Presence records
|
|
75
75
|
delegate :sessions_for_subject, to: :adapter
|
|
76
76
|
|
|
77
|
+
# Get live sessions for many subjects in one call, grouped by subject id.
|
|
78
|
+
# Preferred over calling `sessions_for_subject` per id when the caller
|
|
79
|
+
# already has an id list (e.g. roster aggregation) — adapters can (and do)
|
|
80
|
+
# implement this as a bulk read.
|
|
81
|
+
# @param subject_ids [Array<Integer/String>] Subject identifiers
|
|
82
|
+
# @param timeout [Integer, nil] Seconds threshold (defaults to config.timeout)
|
|
83
|
+
# @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
|
|
84
|
+
delegate :sessions_for_subjects, to: :adapter
|
|
85
|
+
|
|
77
86
|
# Get status of a specific session
|
|
78
87
|
# @param session_id [String] Session identifier
|
|
79
88
|
# @return [Hash, nil] Presence record or nil
|
|
@@ -165,25 +165,24 @@ module WhereIsWaldo
|
|
|
165
165
|
|
|
166
166
|
private
|
|
167
167
|
|
|
168
|
-
# Aggregate state for many subjects in one
|
|
168
|
+
# Aggregate state for many subjects in one adapter call, keyed by
|
|
169
|
+
# subject id. Routes through PresenceService so it matches whichever
|
|
170
|
+
# adapter is configured — reading Presence directly would only work on
|
|
171
|
+
# `:database`, but the roster snapshot needs to see whatever the writer
|
|
172
|
+
# sees under `:redis` too.
|
|
169
173
|
def states_for(subject_ids, timeout: nil)
|
|
170
174
|
ids = Array(subject_ids).compact.uniq
|
|
171
175
|
return {} if ids.empty?
|
|
172
176
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
rows = Presence.where(subject_col => ids)
|
|
177
|
-
.where("last_heartbeat > ?", threshold)
|
|
178
|
-
.to_a
|
|
179
|
-
|
|
180
|
-
rows.group_by { |row| row[subject_col] }
|
|
181
|
-
.transform_values { |sessions| aggregate(sessions) }
|
|
177
|
+
WhereIsWaldo::PresenceService
|
|
178
|
+
.sessions_for_subjects(ids, timeout: timeout)
|
|
179
|
+
.transform_values { |sessions| aggregate(sessions) }
|
|
182
180
|
end
|
|
183
181
|
|
|
184
182
|
# Reduce a subject's live sessions to per-device statuses plus an overall
|
|
185
183
|
# roll-up. Sessions are grouped by platform (so several browser tabs form
|
|
186
184
|
# one "web" status); the overall status is the highest across platforms.
|
|
185
|
+
# @param sessions [Array<Hash>] presence hashes from the adapter
|
|
187
186
|
# @return [Hash] { status: "active", devices: { "web" => "active", ... } }
|
|
188
187
|
def aggregate(sessions)
|
|
189
188
|
devices = sessions.group_by { |s| platform(s) }
|
|
@@ -204,13 +203,13 @@ module WhereIsWaldo
|
|
|
204
203
|
# a hidden tab / backgrounded app is :background; a visible/foreground
|
|
205
204
|
# session is :active when working, else :idle.
|
|
206
205
|
def session_level(session)
|
|
207
|
-
return :background unless session
|
|
206
|
+
return :background unless session[:tab_visible]
|
|
208
207
|
|
|
209
|
-
session
|
|
208
|
+
session[:subject_active] ? :active : :idle
|
|
210
209
|
end
|
|
211
210
|
|
|
212
211
|
def platform(session)
|
|
213
|
-
meta = session
|
|
212
|
+
meta = session[:metadata]
|
|
214
213
|
value = meta && (meta["platform"] || meta[:platform])
|
|
215
214
|
(value.presence || DEFAULT_PLATFORM).to_s
|
|
216
215
|
end
|
|
@@ -32,6 +32,23 @@ module WhereIsWaldo
|
|
|
32
32
|
# :authenticate_proc - proc to authenticate connection, receives request
|
|
33
33
|
attr_accessor :channel_name, :authenticate_proc
|
|
34
34
|
|
|
35
|
+
# :suppress_presence_proc - callable that decides, per connection, whether
|
|
36
|
+
# to SKIP presence registration for that subscriber. Receives the
|
|
37
|
+
# ActionCable connection (host apps can read session/cookies/env off it,
|
|
38
|
+
# e.g. via `connection.request`). Returns truthy to suppress.
|
|
39
|
+
#
|
|
40
|
+
# A suppressed subscriber still subscribes normally — they receive
|
|
41
|
+
# broadcasts to `where_is_waldo:subject:<id>` (WhereIsWaldo.broadcast_to*
|
|
42
|
+
# signaling) and any other Waldo capability — they just don't register a
|
|
43
|
+
# Presence row / heartbeat / roster transition. Use for cases where the
|
|
44
|
+
# WebSocket session is legitimate but shouldn't be counted as "the subject
|
|
45
|
+
# is present", e.g. support-user impersonation tabs.
|
|
46
|
+
#
|
|
47
|
+
# config.suppress_presence_proc = ->(connection) {
|
|
48
|
+
# connection.request.session[:su_user].present?
|
|
49
|
+
# }
|
|
50
|
+
attr_accessor :suppress_presence_proc
|
|
51
|
+
|
|
35
52
|
# Default audience resolver for the Broadcastable concern. A lambda that,
|
|
36
53
|
# given a record, returns the AR scope to broadcast to (e.g. that record's
|
|
37
54
|
# account members). Set once per app to match its container, e.g.:
|
|
@@ -106,6 +123,7 @@ module WhereIsWaldo
|
|
|
106
123
|
# ActionCable defaults
|
|
107
124
|
@channel_name = "WhereIsWaldo::PresenceChannel"
|
|
108
125
|
@authenticate_proc = nil
|
|
126
|
+
@suppress_presence_proc = nil
|
|
109
127
|
|
|
110
128
|
# Broadcastable default audience (set per app)
|
|
111
129
|
@broadcast_audience = nil
|