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

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 

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

31 

32 

33def _populate_single_pass(): 

34 for f in _set_hacky_multi_gauges_funcs: 

35 f() 

36 

37 

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 } 

42 

43 

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 } 

48 

49 

50def test_users_per_community_gauge(db): 

51 user1, _ = generate_user() 

52 user2, _ = generate_user() 

53 user3, _ = generate_user() 

54 

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) 

61 

62 # the gauge reads from the cluster_subscription_counts materialized view 

63 refresh_materialized_views(empty_pb2.Empty()) 

64 

65 _populate(users_per_community_gauge) 

66 values = _sample_values(users_per_community_gauge) 

67 

68 assert values["World"] == 1 

69 assert values["Macroregion"] == 1 

70 assert values["Region"] == 2 

71 assert "Subregion" not in values 

72 

73 

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 

88 

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

92 

93 _populate_single_pass() 

94 values = _sample_values(active_users_by_recency_gauge) 

95 

96 for bucket in ages: 

97 assert values[bucket] == 1 

98 

99 

100def test_active_users_by_recency_gauge_emits_empty_buckets(db): 

101 generate_user() 

102 

103 _populate_single_pass() 

104 values = _sample_values(active_users_by_recency_gauge) 

105 

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 

110 

111 

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) 

117 

118 _populate_single_pass() 

119 values = _users_gauge_values() 

120 

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 

130 

131 

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) 

138 

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

142 

143 _populate_single_pass() 

144 

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

146 

147 

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

151 

152 

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

154 with session_scope() as session: 

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

156 

157 

158def _gauge_value(gauge) -> float: 

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

160 

161 

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

168 

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) 

174 

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 } 

185 

186 

187def test_active_users_by_platform_excludes_invisible_users(db): 

188 visible_user, _ = generate_user() 

189 deleted_user, _ = generate_user(delete_user=True) 

190 

191 _add_activity(visible_user.id, ClientPlatform.web_desktop) 

192 _add_activity(deleted_user.id, ClientPlatform.web_desktop) 

193 

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 } 

202 

203 

204def test_active_users_by_platform_counts_user_once_per_platform(db): 

205 user, _ = generate_user() 

206 

207 _add_activity(user.id, ClientPlatform.web_desktop) 

208 _add_activity(user.id, ClientPlatform.app_ios) 

209 

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 } 

220 

221 

222def test_active_users_by_platform_excludes_old_activity(db): 

223 user, _ = generate_user() 

224 

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

226 

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 } 

235 

236 

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

242 

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) 

247 

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)