Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Add a new version of the R30 phone-home metric, which removes a false impression of retention given by the old R30 metric #10332

Merged
merged 24 commits into from
Jul 19, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
899e7c4
Add initial R30v2 tests
reivilibre Jul 6, 2021
7a87cbe
Allow custom headers (e.g. User-Agent) in some test helpers
reivilibre Jul 6, 2021
e106b94
Add code to calculate R30v2
reivilibre Jul 7, 2021
98dcf2b
Tweak and add R30v2 tests
reivilibre Jul 7, 2021
8511e1a
Add R30v2 stats to the phone home data
reivilibre Jul 7, 2021
248966a
Newsfile
reivilibre Jul 7, 2021
39a3b8f
Explicitly report 0 when no clients of that type are present
reivilibre Jul 9, 2021
2eda2a7
Remove review question
reivilibre Jul 9, 2021
261b6c4
Remove some unneeded advances
reivilibre Jul 9, 2021
6b6b6f2
Apply rename
reivilibre Jul 9, 2021
fd4f493
Rewrite R30v2 query to not use window function
reivilibre Jul 9, 2021
92d215c
Cast things to BIGINT because poor Postgres cries
reivilibre Jul 9, 2021
9353939
Add alias to make Postgres happy
reivilibre Jul 9, 2021
6762bcf
Move multiplies to python from SQL
reivilibre Jul 19, 2021
2fca561
Don't bother ordering
reivilibre Jul 19, 2021
80c9187
Simplify WHEN chain
reivilibre Jul 19, 2021
79db58b
Remove review comment
reivilibre Jul 19, 2021
982b2d1
Clarify desired_time is secs
reivilibre Jul 19, 2021
7860bc9
Factorise out store
reivilibre Jul 19, 2021
a865726
Clean up and standardise the advance times
reivilibre Jul 19, 2021
44a0f91
Describe the structure of the dict
reivilibre Jul 19, 2021
46827cf
Clean up casts and move multiplications into Python
reivilibre Jul 19, 2021
899251c
Clarify comment about five minutes
reivilibre Jul 19, 2021
8a4f589
Factorise store again (oops)
reivilibre Jul 19, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/10332.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a new version of the R30 phone-home metric, which removes a false impression of retention given by the old R30 metric.
4 changes: 4 additions & 0 deletions synapse/app/phone_stats_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ async def phone_stats_home(hs, stats, stats_process=_stats_process):
for name, count in r30_results.items():
stats["r30_users_" + name] = count

r30v2_results = await hs.get_datastore().count_r30_users()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these repeated calls to hs.get_datastore() are fugly :/.

It's fine for now, for consistency with the rest of the code, but at some point it would be nice to factor this out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have a follow-up PR ready to go after this one

for name, count in r30v2_results.items():
stats["r30v2_users_" + name] = count

stats["cache_factor"] = hs.config.caches.global_factor
stats["event_cache_size"] = hs.config.caches.event_cache_size

Expand Down
120 changes: 120 additions & 0 deletions synapse/storage/databases/main/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,126 @@ def _count_r30_users(txn):

return await self.db_pool.runInteraction("count_r30_users", _count_r30_users)

async def count_r30v2_users(self) -> Dict[str, int]:
"""
Counts the number of 30 day retained users, defined as users that:
- Appear more than once in the past 60 days
- Have more than 30 days between the most and least recent appearances that
occurred in the past 60 days.

(This is the second version of this metric, hence R30'v2')

Returns:
A mapping of counts globally as well as broken out by platform.
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
"""

def _count_r30v2_users(txn):
thirty_days_in_secs = 86400 * 30
now = int(self._clock.time())
sixty_days_ago_in_secs = now - 2 * thirty_days_in_secs
one_day_from_now_in_secs = now + 86400

# This is the 'per-platform' count.
sql = """
SELECT
client_type,
count(client_type)
FROM
(
SELECT
user_id,
CASE
WHEN
user_agent IS NULL OR
user_agent = ''
THEN 'unknown'
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
WHEN
LOWER(user_agent) LIKE '%%riot%%' OR
LOWER(user_agent) LIKE '%%element%%'
THEN CASE
WHEN
LOWER(user_agent) LIKE '%%electron%%'
THEN 'electron'
WHEN
LOWER(user_agent) LIKE '%%android%%'
THEN 'android'
WHEN
LOWER(user_agent) LIKE '%%ios%%'
THEN 'ios'
ELSE 'unknown'
END
WHEN
LOWER(user_agent) LIKE '%%mozilla%%' OR
LOWER(user_agent) LIKE '%%gecko%%'
THEN 'web'
ELSE 'unknown'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please could we avoid having two slightly different copies of the User-Agent to platform mapping? factor it out to a constant, maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant to change the pre-existing R30 mapping, but as far as I know, we only want to count Element clients for R30v2.

END as client_type
FROM
user_daily_visits
WHERE
timestamp > (CAST(? AS BIGINT) * 1000)
AND
timestamp < (CAST(? AS BIGINT) * 1000)
GROUP BY
user_id,
client_type
HAVING
max(timestamp) - min(timestamp) > (CAST(? AS BIGINT) * 1000)
) AS temp
GROUP BY
client_type
ORDER BY
client_type
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
;
"""

# We initialise all the client types to zero, so we get an explicit
# zero if they don't appear in the query results
results = {"ios": 0, "android": 0, "web": 0, "electron": 0}
txn.execute(
sql,
(sixty_days_ago_in_secs, one_day_from_now_in_secs, thirty_days_in_secs),
)

for row in txn:
if row[0] == "unknown":
continue
results[row[0]] = row[1]

# This is the 'all users' count.
sql = """
SELECT COUNT(*) FROM (
SELECT
1
FROM
user_daily_visits
WHERE
timestamp > (CAST(? AS BIGINT) * 1000)
AND
timestamp < (CAST(? AS BIGINT) * 1000)
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
GROUP BY
user_id
HAVING
max(timestamp) - min(timestamp) > (CAST(? AS BIGINT) * 1000)
) AS r30_users
"""

txn.execute(
sql,
(sixty_days_ago_in_secs, one_day_from_now_in_secs, thirty_days_in_secs),
)
row = txn.fetchone()
if row is None:
results["all"] = 0
else:
results["all"] = row[0]

return results

return await self.db_pool.runInteraction(
"count_r30v2_users", _count_r30v2_users
)

def _get_start_of_day(self):
"""
Returns millisecond unixtime for start of UTC day.
Expand Down
232 changes: 232 additions & 0 deletions tests/app/test_phone_stats_home.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import synapse
from synapse.app.phone_stats_home import start_phone_stats_home
from synapse.rest.client.v1 import login, room

from tests import unittest
Expand Down Expand Up @@ -151,3 +152,234 @@ def test_r30_user_must_be_retained_for_at_least_a_month(self):
# *Now* the user appears in R30.
r30_results = self.get_success(self.hs.get_datastore().count_r30_users())
self.assertEqual(r30_results, {"all": 1, "unknown": 1})


class PhoneHomeR30V2TestCase(HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets_for_client_rest_resource,
room.register_servlets,
login.register_servlets,
]

def _advance_to(self, desired_time: float):
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
now = self.hs.get_clock().time()
assert now < desired_time
self.reactor.advance(desired_time - now)

def make_homeserver(self, reactor, clock):
hs = super(PhoneHomeR30V2TestCase, self).make_homeserver(reactor, clock)

# We don't want our tests to actually report statistics, so check
# that it's not enabled
assert not hs.config.report_stats

# This starts the needed data collection that we rely on to calculate
# R30v2 metrics.
# REVIEW: would I be better off just starting the looping call for
# populating user_daily_visits itself?
start_phone_stats_home(hs)
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
return hs

def test_r30v2_minimum_usage(self):
"""
Tests the minimum amount of interaction necessary for the R30v2 metric
to consider a user 'retained'.
"""

# Register a user, log it in, create a room and send a message
user_id = self.register_user("u1", "secret!")
access_token = self.login("u1", "secret!")
room_id = self.helper.create_room_as(room_creator=user_id, tok=access_token)
self.helper.send(room_id, "message", tok=access_token)
first_post_at = self.hs.get_clock().time()

# (give time for tables to be updated)
self.reactor.advance(300)
reivilibre marked this conversation as resolved.
Show resolved Hide resolved

# Check the R30 results do not count that user.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# Advance 31 days.
# (R30v2 includes users with **more** than 30 days between the two visits,
# and user_daily_visits records the timestamp as the start of the day.)
self.reactor.advance(31 * ONE_DAY_IN_SECONDS)
# Also advance 10 minutes to let another user_daily_visits update occur
self.reactor.advance(600)

# (Make sure the user isn't somehow counted by this point.)
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# Send a message (this counts as activity)
self.helper.send(room_id, "message2", tok=access_token)

# We have to wait up a few minutes for the user_daily_visits table to
# be updated by a background process.
self.reactor.advance(300)

# *Now* the user is counted.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 1, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# Advance to JUST under 60 days after the user's first post
self._advance_to(first_post_at + 60 * ONE_DAY_IN_SECONDS - 5)
self.reactor.advance(1)
reivilibre marked this conversation as resolved.
Show resolved Hide resolved

# Check the user is still counted.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 1, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# Advance into the next day. The user's first activity is now more than 60 days old.
self._advance_to(first_post_at + 60 * ONE_DAY_IN_SECONDS + 5)

# Check the user is now no longer counted in R30.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

def test_r30v2_user_must_be_retained_for_at_least_a_month(self):
"""
Tests that a newly-registered user must be retained for a whole month
before appearing in the R30v2 statistic, even if they post every day
during that time!
"""
# set a custom user-agent to impersonate Element/Android.
headers = (
(
"User-Agent",
"Element/1.1 (Linux; U; Android 9; MatrixAndroidSDK_X 0.0.1)",
),
)

# Register a user and send a message
user_id = self.register_user("u1", "secret!")
access_token = self.login("u1", "secret!", custom_headers=headers)
room_id = self.helper.create_room_as(
room_creator=user_id, tok=access_token, custom_headers=headers
)
self.helper.send(room_id, "message", tok=access_token, custom_headers=headers)

self.reactor.advance(400)
reivilibre marked this conversation as resolved.
Show resolved Hide resolved

# Check the user does not contribute to R30 yet.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

for _ in range(30):
# This loop posts a message every day for 30 days
self.reactor.advance(ONE_DAY_IN_SECONDS - 400)
self.helper.send(
room_id, "I'm still here", tok=access_token, custom_headers=headers
)

self.reactor.advance(400)

# Notice that the user *still* does not contribute to R30!
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# (This advance needs to be split up into multiple advances
# because otherwise strict inequality hits.)
self.reactor.advance(ONE_DAY_IN_SECONDS)
self.helper.send(
room_id, "Still here!", tok=access_token, custom_headers=headers
)

self.reactor.advance(400)

# *Now* the user appears in R30.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 1, "android": 1, "electron": 0, "ios": 0, "web": 0}
)

def test_r30v2_returning_dormant_users_not_counted(self):
"""
Tests that dormant users (users inactive for a long time) do not
contribute to R30v2 when they return for just a single day.
This is a key difference between R30 and R30v2.
"""

# set a custom user-agent to impersonate Element/iOS.
headers = (
(
"User-Agent",
"Riot/1.4 (iPhone; iOS 13; Scale/4.00)",
),
)

# Register a user and send a message
user_id = self.register_user("u1", "secret!")
access_token = self.login("u1", "secret!", custom_headers=headers)
room_id = self.helper.create_room_as(
room_creator=user_id, tok=access_token, custom_headers=headers
)
self.helper.send(room_id, "message", tok=access_token, custom_headers=headers)

# the user goes inactive for 2 months
self.reactor.advance(60 * ONE_DAY_IN_SECONDS)

# the user returns for one day, perhaps just to check out a new feature
self.helper.send(room_id, "message", tok=access_token, custom_headers=headers)

# (give time for tables to update)
self.reactor.advance(300)

# Check that the user does not contribute to R30v2, even though it's been
# more than 30 days since registration.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)

# Check that this is a situation where old R30 differs:
# old R30 DOES count this as 'retained'.
r30_results = self.get_success(self.hs.get_datastore().count_r30_users())
self.assertEqual(r30_results, {"all": 1, "ios": 1})

# Now we want to check that the user will still be able to appear in
# R30v2 as long as the user performs some other activity between
# 30 and 60 days later.
self.reactor.advance(32 * ONE_DAY_IN_SECONDS)
self.helper.send(room_id, "message", tok=access_token, custom_headers=headers)

# (give time for tables to update)
self.reactor.advance(300)

# Check the user now satisfies the requirements to appear in R30v2.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 1, "ios": 1, "android": 0, "electron": 0, "web": 0}
)

# Advance to 59.5 days after the user's first R30v2-eligible activity.
self.reactor.advance(27.5 * ONE_DAY_IN_SECONDS)

# Check the user still appears in R30v2.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 1, "ios": 1, "android": 0, "electron": 0, "web": 0}
)

# Advance to 60.5 days after the user's first R30v2-eligible activity.
self.reactor.advance(ONE_DAY_IN_SECONDS)

# Check the user no longer appears in R30v2.
r30_results = self.get_success(self.hs.get_datastore().count_r30v2_users())
self.assertEqual(
r30_results, {"all": 0, "android": 0, "electron": 0, "ios": 0, "web": 0}
)
Loading