Coverage for app/backend/src/tests/test_metrics.py: 99%
130 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 06:23 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 06:23 +0000
1from datetime import timedelta
3import pytest
4from google.protobuf import empty_pb2
5from sqlalchemy import update
7from couchers.db import session_scope
8from couchers.materialized_views import refresh_materialized_views
9from couchers.metrics import (
10 _set_hacky_labeled_gauges_funcs,
11 _set_hacky_multi_gauges_funcs,
12 active_users_by_platform_gauge,
13 active_users_by_platform_statement,
14 active_users_by_recency_gauge,
15 active_users_mobile_fraction_gauge,
16 users_gauges,
17 users_per_community_gauge,
18)
19from couchers.models import ClientPlatform, HostingStatus, User, UserActivity
20from couchers.utils import now
21from tests.fixtures.db import generate_user
22from tests.test_communities import create_community
25def _populate(gauge):
26 for registered_gauge, f in _set_hacky_labeled_gauges_funcs: 26 ↛ 30line 26 didn't jump to line 30 because the loop on line 26 didn't complete
27 if registered_gauge is gauge:
28 f(registered_gauge)
29 return
30 raise AssertionError("gauge is not a registered labeled gauge")
33def _populate_single_pass():
34 for f in _set_hacky_multi_gauges_funcs:
35 f()
38def _sample_values(gauge):
39 return {
40 sample.labels[gauge._labelnames[0]]: sample.value for metric in gauge.collect() for sample in metric.samples
41 }
44def _users_gauge_values():
45 return {
46 sample.name: sample.value for gauge in users_gauges for metric in gauge.collect() for sample in metric.samples
47 }
50def test_users_per_community_gauge(db):
51 user1, _ = generate_user()
52 user2, _ = generate_user()
53 user3, _ = generate_user()
55 with session_scope() as session:
56 world = create_community(session, 0, 100, "World", [user1], [], None)
57 macroregion = create_community(session, 0, 50, "Macroregion", [user2], [], world)
58 region = create_community(session, 0, 25, "Region", [user3], [user1], macroregion)
59 # subregion is below the region level and must be excluded
60 create_community(session, 0, 10, "Subregion", [user2], [user3], region)
62 # the gauge reads from the cluster_subscription_counts materialized view
63 refresh_materialized_views(empty_pb2.Empty())
65 _populate(users_per_community_gauge)
66 values = _sample_values(users_per_community_gauge)
68 assert values["World"] == 1
69 assert values["Macroregion"] == 1
70 assert values["Region"] == 2
71 assert "Subregion" not in values
74def test_active_users_by_recency_gauge(db):
75 ages = {
76 "<1d": timedelta(hours=2),
77 "1d-1w": timedelta(days=3),
78 "1w-1m": timedelta(days=14),
79 "1m-6m": timedelta(days=60),
80 "6m-12m": timedelta(days=250),
81 "12m-24m": timedelta(days=500),
82 "24m+": timedelta(days=800),
83 }
84 user_ids_by_bucket = {}
85 for bucket in ages:
86 user, _ = generate_user()
87 user_ids_by_bucket[bucket] = user.id
89 with session_scope() as session:
90 for bucket, age in ages.items():
91 session.execute(update(User).where(User.id == user_ids_by_bucket[bucket]).values(last_active=now() - age))
93 _populate_single_pass()
94 values = _sample_values(active_users_by_recency_gauge)
96 for bucket in ages:
97 assert values[bucket] == 1
100def test_active_users_by_recency_gauge_emits_empty_buckets(db):
101 generate_user()
103 _populate_single_pass()
104 values = _sample_values(active_users_by_recency_gauge)
106 # every bucket is always emitted, even the ones with no users in them
107 assert set(values) == {"<1d", "1d-1w", "1w-1m", "1m-6m", "6m-12m", "12m-24m", "24m+"}
108 assert values["<1d"] == 1
109 assert values["24m+"] == 0
112def test_users_gauges(db):
113 generate_user(gender="Man", hosting_status=HostingStatus.can_host)
114 generate_user(gender="Woman", hosting_status=HostingStatus.cant_host)
115 generate_user(gender="Non-binary", hosting_status=HostingStatus.maybe)
116 generate_user(gender="Woman", hosting_status=HostingStatus.can_host, delete_user=True)
118 _populate_single_pass()
119 values = _users_gauge_values()
121 # the deleted user is excluded from every count
122 assert values["couchers_users"] == 3
123 assert values["couchers_active_users_5m"] == 3
124 assert values["couchers_users_man"] == 1
125 assert values["couchers_users_woman"] == 1
126 assert values["couchers_users_nonbinary"] == 1
127 assert values["couchers_users_can_host"] == 1
128 assert values["couchers_users_cant_host"] == 1
129 assert values["couchers_users_maybe"] == 1
132def test_users_completed_profile_gauge(db):
133 generate_user()
134 generate_user()
135 short_about_me, _ = generate_user()
136 # a long enough about me isn't sufficient on its own, there has to be a photo in the profile gallery too
137 generate_user(complete_profile=False, about_me="x" * 150)
139 # ...and neither is a photo without a long enough about me
140 with session_scope() as session:
141 session.execute(update(User).where(User.id == short_about_me.id).values(about_me="too short"))
143 _populate_single_pass()
145 assert _users_gauge_values()["couchers_users_completed_profile"] == 2
148def _add_activity(user_id: int, client_platform: ClientPlatform | None, age: timedelta = timedelta(minutes=1)) -> None:
149 with session_scope() as session:
150 session.add(UserActivity(user_id=user_id, period=now() - age, client_platform=client_platform, api_calls=1))
153def _platform_metrics() -> dict[str, int]:
154 with session_scope() as session:
155 return dict(session.execute(active_users_by_platform_statement()).one()._mapping)
158def _gauge_value(gauge) -> float:
159 return float(next(sample.value for metric in gauge.collect() for sample in metric.samples))
162def test_active_users_by_platform(db):
163 web_desktop_user, _ = generate_user()
164 web_mobile_user, _ = generate_user()
165 ios_user, _ = generate_user()
166 android_user, _ = generate_user()
167 other_user, _ = generate_user()
169 _add_activity(web_desktop_user.id, ClientPlatform.web_desktop)
170 _add_activity(web_mobile_user.id, ClientPlatform.web_mobile)
171 _add_activity(ios_user.id, ClientPlatform.app_ios)
172 _add_activity(android_user.id, ClientPlatform.app_android)
173 _add_activity(other_user.id, None)
175 # the "other" (null platform) user is counted in the total but in no platform bucket, and mobile is the union of
176 # web_mobile + app_ios + app_android
177 assert _platform_metrics() == {
178 "total": 5,
179 "mobile": 3,
180 "web_desktop": 1,
181 "web_mobile": 1,
182 "app_ios": 1,
183 "app_android": 1,
184 }
187def test_active_users_by_platform_excludes_invisible_users(db):
188 visible_user, _ = generate_user()
189 deleted_user, _ = generate_user(delete_user=True)
191 _add_activity(visible_user.id, ClientPlatform.web_desktop)
192 _add_activity(deleted_user.id, ClientPlatform.web_desktop)
194 assert _platform_metrics() == {
195 "total": 1,
196 "mobile": 0,
197 "web_desktop": 1,
198 "web_mobile": 0,
199 "app_ios": 0,
200 "app_android": 0,
201 }
204def test_active_users_by_platform_counts_user_once_per_platform(db):
205 user, _ = generate_user()
207 _add_activity(user.id, ClientPlatform.web_desktop)
208 _add_activity(user.id, ClientPlatform.app_ios)
210 # the user is counted once in the total and once in mobile (they had app_ios activity), but appears in both the
211 # web_desktop and app_ios breakdowns
212 assert _platform_metrics() == {
213 "total": 1,
214 "mobile": 1,
215 "web_desktop": 1,
216 "web_mobile": 0,
217 "app_ios": 1,
218 "app_android": 0,
219 }
222def test_active_users_by_platform_excludes_old_activity(db):
223 user, _ = generate_user()
225 _add_activity(user.id, ClientPlatform.app_ios, age=timedelta(days=2))
227 assert _platform_metrics() == {
228 "total": 0,
229 "mobile": 0,
230 "web_desktop": 0,
231 "web_mobile": 0,
232 "app_ios": 0,
233 "app_android": 0,
234 }
237def test_active_users_mobile_fraction_gauge(db):
238 desktop_user, _ = generate_user()
239 mobile_web_user, _ = generate_user()
240 ios_user, _ = generate_user()
241 android_user, _ = generate_user()
243 _add_activity(desktop_user.id, ClientPlatform.web_desktop)
244 _add_activity(mobile_web_user.id, ClientPlatform.web_mobile)
245 _add_activity(ios_user.id, ClientPlatform.app_ios)
246 _add_activity(android_user.id, ClientPlatform.app_android)
248 # populating the breakdown gauge also sets the mobile fraction gauge: 3 of 4 active users are on mobile
249 _populate(active_users_by_platform_gauge)
250 assert _gauge_value(active_users_mobile_fraction_gauge) == pytest.approx(0.75)