Coverage for app/backend/src/tests/test_metrics.py: 98%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-06 00:27 +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 active_users_by_platform_gauge, 

12 active_users_by_platform_statement, 

13 active_users_by_recency_gauge, 

14 active_users_mobile_fraction_gauge, 

15 users_per_community_gauge, 

16) 

17from couchers.models import ClientPlatform, User, UserActivity 

18from couchers.utils import now 

19from tests.fixtures.db import generate_user 

20from tests.test_communities import create_community 

21 

22 

23@pytest.fixture(autouse=True) 

24def _(testconfig): 

25 pass 

26 

27 

28def _populate(gauge): 

29 for registered_gauge, f in _set_hacky_labeled_gauges_funcs: 29 ↛ 33line 29 didn't jump to line 33 because the loop on line 29 didn't complete

30 if registered_gauge is gauge: 

31 f(registered_gauge) 

32 return 

33 raise AssertionError("gauge is not a registered labeled gauge") 

34 

35 

36def _sample_values(gauge): 

37 return { 

38 sample.labels[gauge._labelnames[0]]: sample.value for metric in gauge.collect() for sample in metric.samples 

39 } 

40 

41 

42def test_users_per_community_gauge(db): 

43 user1, _ = generate_user() 

44 user2, _ = generate_user() 

45 user3, _ = generate_user() 

46 

47 with session_scope() as session: 

48 world = create_community(session, 0, 100, "World", [user1], [], None) 

49 macroregion = create_community(session, 0, 50, "Macroregion", [user2], [], world) 

50 region = create_community(session, 0, 25, "Region", [user3], [user1], macroregion) 

51 # subregion is below the region level and must be excluded 

52 create_community(session, 0, 10, "Subregion", [user2], [user3], region) 

53 

54 # the gauge reads from the cluster_subscription_counts materialized view 

55 refresh_materialized_views(empty_pb2.Empty()) 

56 

57 _populate(users_per_community_gauge) 

58 values = _sample_values(users_per_community_gauge) 

59 

60 assert values["World"] == 1 

61 assert values["Macroregion"] == 1 

62 assert values["Region"] == 2 

63 assert "Subregion" not in values 

64 

65 

66def test_active_users_by_recency_gauge(db): 

67 ages = { 

68 "<1d": timedelta(hours=2), 

69 "1d-1w": timedelta(days=3), 

70 "1w-1m": timedelta(days=14), 

71 "1m-6m": timedelta(days=60), 

72 "6m-12m": timedelta(days=250), 

73 "12m-24m": timedelta(days=500), 

74 "24m+": timedelta(days=800), 

75 } 

76 user_ids_by_bucket = {} 

77 for bucket in ages: 

78 user, _ = generate_user() 

79 user_ids_by_bucket[bucket] = user.id 

80 

81 with session_scope() as session: 

82 for bucket, age in ages.items(): 

83 session.execute(update(User).where(User.id == user_ids_by_bucket[bucket]).values(last_active=now() - age)) 

84 

85 _populate(active_users_by_recency_gauge) 

86 values = _sample_values(active_users_by_recency_gauge) 

87 

88 for bucket in ages: 

89 assert values[bucket] == 1 

90 

91 

92def _add_activity(user_id: int, client_platform: ClientPlatform | None, age: timedelta = timedelta(minutes=1)) -> None: 

93 with session_scope() as session: 

94 session.add(UserActivity(user_id=user_id, period=now() - age, client_platform=client_platform, api_calls=1)) 

95 

96 

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

98 with session_scope() as session: 

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

100 

101 

102def _gauge_value(gauge) -> float: 

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

104 

105 

106def test_active_users_by_platform(db): 

107 web_desktop_user, _ = generate_user() 

108 web_mobile_user, _ = generate_user() 

109 ios_user, _ = generate_user() 

110 android_user, _ = generate_user() 

111 other_user, _ = generate_user() 

112 

113 _add_activity(web_desktop_user.id, ClientPlatform.web_desktop) 

114 _add_activity(web_mobile_user.id, ClientPlatform.web_mobile) 

115 _add_activity(ios_user.id, ClientPlatform.app_ios) 

116 _add_activity(android_user.id, ClientPlatform.app_android) 

117 _add_activity(other_user.id, None) 

118 

119 # the "other" (null platform) user is counted in the total but in no platform bucket, and mobile is the union of 

120 # web_mobile + app_ios + app_android 

121 assert _platform_metrics() == { 

122 "total": 5, 

123 "mobile": 3, 

124 "web_desktop": 1, 

125 "web_mobile": 1, 

126 "app_ios": 1, 

127 "app_android": 1, 

128 } 

129 

130 

131def test_active_users_by_platform_excludes_invisible_users(db): 

132 visible_user, _ = generate_user() 

133 deleted_user, _ = generate_user(delete_user=True) 

134 

135 _add_activity(visible_user.id, ClientPlatform.web_desktop) 

136 _add_activity(deleted_user.id, ClientPlatform.web_desktop) 

137 

138 assert _platform_metrics() == { 

139 "total": 1, 

140 "mobile": 0, 

141 "web_desktop": 1, 

142 "web_mobile": 0, 

143 "app_ios": 0, 

144 "app_android": 0, 

145 } 

146 

147 

148def test_active_users_by_platform_counts_user_once_per_platform(db): 

149 user, _ = generate_user() 

150 

151 _add_activity(user.id, ClientPlatform.web_desktop) 

152 _add_activity(user.id, ClientPlatform.app_ios) 

153 

154 # the user is counted once in the total and once in mobile (they had app_ios activity), but appears in both the 

155 # web_desktop and app_ios breakdowns 

156 assert _platform_metrics() == { 

157 "total": 1, 

158 "mobile": 1, 

159 "web_desktop": 1, 

160 "web_mobile": 0, 

161 "app_ios": 1, 

162 "app_android": 0, 

163 } 

164 

165 

166def test_active_users_by_platform_excludes_old_activity(db): 

167 user, _ = generate_user() 

168 

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

170 

171 assert _platform_metrics() == { 

172 "total": 0, 

173 "mobile": 0, 

174 "web_desktop": 0, 

175 "web_mobile": 0, 

176 "app_ios": 0, 

177 "app_android": 0, 

178 } 

179 

180 

181def test_active_users_mobile_fraction_gauge(db): 

182 desktop_user, _ = generate_user() 

183 mobile_web_user, _ = generate_user() 

184 ios_user, _ = generate_user() 

185 android_user, _ = generate_user() 

186 

187 _add_activity(desktop_user.id, ClientPlatform.web_desktop) 

188 _add_activity(mobile_web_user.id, ClientPlatform.web_mobile) 

189 _add_activity(ios_user.id, ClientPlatform.app_ios) 

190 _add_activity(android_user.id, ClientPlatform.app_android) 

191 

192 # populating the breakdown gauge also sets the mobile fraction gauge: 3 of 4 active users are on mobile 

193 _populate(active_users_by_platform_gauge) 

194 assert _gauge_value(active_users_mobile_fraction_gauge) == pytest.approx(0.75)