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

1from datetime import timedelta 

2 

3import pytest 

4from google.protobuf import empty_pb2 

5from sqlalchemy import update 

6 

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 

23 

24 

25@pytest.fixture(autouse=True) 

26def _(testconfig): 

27 pass 

28 

29 

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") 

36 

37 

38def _populate_single_pass(): 

39 for f in _set_hacky_multi_gauges_funcs: 

40 f() 

41 

42 

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 } 

47 

48 

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 } 

53 

54 

55def test_users_per_community_gauge(db): 

56 user1, _ = generate_user() 

57 user2, _ = generate_user() 

58 user3, _ = generate_user() 

59 

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) 

66 

67 # the gauge reads from the cluster_subscription_counts materialized view 

68 refresh_materialized_views(empty_pb2.Empty()) 

69 

70 _populate(users_per_community_gauge) 

71 values = _sample_values(users_per_community_gauge) 

72 

73 assert values["World"] == 1 

74 assert values["Macroregion"] == 1 

75 assert values["Region"] == 2 

76 assert "Subregion" not in values 

77 

78 

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 

93 

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)) 

97 

98 _populate_single_pass() 

99 values = _sample_values(active_users_by_recency_gauge) 

100 

101 for bucket in ages: 

102 assert values[bucket] == 1 

103 

104 

105def test_active_users_by_recency_gauge_emits_empty_buckets(db): 

106 generate_user() 

107 

108 _populate_single_pass() 

109 values = _sample_values(active_users_by_recency_gauge) 

110 

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 

115 

116 

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) 

122 

123 _populate_single_pass() 

124 values = _users_gauge_values() 

125 

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 

135 

136 

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) 

143 

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")) 

147 

148 _populate_single_pass() 

149 

150 assert _users_gauge_values()["couchers_users_completed_profile"] == 2 

151 

152 

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)) 

156 

157 

158def _platform_metrics() -> dict[str, int]: 

159 with session_scope() as session: 

160 return dict(session.execute(active_users_by_platform_statement()).one()._mapping) 

161 

162 

163def _gauge_value(gauge) -> float: 

164 return float(next(sample.value for metric in gauge.collect() for sample in metric.samples)) 

165 

166 

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() 

173 

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) 

179 

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 } 

190 

191 

192def test_active_users_by_platform_excludes_invisible_users(db): 

193 visible_user, _ = generate_user() 

194 deleted_user, _ = generate_user(delete_user=True) 

195 

196 _add_activity(visible_user.id, ClientPlatform.web_desktop) 

197 _add_activity(deleted_user.id, ClientPlatform.web_desktop) 

198 

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 } 

207 

208 

209def test_active_users_by_platform_counts_user_once_per_platform(db): 

210 user, _ = generate_user() 

211 

212 _add_activity(user.id, ClientPlatform.web_desktop) 

213 _add_activity(user.id, ClientPlatform.app_ios) 

214 

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 } 

225 

226 

227def test_active_users_by_platform_excludes_old_activity(db): 

228 user, _ = generate_user() 

229 

230 _add_activity(user.id, ClientPlatform.app_ios, age=timedelta(days=2)) 

231 

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 } 

240 

241 

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() 

247 

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) 

252 

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)