Skip to content

Commit b14601b

Browse files
committed
feat: add user metrics
1 parent 596b2a2 commit b14601b

3 files changed

Lines changed: 326 additions & 0 deletions

File tree

synapse/metrics/common_usage_metrics.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,44 @@
3737
labelnames=[SERVER_NAME_LABEL],
3838
)
3939

40+
# Gauge for users
41+
users_in_status_gauge = Gauge(
42+
"synapse_user_count",
43+
"Number of users in active, deactivated, suspended, and locked status",
44+
["status", SERVER_NAME_LABEL],
45+
)
46+
47+
users_in_time_ranges_gauge = Gauge(
48+
"synapse_active_users",
49+
"Number of active users in time ranges in 24h, 7d, and 30d",
50+
["time_range", SERVER_NAME_LABEL],
51+
)
52+
53+
# We may want to add additional ranges in the future.
54+
retained_users_gauge = Gauge(
55+
"synapse_retained_users",
56+
"Number of retained users in 30d",
57+
["time_range", SERVER_NAME_LABEL],
58+
)
59+
4060

4161
@attr.s(auto_attribs=True)
4262
class CommonUsageMetrics:
4363
"""Usage metrics shared between the phone home stats and the prometheus exporter."""
4464

65+
# active users in time ranges
4566
daily_active_users: int
67+
weekly_active_users: int
68+
monthly_active_users: int
69+
70+
# user counts in different states
71+
active_users: int
72+
deactivated_users: int
73+
suspended_users: int
74+
locked_users: int
75+
76+
# retained users in time ranges
77+
monthly_retained_users: int
4678

4779

4880
class CommonUsageMetricsManager:
@@ -82,9 +114,20 @@ async def _collect(self) -> CommonUsageMetrics:
82114
use if it doesn't exist yet, or update it.
83115
"""
84116
dau_count = await self._store.count_daily_users()
117+
wau_count = await self._store.count_weekly_users()
118+
mau_count = await self._store.count_monthly_users()
119+
120+
user_metric = await self._store.get_user_count_per_status()
85121

86122
return CommonUsageMetrics(
87123
daily_active_users=dau_count,
124+
weekly_active_users=wau_count,
125+
monthly_active_users=mau_count,
126+
active_users=user_metric.get("active", 0),
127+
deactivated_users=user_metric.get("deactivated", 0),
128+
suspended_users=user_metric.get("suspended", 0),
129+
locked_users=user_metric.get("locked", 0),
130+
monthly_retained_users=user_metric.get("monthly_retained", 0),
88131
)
89132

90133
async def _update_gauges(self) -> None:
@@ -94,3 +137,28 @@ async def _update_gauges(self) -> None:
94137
current_dau_gauge.labels(
95138
**{SERVER_NAME_LABEL: self.server_name},
96139
).set(float(metrics.daily_active_users))
140+
141+
time_range_to_metric = {
142+
"24h": metrics.daily_active_users,
143+
"7d": metrics.weekly_active_users,
144+
"30d": metrics.monthly_active_users,
145+
}
146+
for time_range, _metric in time_range_to_metric.items():
147+
users_in_time_ranges_gauge.labels(
148+
time_range=time_range, **{SERVER_NAME_LABEL: self.server_name}
149+
).set(float(_metric))
150+
151+
status_to_metric = {
152+
"active": metrics.active_users,
153+
"deactivated": metrics.deactivated_users,
154+
"suspended": metrics.suspended_users,
155+
"locked": metrics.locked_users,
156+
}
157+
for status, _metric in status_to_metric.items():
158+
users_in_status_gauge.labels(
159+
status=status, **{SERVER_NAME_LABEL: self.server_name}
160+
).set(float(_metric))
161+
162+
retained_users_gauge.labels(
163+
time_range="30d", **{SERVER_NAME_LABEL: self.server_name}
164+
).set(float(metrics.monthly_retained_users))

synapse/storage/databases/main/metrics.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,15 @@ async def count_daily_users(self) -> int:
229229
"count_daily_users", self._count_users, yesterday
230230
)
231231

232+
async def count_weekly_users(self) -> int:
233+
"""
234+
Counts the number of users who used this homeserver in the last 7 days.
235+
"""
236+
seven_days_ago = int(self._clock.time_msec()) - (1000 * 60 * 60 * 24 * 7)
237+
return await self.db_pool.runInteraction(
238+
"count_weekly_users", self._count_users, seven_days_ago
239+
)
240+
232241
async def count_monthly_users(self) -> int:
233242
"""
234243
Counts the number of users who used this homeserver in the last 30 days.
@@ -473,3 +482,57 @@ def _generate_user_daily_visits(txn: LoggingTransaction) -> None:
473482
await self.db_pool.runInteraction(
474483
"generate_user_daily_visits", _generate_user_daily_visits
475484
)
485+
486+
async def get_user_count_per_status(self) -> Dict[str, int]:
487+
def _get_user_count_per_status(txn: LoggingTransaction) -> Dict[str, int]:
488+
sql = """
489+
SELECT
490+
SUM(CASE WHEN deactivated = 0 AND locked = FALSE AND suspended = FALSE THEN 1 ELSE 0 END) AS active_users,
491+
SUM(CASE WHEN deactivated = 1 THEN 1 ELSE 0 END) AS deactivated_users,
492+
SUM(CASE WHEN suspended = TRUE THEN 1 ELSE 0 END) AS suspended_users,
493+
SUM(CASE WHEN locked = TRUE THEN 1 ELSE 0 END) AS locked_users
494+
FROM users;
495+
"""
496+
txn.execute(sql)
497+
row = txn.fetchone()
498+
if not row:
499+
logger.warning("No results from get_user_count_per_status")
500+
metrics = {
501+
"active": row[0] if row is not None else 0,
502+
"deactivated": row[1] if row is not None else 0,
503+
"suspended": row[2] if row is not None else 0,
504+
"locked": row[3] if row is not None else 0,
505+
}
506+
507+
sql = """
508+
SELECT COUNT(*)
509+
FROM (
510+
SELECT user_id
511+
FROM user_daily_visits
512+
WHERE timestamp > ? AND timestamp < ?
513+
GROUP BY user_id
514+
HAVING max(timestamp) - min(timestamp) > ?
515+
) AS r30_users;
516+
"""
517+
thirty_days_in_ms = 86400 * 30 * 1000
518+
now_ms = int(self._clock.time()) * 1000
519+
sixty_days_ago_in_ms = now_ms - 2 * thirty_days_in_ms
520+
one_day_from_now_in_ms = now_ms + (86400 * 1000)
521+
txn.execute(
522+
sql,
523+
(
524+
sixty_days_ago_in_ms,
525+
one_day_from_now_in_ms,
526+
thirty_days_in_ms,
527+
),
528+
)
529+
(count,) = cast(Tuple[int], txn.fetchone())
530+
if not count:
531+
logger.warning("No results from get_user_count_per_status")
532+
metrics["monthly_retained"] = 0
533+
metrics["monthly_retained"] = count
534+
return metrics
535+
536+
return await self.db_pool.runInteraction(
537+
"get_user_count_per_status", _get_user_count_per_status
538+
)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
from twisted.test.proto_helpers import MemoryReactor
2+
3+
from synapse.rest import admin, login, register, room
4+
from synapse.server import HomeServer
5+
from synapse.types import create_requester
6+
from synapse.util import Clock
7+
8+
from tests.unittest import FederatingHomeserverTestCase
9+
10+
11+
class CommonUsageMetricsManagerTestCase(FederatingHomeserverTestCase):
12+
"""
13+
Tests for the CommonUsageMetricsManager.
14+
"""
15+
16+
servlets = [
17+
admin.register_servlets,
18+
admin.register_servlets_for_client_rest_resource,
19+
room.register_servlets,
20+
register.register_servlets,
21+
login.register_servlets,
22+
]
23+
24+
def prepare(
25+
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
26+
) -> None:
27+
self.manager = homeserver.get_common_usage_metrics_manager()
28+
self.admin_user = self.register_user("admin", "pass", admin=True)
29+
self.admin_token = self.login(self.admin_user, "pass")
30+
31+
def _create_active_user(self, prefix: str, i: int) -> str:
32+
"""
33+
Create given number of active users.
34+
"""
35+
username = "%s_active_user_%d" % (prefix, i)
36+
self.register_user(
37+
username=username,
38+
password="test",
39+
)
40+
user_tok = self.login(username=username, password="test")
41+
room_id = self.helper.create_room_as(room_creator=username, tok=user_tok)
42+
self.helper.send(room_id, "message", tok=user_tok)
43+
return user_tok
44+
45+
def test_users_in_status_gauge_update(self) -> None:
46+
"""
47+
Test that the users_in_status_gauge updates correctly.
48+
"""
49+
metrics = self.get_success(self.manager.get_metrics())
50+
51+
# Check initial values
52+
self.assertEqual(metrics.active_users, 1) # 1 admin
53+
self.assertEqual(metrics.deactivated_users, 0)
54+
self.assertEqual(metrics.suspended_users, 0)
55+
self.assertEqual(metrics.locked_users, 0)
56+
57+
# Create an active user
58+
self._create_active_user("t", 1)
59+
60+
# Create a deactivated user
61+
user_mxid = self.register_user(
62+
username="deactivated_user",
63+
password="test",
64+
)
65+
self.login(username=user_mxid, password="test")
66+
deactivate_handler = self.hs.get_deactivate_account_handler()
67+
self.get_success(
68+
deactivate_handler.deactivate_account(
69+
user_mxid, erase_data=False, requester=create_requester(self.admin_user)
70+
)
71+
)
72+
73+
# Create a suspended user
74+
user_mxid = self.register_user(
75+
username="suspended_user",
76+
password="test",
77+
)
78+
self.login("suspended_user", "test")
79+
channel = self.make_request(
80+
"PUT",
81+
f"/_synapse/admin/v1/suspend/{user_mxid}",
82+
{"suspend": True},
83+
access_token=self.admin_token,
84+
)
85+
self.assertEqual(channel.code, 200)
86+
self.assertEqual(channel.json_body, {f"user_{user_mxid}_suspended": True})
87+
88+
# Create a locked user
89+
user_mxid = self.register_user(
90+
username="locked_user",
91+
password="test",
92+
)
93+
self.login(username=user_mxid, password="test")
94+
self.get_success(
95+
self.hs.get_datastores().main.set_user_locked_status(user_mxid, True)
96+
)
97+
98+
# Wait for the metrics to be updated
99+
self.reactor.advance(5 * 60)
100+
metrics = self.get_success(self.manager.get_metrics())
101+
102+
self.assertEqual(metrics.active_users, 2) # 1 admin and 1 active user
103+
self.assertEqual(metrics.deactivated_users, 1)
104+
self.assertEqual(metrics.suspended_users, 1)
105+
self.assertEqual(metrics.locked_users, 1)
106+
107+
def test_users_in_time_ranges_gauge_update(self) -> None:
108+
"""
109+
Test that the users_in_time_ranges_gauge updates correctly.
110+
"""
111+
metrics = self.get_success(self.manager.get_metrics())
112+
113+
# Check initial values
114+
self.assertEqual(metrics.daily_active_users, 0)
115+
self.assertEqual(metrics.weekly_active_users, 0)
116+
self.assertEqual(metrics.monthly_active_users, 0)
117+
118+
# Simulate active users per time range
119+
# create four monthly active users.
120+
for i in range(4):
121+
self._create_active_user("monthly", i)
122+
self.reactor.advance(60 * 60 * 24 * 5) # Simulate time passing by 5 days
123+
# create five weekly active users.
124+
for i in range(5):
125+
self._create_active_user("weekly", i)
126+
self.reactor.advance(60 * 60 * 24) # Simulate time passing by 1 day
127+
128+
# create five daily active users.
129+
for i in range(5):
130+
self._create_active_user("daily", i)
131+
self.reactor.advance(60 * 60) # Simulate time passing by 1 hour
132+
133+
channel = self.make_request(
134+
"GET",
135+
"/_synapse/admin/v2/users",
136+
access_token=self.admin_token,
137+
)
138+
self.assertEqual(200, channel.code)
139+
self.assertEqual(
140+
len(channel.json_body["users"]), 15
141+
) # 5 daily, 5 weekly, 4 monthly, 1 admin
142+
143+
# Wait for the metrics to be updated
144+
self.reactor.advance(5 * 60)
145+
metrics = self.get_success(self.manager.get_metrics())
146+
147+
self.assertEqual(metrics.daily_active_users, 6) # 5 daily + 1 admin
148+
self.assertEqual(
149+
metrics.weekly_active_users, 11
150+
) # 5 weekly + 5 daily + 1 admin
151+
self.assertEqual(
152+
metrics.monthly_active_users, 15
153+
) # 4 monthly + 5 weekly + 5 daily + 1 admin
154+
155+
def test_retained_users_gauge_update(self) -> None:
156+
"""
157+
Test that the retained users gauge updates correctly.
158+
"""
159+
# start the user_daily_visits table update loop
160+
self.clock.looping_call(
161+
self.hs.get_datastores().main.generate_user_daily_visits,
162+
5 * 60 * 1000,
163+
)
164+
metrics = self.get_success(self.manager.get_metrics())
165+
166+
# Check initial values
167+
self.assertEqual(metrics.monthly_retained_users, 0)
168+
169+
# Simulate retained users
170+
for i in range(5):
171+
self._create_active_user("retained", i)
172+
173+
# Give time for user_daily_visits table to be updated.
174+
self.reactor.advance(60 * 5)
175+
176+
# Simulate time passing by 31 days
177+
self.reactor.advance(60 * 60 * 24 * 31)
178+
179+
for i in range(5):
180+
user_tok = self.login(
181+
username="retained_active_user_%s" % i, password="test"
182+
)
183+
room_id = self.helper.create_room_as(
184+
room_creator="retained_active_user_%s" % i, tok=user_tok
185+
)
186+
self.helper.send(room_id, "new message", tok=user_tok)
187+
188+
# Let another user_daily_visits update occur
189+
self.reactor.advance(60 * 5)
190+
191+
# Wait for the metrics to be updated
192+
self.reactor.advance(5 * 60)
193+
metrics = self.get_success(self.manager.get_metrics())
194+
195+
self.assertEqual(metrics.monthly_retained_users, 5)

0 commit comments

Comments
 (0)