Coverage for app/backend/src/tests/test_metrics.py: 99%
133 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 06:55 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 06:55 +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
25@pytest.fixture(autouse=True)
26def _(testconfig):
27 pass
30def _populate(gauge):
31 for registered_gauge, f in _set_hacky_labeled_gauges_funcs: 31 ↛ 35line 31 didn't jump to line 35 because the loop on line 31 didn't complete
32 if registered_gauge is gauge:
33 f(registered_gauge)
34 return
35 raise AssertionError("gauge is not a registered labeled gauge")
38def _populate_single_pass():
39 for f in _set_hacky_multi_gauges_funcs:
40 f()
43def _sample_values(gauge):
44 return {
45 sample.labels[gauge._labelnames[0]]: sample.value for metric in gauge.collect() for sample in metric.samples
46 }
49def _users_gauge_values():
50 return {
51 sample.name: sample.value for gauge in users_gauges for metric in gauge.collect() for sample in metric.samples
52 }
55def test_users_per_community_gauge(db):
56 user1, _ = generate_user()
57 user2, _ = generate_user()
58 user3, _ = generate_user()
60 with session_scope() as session:
61 world = create_community(session, 0, 100, "World", [user1], [], None)
62 macroregion = create_community(session, 0, 50, "Macroregion", [user2], [], world)
63 region = create_community(session, 0, 25, "Region", [user3], [user1], macroregion)
64 # subregion is below the region level and must be excluded
65 create_community(session, 0, 10, "Subregion", [user2], [user3], region)
67 # the gauge reads from the cluster_subscription_counts materialized view
68 refresh_materialized_views(empty_pb2.Empty())
70 _populate(users_per_community_gauge)
71 values = _sample_values(users_per_community_gauge)
73 assert values["World"] == 1
74 assert values["Macroregion"] == 1
75 assert values["Region"] == 2
76 assert "Subregion" not in values
79def test_active_users_by_recency_gauge(db):
80 ages = {
81 "<1d": timedelta(hours=2),
82 "1d-1w": timedelta(days=3),
83 "1w-1m": timedelta(days=14),
84 "1m-6m": timedelta(days=60),
85 "6m-12m": timedelta(days=250),
86 "12m-24m": timedelta(days=500),
87 "24m+": timedelta(days=800),
88 }
89 user_ids_by_bucket = {}
90 for bucket in ages:
91 user, _ = generate_user()
92 user_ids_by_bucket[bucket] = user.id
94 with session_scope() as session:
95 for bucket, age in ages.items():
96 session.execute(update(User).where(User.id == user_ids_by_bucket[bucket]).values(last_active=now() - age))
98 _populate_single_pass()
99 values = _sample_values(active_users_by_recency_gauge)
101 for bucket in ages:
102 assert values[bucket] == 1
105def test_active_users_by_recency_gauge_emits_empty_buckets(db):
106 generate_user()
108 _populate_single_pass()
109 values = _sample_values(active_users_by_recency_gauge)
111 # every bucket is always emitted, even the ones with no users in them
112 assert set(values) == {"<1d", "1d-1w", "1w-1m", "1m-6m", "6m-12m", "12m-24m", "24m+"}
113 assert values["<1d"] == 1
114 assert values["24m+"] == 0
117def test_users_gauges(db):
118 generate_user(gender="Man", hosting_status=HostingStatus.can_host)
119 generate_user(gender="Woman", hosting_status=HostingStatus.cant_host)
120 generate_user(gender="Non-binary", hosting_status=HostingStatus.maybe)
121 generate_user(gender="Woman", hosting_status=HostingStatus.can_host, delete_user=True)
123 _populate_single_pass()
124 values = _users_gauge_values()
126 # the deleted user is excluded from every count
127 assert values["couchers_users"] == 3
128 assert values["couchers_active_users_5m"] == 3
129 assert values["couchers_users_man"] == 1
130 assert values["couchers_users_woman"] == 1
131 assert values["couchers_users_nonbinary"] == 1
132 assert values["couchers_users_can_host"] == 1
133 assert values["couchers_users_cant_host"] == 1
134 assert values["couchers_users_maybe"] == 1
137def test_users_completed_profile_gauge(db):
138 generate_user()
139 generate_user()
140 short_about_me, _ = generate_user()
141 # a long enough about me isn't sufficient on its own, there has to be a photo in the profile gallery too
142 generate_user(complete_profile=False, about_me="x" * 150)
144 # ...and neither is a photo without a long enough about me
145 with session_scope() as session:
146 session.execute(update(User).where(User.id == short_about_me.id).values(about_me="too short"))
148 _populate_single_pass()
150 assert _users_gauge_values()["couchers_users_completed_profile"] == 2
153def _add_activity(user_id: int, client_platform: ClientPlatform | None, age: timedelta = timedelta(minutes=1)) -> None:
154 with session_scope() as session:
155 session.add(UserActivity(user_id=user_id, period=now() - age, client_platform=client_platform, api_calls=1))
158def _platform_metrics() -> dict[str, int]:
159 with session_scope() as session:
160 return dict(session.execute(active_users_by_platform_statement()).one()._mapping)
163def _gauge_value(gauge) -> float:
164 return float(next(sample.value for metric in gauge.collect() for sample in metric.samples))
167def test_active_users_by_platform(db):
168 web_desktop_user, _ = generate_user()
169 web_mobile_user, _ = generate_user()
170 ios_user, _ = generate_user()
171 android_user, _ = generate_user()
172 other_user, _ = generate_user()
174 _add_activity(web_desktop_user.id, ClientPlatform.web_desktop)
175 _add_activity(web_mobile_user.id, ClientPlatform.web_mobile)
176 _add_activity(ios_user.id, ClientPlatform.app_ios)
177 _add_activity(android_user.id, ClientPlatform.app_android)
178 _add_activity(other_user.id, None)
180 # the "other" (null platform) user is counted in the total but in no platform bucket, and mobile is the union of
181 # web_mobile + app_ios + app_android
182 assert _platform_metrics() == {
183 "total": 5,
184 "mobile": 3,
185 "web_desktop": 1,
186 "web_mobile": 1,
187 "app_ios": 1,
188 "app_android": 1,
189 }
192def test_active_users_by_platform_excludes_invisible_users(db):
193 visible_user, _ = generate_user()
194 deleted_user, _ = generate_user(delete_user=True)
196 _add_activity(visible_user.id, ClientPlatform.web_desktop)
197 _add_activity(deleted_user.id, ClientPlatform.web_desktop)
199 assert _platform_metrics() == {
200 "total": 1,
201 "mobile": 0,
202 "web_desktop": 1,
203 "web_mobile": 0,
204 "app_ios": 0,
205 "app_android": 0,
206 }
209def test_active_users_by_platform_counts_user_once_per_platform(db):
210 user, _ = generate_user()
212 _add_activity(user.id, ClientPlatform.web_desktop)
213 _add_activity(user.id, ClientPlatform.app_ios)
215 # the user is counted once in the total and once in mobile (they had app_ios activity), but appears in both the
216 # web_desktop and app_ios breakdowns
217 assert _platform_metrics() == {
218 "total": 1,
219 "mobile": 1,
220 "web_desktop": 1,
221 "web_mobile": 0,
222 "app_ios": 1,
223 "app_android": 0,
224 }
227def test_active_users_by_platform_excludes_old_activity(db):
228 user, _ = generate_user()
230 _add_activity(user.id, ClientPlatform.app_ios, age=timedelta(days=2))
232 assert _platform_metrics() == {
233 "total": 0,
234 "mobile": 0,
235 "web_desktop": 0,
236 "web_mobile": 0,
237 "app_ios": 0,
238 "app_android": 0,
239 }
242def test_active_users_mobile_fraction_gauge(db):
243 desktop_user, _ = generate_user()
244 mobile_web_user, _ = generate_user()
245 ios_user, _ = generate_user()
246 android_user, _ = generate_user()
248 _add_activity(desktop_user.id, ClientPlatform.web_desktop)
249 _add_activity(mobile_web_user.id, ClientPlatform.web_mobile)
250 _add_activity(ios_user.id, ClientPlatform.app_ios)
251 _add_activity(android_user.id, ClientPlatform.app_android)
253 # populating the breakdown gauge also sets the mobile fraction gauge: 3 of 4 active users are on mobile
254 _populate(active_users_by_platform_gauge)
255 assert _gauge_value(active_users_mobile_fraction_gauge) == pytest.approx(0.75)