From 70238992ac72e46dfde34a135eafa53863720133 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 13:03:12 +0100 Subject: [PATCH 01/26] Add synapse_admin_user_count metric. --- changelog.d/19848.feature | 1 + synapse/app/phone_stats_home.py | 26 ++++++++++++++++ .../storage/databases/main/registration.py | 26 ++++++++++++++++ tests/metrics/test_phone_home_stats.py | 30 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 changelog.d/19848.feature diff --git a/changelog.d/19848.feature b/changelog.d/19848.feature new file mode 100644 index 00000000000..2a2e3b87bd2 --- /dev/null +++ b/changelog.d/19848.feature @@ -0,0 +1 @@ +Add new metric `synapse_admin_user_count` which tracks how many users are registered to the homeserver. diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 7b4bf25c280..0a8df58cb41 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -68,12 +68,19 @@ "MAU Limit", labelnames=[SERVER_NAME_LABEL], ) + registered_reserved_users_mau_gauge = Gauge( "synapse_admin_mau_registered_reserved_users", "Registered users with reserved threepids", labelnames=[SERVER_NAME_LABEL], ) +user_count_gauge = Gauge( + "synapse_admin__user_count", + "Total user count within the Synapse database", + labelnames=["app_service", SERVER_NAME_LABEL], +) + def phone_stats_home( hs: "HomeServer", @@ -261,6 +268,25 @@ async def _generate_monthly_active_users() -> None: _generate_monthly_active_users, ) + def generate_total_users() -> "defer.Deferred[None]": + async def _generate_total_users() -> None: + store = hs.get_datastores().main + + result = await store.get_user_count_by_service() + + for app_service, count in result: + user_count_gauge.labels( + app_service=app_service, **{SERVER_NAME_LABEL: server_name} + ).set(float(count)) + + return hs.run_as_background_process( + "generate_total_users", + _generate_total_users, + ) + + generate_total_users() + clock.looping_call(generate_total_users, Duration(minutes=5)) + if hs.config.server.limit_usage_by_mau or hs.config.server.mau_stats_only: generate_monthly_active_users() clock.looping_call(generate_monthly_active_users, Duration(minutes=5)) diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py index 2455057b027..3c88a416a6a 100644 --- a/synapse/storage/databases/main/registration.py +++ b/synapse/storage/databases/main/registration.py @@ -1133,6 +1133,32 @@ def _count_users(txn: LoggingTransaction) -> int: return await self.db_pool.runInteraction("count_real_users", _count_users) + async def get_user_count_by_service(self) -> list[tuple[str, int]]: + """Counts users grouped by their appservice. + + Returns: + A list of tuples (appservice_id, count). "native" is emitted as the + appservice for users that don't come from appservices (i.e. native Matrix + users). + + """ + + def _get_user_count_by_service( + txn: LoggingTransaction, + ) -> list[tuple[str, int]]: + sql = """ + SELECT COALESCE(appservice_id, 'native'), COUNT(name) + FROM users WHERE deactivated = 0 + GROUP BY appservice_id; + """ + + txn.execute(sql) + return cast(list[tuple[str, int]], txn.fetchall()) + + return await self.db_pool.runInteraction( + "get_user_count_by_service", _get_user_count_by_service + ) + async def generate_user_id(self) -> str: """Generate a suitable localpart for a guest user diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index dfb88588cdf..5b3c82da0e1 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -19,11 +19,13 @@ from synapse.app.phone_stats_home import ( PHONE_HOME_INTERVAL, start_phone_stats_home, + user_count_gauge, ) from synapse.rest import admin, login, register, room from synapse.server import HomeServer from synapse.types import JsonDict from synapse.util.clock import Clock +from synapse.util.duration import Duration from tests import unittest from tests.server import ThreadedMemoryReactorClock @@ -261,3 +263,31 @@ def test_phone_home_stats(self) -> None: synapse_logger = logging.getLogger("synapse") log_level = synapse_logger.getEffectiveLevel() self.assertEqual(phone_home_stats["log_level"], logging.getLevelName(log_level)) + + def test_generate_total_users_gauge(self) -> None: + """ + Test that generate_total_users() populates user_count_gauge correctly, + counting only active (non-deactivated) users split by app_service. + """ + server_name = self.hs.config.server.server_name + + # Register two native users and deactivate one to confirm it is excluded. + self.register_user("gauge_user_1", "password") + user_2 = self.register_user("gauge_user_2", "password") + self.get_success( + self.store.set_user_deactivated_status(user_id=user_2, deactivated=True) + ) + + # Advance past the 5-minute looping interval so generate_total_users fires again. + self.reactor.advance(Duration(minutes=5).as_secs() + 1) + + # Collect gauge samples for our server_name. + samples = { + sample.labels["app_service"]: sample.value + for metric_family in user_count_gauge.collect() + for sample in metric_family.samples + if sample.labels.get("server_name") == server_name + } + + # Only the one active native user should be counted. + self.assertEqual(samples.get("native"), 1.0) From c89519e73f2f46e8049a527ee118b8ecf54c4b20 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 13:04:33 +0100 Subject: [PATCH 02/26] typo --- synapse/app/phone_stats_home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 0a8df58cb41..901ce0835f5 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -76,7 +76,7 @@ ) user_count_gauge = Gauge( - "synapse_admin__user_count", + "synapse_admin_user_count", "Total user count within the Synapse database", labelnames=["app_service", SERVER_NAME_LABEL], ) From b92404822b511a5465855bf2dfe3cf57416fc3b2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 13:05:03 +0100 Subject: [PATCH 03/26] spacing --- synapse/app/phone_stats_home.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 901ce0835f5..5c7a92e0975 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -68,13 +68,11 @@ "MAU Limit", labelnames=[SERVER_NAME_LABEL], ) - registered_reserved_users_mau_gauge = Gauge( "synapse_admin_mau_registered_reserved_users", "Registered users with reserved threepids", labelnames=[SERVER_NAME_LABEL], ) - user_count_gauge = Gauge( "synapse_admin_user_count", "Total user count within the Synapse database", From 3c0e6f4aad8156c5876146e578b008f0b9849c72 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:58:29 +0100 Subject: [PATCH 04/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- synapse/storage/databases/main/registration.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py index 3c88a416a6a..4bc1a0be1b0 100644 --- a/synapse/storage/databases/main/registration.py +++ b/synapse/storage/databases/main/registration.py @@ -1147,10 +1147,11 @@ def _get_user_count_by_service( txn: LoggingTransaction, ) -> list[tuple[str, int]]: sql = """ - SELECT COALESCE(appservice_id, 'native'), COUNT(name) - FROM users WHERE deactivated = 0 - GROUP BY appservice_id; - """ + SELECT COALESCE(NULLIF(appservice_id, ''), 'native') AS app_service, COUNT(*) AS count + FROM users + WHERE deactivated = 0 + GROUP BY COALESCE(NULLIF(appservice_id, ''), 'native') + """ txn.execute(sql) return cast(list[tuple[str, int]], txn.fetchall()) From 4335e1fb9894520b8277b2a69fbfd410ec86ed61 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:58:49 +0100 Subject: [PATCH 05/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- synapse/app/phone_stats_home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 5c7a92e0975..00dc1545145 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -75,7 +75,7 @@ ) user_count_gauge = Gauge( "synapse_admin_user_count", - "Total user count within the Synapse database", + "Total active (non-deactivated) user count within the Synapse database, split by appservice", labelnames=["app_service", SERVER_NAME_LABEL], ) From 39468bc7a4fa1d3f95437e10759007ae678b2210 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:59:05 +0100 Subject: [PATCH 06/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- synapse/app/phone_stats_home.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 00dc1545145..1d5c347f345 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -272,11 +272,12 @@ async def _generate_total_users() -> None: result = await store.get_user_count_by_service() + user_count_gauge.clear() + for app_service, count in result: user_count_gauge.labels( app_service=app_service, **{SERVER_NAME_LABEL: server_name} ).set(float(count)) - return hs.run_as_background_process( "generate_total_users", _generate_total_users, From 170d12053ff098947515a236eea6ba15df4efe35 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:59:27 +0100 Subject: [PATCH 07/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- synapse/app/phone_stats_home.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 1d5c347f345..35a3b7b01b0 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -283,8 +283,9 @@ async def _generate_total_users() -> None: _generate_total_users, ) - generate_total_users() - clock.looping_call(generate_total_users, Duration(minutes=5)) + if hs.config.metrics.enable_metrics: + generate_total_users() + clock.looping_call(generate_total_users, Duration(minutes=5)) if hs.config.server.limit_usage_by_mau or hs.config.server.mau_stats_only: generate_monthly_active_users() From a0dc0c3be8fa87bcdaac58eea9ed3468c83c3252 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:59:48 +0100 Subject: [PATCH 08/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- changelog.d/19848.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/19848.feature b/changelog.d/19848.feature index 2a2e3b87bd2..5827b8dec5d 100644 --- a/changelog.d/19848.feature +++ b/changelog.d/19848.feature @@ -1 +1 @@ -Add new metric `synapse_admin_user_count` which tracks how many users are registered to the homeserver. +Add new metric `synapse_admin_user_count` which tracks the number of non-deactivated users in the database, split by `app_service`. From 2c1a9f3f9b1b579daafe84f5bab6ccf3cd1a2349 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 14:02:44 +0100 Subject: [PATCH 09/26] test update --- tests/metrics/test_phone_home_stats.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index 5b3c82da0e1..2db60087a8d 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -264,6 +264,28 @@ def test_phone_home_stats(self) -> None: log_level = synapse_logger.getEffectiveLevel() self.assertEqual(phone_home_stats["log_level"], logging.getLevelName(log_level)) + +class TotalUsersGaugeTestCase(unittest.HomeserverTestCase): + servlets = [ + admin.register_servlets_for_client_rest_resource, + register.register_servlets, + ] + + def make_homeserver( + self, reactor: ThreadedMemoryReactorClock, clock: Clock + ) -> HomeServer: + config = self.default_config() + config["enable_metrics"] = True + return self.setup_test_homeserver(config=config) + + def prepare( + self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer + ) -> None: + self.store = homeserver.get_datastores().main + self.wait_for_background_updates() + start_phone_stats_home(hs=homeserver) + super().prepare(reactor, clock, homeserver) + def test_generate_total_users_gauge(self) -> None: """ Test that generate_total_users() populates user_count_gauge correctly, From e420c3bf3564c521a4a12b2119844e3a36e63162 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 14:14:40 +0100 Subject: [PATCH 10/26] lint --- synapse/app/phone_stats_home.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 35a3b7b01b0..6e7bfb8aba5 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -278,6 +278,7 @@ async def _generate_total_users() -> None: user_count_gauge.labels( app_service=app_service, **{SERVER_NAME_LABEL: server_name} ).set(float(count)) + return hs.run_as_background_process( "generate_total_users", _generate_total_users, From fb2ec7ddb627ae1917f64495564536c7aa1ce9f3 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 15:21:51 +0100 Subject: [PATCH 11/26] Bump metrics dependency to support .clear() --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3500b4f5167..c93722c1260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,9 +61,8 @@ dependencies = [ "msgpack>=0.5.2", "phonenumbers>=8.2.0", # we use GaugeHistogramMetric, which was added in prom-client 0.4.0. - # `prometheus_client.metrics` was added in 0.5.0, so we require that too. - # We chose 0.6.0 as that is the current version in Debian Buster (oldstable). - "prometheus-client>=0.6.0", + # `Gauge.clear()` was added in 0.10.0. + "prometheus-client>=0.10.0", # we use `order`, which arrived in attrs 19.2.0. # Note: 21.1.0 broke `/sync`, see https://github.com/matrix-org/synapse/issues/9936 "attrs>=19.2.0,!=21.1.0", From 715c253910f745d9b5558e2f68c39386865d8420 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 11 Jun 2026 16:28:22 +0100 Subject: [PATCH 12/26] update lock --- poetry.lock | 63 ++++++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4afbaad2c15..b0e0b5c89fc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -31,7 +31,7 @@ description = "The ultimate Python library in building OAuth and OpenID Connect optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"jwt\" or extra == \"oidc\"" +markers = "extra == \"oidc\" or extra == \"jwt\" or extra == \"all\"" files = [ {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, @@ -62,7 +62,7 @@ description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -284,6 +284,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -531,7 +532,7 @@ description = "XML bomb protection for Python stdlib modules" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -556,7 +557,7 @@ description = "XPath 1.0/2.0/3.0/3.1 parsers and selectors for ElementTree and l optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "elementpath-4.8.0-py3-none-any.whl", hash = "sha256:5393191f84969bcf8033b05ec4593ef940e58622ea13cefe60ecefbbf09d58d9"}, {file = "elementpath-4.8.0.tar.gz", hash = "sha256:5822a2560d99e2633d95f78694c7ff9646adaa187db520da200a8e9479dc46ae"}, @@ -606,7 +607,7 @@ description = "Python wrapper for hiredis" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"redis\"" +markers = "extra == \"redis\" or extra == \"all\"" files = [ {file = "hiredis-3.3.1-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f525734382a47f9828c9d6a1501522c78d5935466d8e2be1a41ba40ca5bb922b"}, {file = "hiredis-3.3.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:6e2e1024f0a021777740cb7c633a0efb2c4a4bc570f508223a8dcbcf79f99ef9"}, @@ -889,7 +890,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -930,7 +931,7 @@ description = "Jaeger Python OpenTracing Tracer implementation" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"opentracing\"" +markers = "extra == \"opentracing\" or extra == \"all\"" files = [ {file = "jaeger-client-4.8.0.tar.gz", hash = "sha256:3157836edab8e2c209bd2d6ae61113db36f7ee399e66b1dcbb715d87ab49bfe0"}, ] @@ -1061,7 +1062,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" @@ -1122,7 +1123,7 @@ description = "A strictly RFC 4510 conforming LDAP V3 pure Python client library optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"matrix-synapse-ldap3\"" +markers = "extra == \"matrix-synapse-ldap3\" or extra == \"all\"" files = [ {file = "ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70"}, {file = "ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"}, @@ -1239,7 +1240,7 @@ description = "Powerful and Pythonic XML processing library combining libxml2/li optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"url-preview\"" +markers = "extra == \"url-preview\" or extra == \"all\"" files = [ {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec"}, {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec"}, @@ -1547,7 +1548,7 @@ description = "An LDAP3 auth provider for Synapse" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"matrix-synapse-ldap3\"" +markers = "extra == \"matrix-synapse-ldap3\" or extra == \"all\"" files = [ {file = "matrix_synapse_ldap3-0.4.0-py3-none-any.whl", hash = "sha256:bf080037230d2af5fd3639cb87266de65c1cad7a68ea206278c5b4bf9c1a17f3"}, {file = "matrix_synapse_ldap3-0.4.0.tar.gz", hash = "sha256:cff52ba780170de5e6e8af42863d2648ee23f3bf0a9fea6db52372f9fc00be2b"}, @@ -1828,7 +1829,7 @@ description = "OpenTracing API for Python. See documentation at http://opentraci optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"opentracing\"" +markers = "extra == \"opentracing\" or extra == \"all\"" files = [ {file = "opentracing-2.4.0.tar.gz", hash = "sha256:a173117e6ef580d55874734d1fa7ecb6f3655160b8b8974a2a1e98e5ec9c840d"}, ] @@ -1859,6 +1860,7 @@ files = [ {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, ] +markers = {main = "extra == \"test\""} [package.extras] dev = ["jinja2"] @@ -2026,7 +2028,7 @@ description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"postgres\"" +markers = "extra == \"postgres\" or extra == \"all\"" files = [ {file = "psycopg2-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:103e857f46bb76908768ead4e2d0ba1d1a130e7b8ed77d3ae91e8b33481813e8"}, {file = "psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb"}, @@ -2044,7 +2046,7 @@ description = ".. image:: https://travis-ci.org/chtd/psycopg2cffi.svg?branch=mas optional = true python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and (extra == \"all\" or extra == \"postgres\")" +markers = "platform_python_implementation == \"PyPy\" and (extra == \"postgres\" or extra == \"all\")" files = [ {file = "psycopg2cffi-2.9.0.tar.gz", hash = "sha256:7e272edcd837de3a1d12b62185eb85c45a19feda9e62fa1b120c54f9e8d35c52"}, ] @@ -2060,7 +2062,7 @@ description = "A Simple library to enable psycopg2 compatability" optional = true python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and (extra == \"all\" or extra == \"postgres\")" +markers = "platform_python_implementation == \"PyPy\" and (extra == \"postgres\" or extra == \"all\")" files = [ {file = "psycopg2cffi-compat-1.1.tar.gz", hash = "sha256:d25e921748475522b33d13420aad5c2831c743227dc1f1f2585e0fdb5c914e05"}, ] @@ -2102,11 +2104,11 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] +markers = {main = "implementation_name != \"PyPy\"", dev = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" @@ -2342,7 +2344,7 @@ description = "A development tool to measure, monitor and analyze the memory beh optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"all\" or extra == \"cache-memory\"" +markers = "extra == \"cache-memory\" or extra == \"all\"" files = [ {file = "Pympler-1.0.1-py3-none-any.whl", hash = "sha256:d260dda9ae781e1eab6ea15bacb84015849833ba5555f141d2d9b7b7473b307d"}, {file = "Pympler-1.0.1.tar.gz", hash = "sha256:993f1a3599ca3f4fcd7160c7545ad06310c9e12f70174ae7ae8d4e25f6c5d3fa"}, @@ -2474,7 +2476,7 @@ description = "Python implementation of SAML Version 2 Standard" optional = true python-versions = ">=3.9,<4.0" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "pysaml2-7.5.0-py3-none-any.whl", hash = "sha256:bc6627cc344476a83c757f440a73fda1369f13b6fda1b4e16bca63ffbabb5318"}, {file = "pysaml2-7.5.0.tar.gz", hash = "sha256:f36871d4e5ee857c6b85532e942550d2cf90ea4ee943d75eb681044bbc4f54f7"}, @@ -2499,7 +2501,7 @@ description = "Extensions to the standard Python datetime module" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2527,7 +2529,7 @@ description = "World timezone definitions, modern and historical" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, @@ -2932,7 +2934,7 @@ description = "Python client for Sentry (https://sentry.io)" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"all\" or extra == \"sentry\"" +markers = "extra == \"sentry\" or extra == \"all\"" files = [ {file = "sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585"}, {file = "sentry_sdk-2.57.0.tar.gz", hash = "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199"}, @@ -3132,7 +3134,7 @@ description = "Tornado IOLoop Backed Concurrent Futures" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"opentracing\"" +markers = "extra == \"opentracing\" or extra == \"all\"" files = [ {file = "threadloop-1.0.2-py2-none-any.whl", hash = "sha256:5c90dbefab6ffbdba26afb4829d2a9df8275d13ac7dc58dccb0e279992679599"}, {file = "threadloop-1.0.2.tar.gz", hash = "sha256:8b180aac31013de13c2ad5c834819771992d350267bddb854613ae77ef571944"}, @@ -3148,7 +3150,7 @@ description = "Python bindings for the Apache Thrift RPC system" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"opentracing\"" +markers = "extra == \"opentracing\" or extra == \"all\"" files = [ {file = "thrift-0.22.0.tar.gz", hash = "sha256:42e8276afbd5f54fe1d364858b6877bc5e5a4a5ed69f6a005b94ca4918fe1466"}, ] @@ -3214,6 +3216,7 @@ files = [ {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] +markers = {main = "python_version < \"3.11\""} [[package]] name = "tornado" @@ -3222,7 +3225,7 @@ description = "Tornado is a Python web framework and asynchronous networking lib optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"opentracing\"" +markers = "extra == \"opentracing\" or extra == \"all\"" files = [ {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, @@ -3254,7 +3257,7 @@ jinja2 = "*" tomli = {version = "*", markers = "python_version < \"3.11\""} [package.extras] -dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"] +dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"] [[package]] name = "treq" @@ -3354,7 +3357,7 @@ description = "non-blocking redis client for python" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"redis\"" +markers = "extra == \"redis\" or extra == \"all\"" files = [ {file = "txredisapi-1.4.11-py3-none-any.whl", hash = "sha256:ac64d7a9342b58edca13ef267d4fa7637c1aa63f8595e066801c1e8b56b22d0b"}, {file = "txredisapi-1.4.11.tar.gz", hash = "sha256:3eb1af99aefdefb59eb877b1dd08861efad60915e30ad5bf3d5bf6c5cedcdbc6"}, @@ -3615,7 +3618,7 @@ description = "An XML Schema validator and decoder" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml2\"" +markers = "extra == \"saml2\" or extra == \"all\"" files = [ {file = "xmlschema-2.5.1-py3-none-any.whl", hash = "sha256:ec2b2a15c8896c1fcd14dcee34ca30032b99456c3c43ce793fdb9dca2fb4b869"}, {file = "xmlschema-2.5.1.tar.gz", hash = "sha256:4f7497de6c8b6dc2c28ad7b9ed6e21d186f4afe248a5bea4f54eedab4da44083"}, @@ -3636,7 +3639,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -3749,4 +3752,4 @@ url-preview = ["lxml"] [metadata] lock-version = "2.1" python-versions = ">=3.10.0,<4.0.0" -content-hash = "ef0540b89c417a69668f551688bd0974256ea7a580044f3954a76bdf0d8fe7c9" +content-hash = "53ecef32887866656603697f9e11043dae7b2917b1a2e7727f5133f604acf9de" From 772082e2d6eee9903726b6694d2c637c8c6ac370 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 15 Jun 2026 09:31:08 +0100 Subject: [PATCH 13/26] Active is fine --- synapse/app/phone_stats_home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 6e7bfb8aba5..e6bb1269ed5 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -75,7 +75,7 @@ ) user_count_gauge = Gauge( "synapse_admin_user_count", - "Total active (non-deactivated) user count within the Synapse database, split by appservice", + "Total active user count within the Synapse database, split by appservice", labelnames=["app_service", SERVER_NAME_LABEL], ) From 904385c526a655b2389071e1b712fa6108443224 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 16 Jun 2026 10:24:53 +0100 Subject: [PATCH 14/26] Review changes --- changelog.d/19848.feature | 2 +- synapse/app/phone_stats_home.py | 6 +- tests/metrics/__init__.py | 27 ++++++++ tests/metrics/test_metrics.py | 25 +------ tests/metrics/test_phone_home_stats.py | 93 ++++++++++++++++++-------- 5 files changed, 99 insertions(+), 54 deletions(-) diff --git a/changelog.d/19848.feature b/changelog.d/19848.feature index 5827b8dec5d..2570e4d1328 100644 --- a/changelog.d/19848.feature +++ b/changelog.d/19848.feature @@ -1 +1 @@ -Add new metric `synapse_admin_user_count` which tracks the number of non-deactivated users in the database, split by `app_service`. +Add new metric `synapse_user_count` which tracks the number of non-deactivated users in the database, split by `app_service`. diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index e6bb1269ed5..f235dc72c04 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -74,8 +74,8 @@ labelnames=[SERVER_NAME_LABEL], ) user_count_gauge = Gauge( - "synapse_admin_user_count", - "Total active user count within the Synapse database, split by appservice", + "synapse_user_count", + "Total non-deactivated user count within the Synapse database, split by appservice", labelnames=["app_service", SERVER_NAME_LABEL], ) @@ -272,6 +272,8 @@ async def _generate_total_users() -> None: result = await store.get_user_count_by_service() + # Should an appservice disappear, we want to ensure + # we don't have stale data. user_count_gauge.clear() for app_service, count in result: diff --git a/tests/metrics/__init__.py b/tests/metrics/__init__.py index e69de29bb2d..adebf9d30a3 100644 --- a/tests/metrics/__init__.py +++ b/tests/metrics/__init__.py @@ -0,0 +1,27 @@ +from synapse.metrics import ( + REGISTRY, + generate_latest, +) + + +def get_latest_metrics() -> dict[str, str]: + """ + Collect the latest metrics from the registry and parse them into an easy to use map. + The key includes the metric name and labels. + + Example output: + { + "synapse_util_caches_cache_size": "0.0", + "synapse_util_caches_cache_max_size{name="some_cache",server_name="hs1"}": "777.0", + ... + } + """ + metric_map = { + x.split(b" ")[0].decode("ascii"): x.split(b" ")[1].decode("ascii") + for x in filter( + lambda x: len(x) > 0 and not x.startswith(b"#"), + generate_latest(REGISTRY).split(b"\n"), + ) + } + + return metric_map diff --git a/tests/metrics/test_metrics.py b/tests/metrics/test_metrics.py index 174b1656797..14eddd229e5 100644 --- a/tests/metrics/test_metrics.py +++ b/tests/metrics/test_metrics.py @@ -42,6 +42,8 @@ from tests import unittest +from . import get_latest_metrics + def get_sample_labels_value(sample: Sample) -> tuple[dict[str, str], float]: """Extract the labels and values of a sample. @@ -390,26 +392,3 @@ def raise_exception() -> NoReturn: f"Missing metric {hs2_metric} in cache metrics {metrics_map}", ) self.assertEqual(hs2_metric_value, "2.0") - - -def get_latest_metrics() -> dict[str, str]: - """ - Collect the latest metrics from the registry and parse them into an easy to use map. - The key includes the metric name and labels. - - Example output: - { - "synapse_util_caches_cache_size": "0.0", - "synapse_util_caches_cache_max_size{name="some_cache",server_name="hs1"}": "777.0", - ... - } - """ - metric_map = { - x.split(b" ")[0].decode("ascii"): x.split(b" ")[1].decode("ascii") - for x in filter( - lambda x: len(x) > 0 and not x.startswith(b"#"), - generate_latest(REGISTRY).split(b"\n"), - ) - } - - return metric_map diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index 2db60087a8d..788b9cbb0d9 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -12,24 +12,23 @@ # . import logging -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch from twisted.internet.testing import MemoryReactor -from synapse.app.phone_stats_home import ( - PHONE_HOME_INTERVAL, - start_phone_stats_home, - user_count_gauge, -) +from synapse.app.phone_stats_home import PHONE_HOME_INTERVAL, start_phone_stats_home +from synapse.appservice import ApplicationService from synapse.rest import admin, login, register, room from synapse.server import HomeServer -from synapse.types import JsonDict +from synapse.types import JsonDict, UserID from synapse.util.clock import Clock from synapse.util.duration import Duration from tests import unittest from tests.server import ThreadedMemoryReactorClock +from . import get_latest_metrics + TEST_REPORT_STATS_ENDPOINT = "https://fake.endpoint/stats" TEST_SERVER_CONTEXT = "test-server-context" @@ -276,7 +275,21 @@ def make_homeserver( ) -> HomeServer: config = self.default_config() config["enable_metrics"] = True - return self.setup_test_homeserver(config=config) + self.appservice = ApplicationService( + token="i_am_an_app_service", + id="1234", + namespaces={"users": [{"regex": r"@as_user.*", "exclusive": True}]}, + # Note: this user does not match the regex above, so that tests + # can distinguish the sender from the AS user. + sender=UserID.from_string("@as_main:test"), + ) + + mock_load_appservices = Mock(return_value=[self.appservice]) + with patch( + "synapse.storage.databases.main.appservice.load_appservices", + mock_load_appservices, + ): + return self.setup_test_homeserver(config=config) def prepare( self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer @@ -286,30 +299,54 @@ def prepare( start_phone_stats_home(hs=homeserver) super().prepare(reactor, clock, homeserver) - def test_generate_total_users_gauge(self) -> None: - """ - Test that generate_total_users() populates user_count_gauge correctly, - counting only active (non-deactivated) users split by app_service. - """ - server_name = self.hs.config.server.server_name + def _get_user_count_metrics(self) -> dict[str, str]: + self.reactor.advance(Duration(minutes=5).as_secs()) + return get_latest_metrics() + + def _native_key(self) -> str: + return f'synapse_user_count{{app_service="native",server_name="{self.hs.config.server.server_name}"}}' + + def _appservice_key(self) -> str: + return f'synapse_user_count{{app_service="{self.appservice.id}",server_name="{self.hs.config.server.server_name}"}}' + + def test_two_native_users(self) -> None: + """Two registered native users are counted correctly.""" + self.register_user("user_1", "password") + self.register_user("user_2", "password") + + metrics = self._get_user_count_metrics() - # Register two native users and deactivate one to confirm it is excluded. - self.register_user("gauge_user_1", "password") - user_2 = self.register_user("gauge_user_2", "password") + self.assertEqual(metrics.get(self._native_key()), "2.0") + + def test_deactivated_native_user_excluded(self) -> None: + """A deactivated native user is not counted.""" + self.register_user("user_1", "password") + user_2 = self.register_user("user_2", "password") self.get_success( self.store.set_user_deactivated_status(user_id=user_2, deactivated=True) ) - # Advance past the 5-minute looping interval so generate_total_users fires again. - self.reactor.advance(Duration(minutes=5).as_secs() + 1) + metrics = self._get_user_count_metrics() + + self.assertEqual(metrics.get(self._native_key()), "1.0") + + def test_native_and_appservice_users(self) -> None: + """A native user and an appservice user are counted under separate labels.""" + self.register_user("user_1", "password") + self.register_appservice_user("as_user_1", self.appservice.token) + + metrics = self._get_user_count_metrics() + + self.assertEqual(metrics.get(self._native_key()), "1.0") + self.assertEqual(metrics.get(self._appservice_key()), "1.0") + + def test_deactivated_appservice_user_excluded(self) -> None: + """A deactivated appservice user is not counted.""" + as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) + self.get_success( + self.store.set_user_deactivated_status(user_id=as_user, deactivated=True) + ) - # Collect gauge samples for our server_name. - samples = { - sample.labels["app_service"]: sample.value - for metric_family in user_count_gauge.collect() - for sample in metric_family.samples - if sample.labels.get("server_name") == server_name - } + metrics = self._get_user_count_metrics() - # Only the one active native user should be counted. - self.assertEqual(samples.get("native"), 1.0) + self.assertIsNone(metrics.get(self._appservice_key())) From 1b9ce6c1c7f0f10f41787ff6e4d9978e6b5159cd Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 16 Jun 2026 10:38:54 +0100 Subject: [PATCH 15/26] update tests --- tests/metrics/test_phone_home_stats.py | 55 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index 788b9cbb0d9..ae036d0e4ae 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -18,7 +18,7 @@ from synapse.app.phone_stats_home import PHONE_HOME_INTERVAL, start_phone_stats_home from synapse.appservice import ApplicationService -from synapse.rest import admin, login, register, room +from synapse.rest import account, admin, login, register, room from synapse.server import HomeServer from synapse.types import JsonDict, UserID from synapse.util.clock import Clock @@ -266,8 +266,10 @@ def test_phone_home_stats(self) -> None: class TotalUsersGaugeTestCase(unittest.HomeserverTestCase): servlets = [ - admin.register_servlets_for_client_rest_resource, + account.register_servlets, + admin.register_servlets, register.register_servlets, + login.register_servlets, ] def make_homeserver( @@ -299,6 +301,37 @@ def prepare( start_phone_stats_home(hs=homeserver) super().prepare(reactor, clock, homeserver) + # Always register the first user as an admin so we can + # deactivate. + self.register_user("user_1", "password", admin=True) + self._admin_token = self.login("user_1", "password") + + def _deactivate_user(self, user_id: str, tok: str) -> None: + """ + Helper to deactivate a user using the /account/deactivate endpoint, optionally + with erasure + + Args: + user_id: the string formatted mxid(not a UserID) + tok: the user's access token + erase: bool of if this should be a full erasure request + """ + request_data = { + "auth": { + "type": "m.login.password", + "user": user_id, + "password": "password", + }, + "erase": False, + } + channel = self.make_request( + "POST", + "account/deactivate", + request_data, + access_token=tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + def _get_user_count_metrics(self) -> dict[str, str]: self.reactor.advance(Duration(minutes=5).as_secs()) return get_latest_metrics() @@ -311,7 +344,6 @@ def _appservice_key(self) -> str: def test_two_native_users(self) -> None: """Two registered native users are counted correctly.""" - self.register_user("user_1", "password") self.register_user("user_2", "password") metrics = self._get_user_count_metrics() @@ -320,11 +352,8 @@ def test_two_native_users(self) -> None: def test_deactivated_native_user_excluded(self) -> None: """A deactivated native user is not counted.""" - self.register_user("user_1", "password") user_2 = self.register_user("user_2", "password") - self.get_success( - self.store.set_user_deactivated_status(user_id=user_2, deactivated=True) - ) + self._deactivate_user(user_2, self.login("user_2", "password")) metrics = self._get_user_count_metrics() @@ -332,21 +361,9 @@ def test_deactivated_native_user_excluded(self) -> None: def test_native_and_appservice_users(self) -> None: """A native user and an appservice user are counted under separate labels.""" - self.register_user("user_1", "password") self.register_appservice_user("as_user_1", self.appservice.token) metrics = self._get_user_count_metrics() self.assertEqual(metrics.get(self._native_key()), "1.0") self.assertEqual(metrics.get(self._appservice_key()), "1.0") - - def test_deactivated_appservice_user_excluded(self) -> None: - """A deactivated appservice user is not counted.""" - as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) - self.get_success( - self.store.set_user_deactivated_status(user_id=as_user, deactivated=True) - ) - - metrics = self._get_user_count_metrics() - - self.assertIsNone(metrics.get(self._appservice_key())) From 3ae11395061549ce0401f31d708ee6f0d8b320d2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 16 Jun 2026 10:46:40 +0100 Subject: [PATCH 16/26] Update tests --- tests/metrics/test_phone_home_stats.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index ae036d0e4ae..485afae7419 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -324,9 +324,16 @@ def _deactivate_user(self, user_id: str, tok: str) -> None: }, "erase": False, } + + # If an appservice calls this, append the user_id + url = ( + f"account/deactivate?user_id={user_id}" + if tok == self.appservice.token + else "account/deactivate" + ) channel = self.make_request( "POST", - "account/deactivate", + url, request_data, access_token=tok, ) @@ -367,3 +374,12 @@ def test_native_and_appservice_users(self) -> None: self.assertEqual(metrics.get(self._native_key()), "1.0") self.assertEqual(metrics.get(self._appservice_key()), "1.0") + + def test_deactivated_appservice_user_excluded(self) -> None: + """A deactivated appservice user is not counted.""" + as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) + self._deactivate_user(as_user, self.appservice.token) + + metrics = self._get_user_count_metrics() + + self.assertIsNone(metrics.get(self._appservice_key())) From d821bbeee8bf73696974a85c33d7054b0bfe4be7 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 16 Jun 2026 11:48:27 +0100 Subject: [PATCH 17/26] update lock --- poetry.lock | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index e140cedc9c7..67787d86f5b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -284,7 +284,6 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -1062,7 +1061,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" @@ -1860,7 +1859,6 @@ files = [ {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, ] -markers = {main = "extra == \"test\""} [package.extras] dev = ["jinja2"] @@ -2104,11 +2102,11 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main", "dev"] +markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] -markers = {main = "implementation_name != \"PyPy\"", dev = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" @@ -3216,7 +3214,7 @@ files = [ {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] -markers = {main = "python_version < \"3.11\""} +markers = {main = "python_version < \"3.14\""} [[package]] name = "tornado" @@ -3257,7 +3255,7 @@ jinja2 = "*" tomli = {version = "*", markers = "python_version < \"3.11\""} [package.extras] -dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"] +dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"] [[package]] name = "treq" @@ -3752,4 +3750,4 @@ url-preview = ["lxml"] [metadata] lock-version = "2.1" python-versions = ">=3.10.0,<4.0.0" -content-hash = "53ecef32887866656603697f9e11043dae7b2917b1a2e7727f5133f604acf9de" +content-hash = "54037a80ccc59f29296ad2290a6f9c45905d93c9946c2fcb69ca666011eeeb1a" From b240e9c5baa8d122a29bd1fb102e900e210ad5d5 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 18 Jun 2026 13:48:57 +0100 Subject: [PATCH 18/26] More tidyup --- changelog.d/19848.feature | 2 +- synapse/app/phone_stats_home.py | 31 ++++++++++++++++---------- tests/metrics/__init__.py | 13 +++++++++++ tests/metrics/test_phone_home_stats.py | 28 +++++++++++++++-------- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/changelog.d/19848.feature b/changelog.d/19848.feature index 2570e4d1328..a7b891e547a 100644 --- a/changelog.d/19848.feature +++ b/changelog.d/19848.feature @@ -1 +1 @@ -Add new metric `synapse_user_count` which tracks the number of non-deactivated users in the database, split by `app_service`. +Add new metric `synapse_non_deactivated_user_count` which tracks the number of non-deactivated users in the database, split by `app_service`. diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index f235dc72c04..7cc69e0cfd6 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -48,6 +48,11 @@ Phone home stats are sent every 3 hours """ +COUNT_USERS_INTERVAL = Duration(minutes=5) +""" +We recalculate synapse_non_deactivated_user_count every 5 minutes. +""" + # Contains the list of processes we will be monitoring # currently either 0 or 1 _stats_process: list[tuple[int, "resource.struct_rusage"]] = [] @@ -74,7 +79,7 @@ labelnames=[SERVER_NAME_LABEL], ) user_count_gauge = Gauge( - "synapse_user_count", + "synapse_non_deactivated_user_count", "Total non-deactivated user count within the Synapse database, split by appservice", labelnames=["app_service", SERVER_NAME_LABEL], ) @@ -266,19 +271,26 @@ async def _generate_monthly_active_users() -> None: _generate_monthly_active_users, ) - def generate_total_users() -> "defer.Deferred[None]": + if hs.config.server.limit_usage_by_mau or hs.config.server.mau_stats_only: + generate_monthly_active_users() + clock.looping_call(generate_monthly_active_users, COUNT_USERS_INTERVAL) + # End of monthly active user settings + + def generate_non_deactivated_user_count() -> "defer.Deferred[None]": async def _generate_total_users() -> None: store = hs.get_datastores().main result = await store.get_user_count_by_service() - # Should an appservice disappear, we want to ensure - # we don't have stale data. + # Should an appservice disappear from the results (because all of the users + # were deleted/deactivated), we want to ensure we don't leave behind any + # stale data. user_count_gauge.clear() for app_service, count in result: user_count_gauge.labels( - app_service=app_service, **{SERVER_NAME_LABEL: server_name} + app_service=app_service, + **{SERVER_NAME_LABEL: server_name}, ).set(float(count)) return hs.run_as_background_process( @@ -287,13 +299,8 @@ async def _generate_total_users() -> None: ) if hs.config.metrics.enable_metrics: - generate_total_users() - clock.looping_call(generate_total_users, Duration(minutes=5)) - - if hs.config.server.limit_usage_by_mau or hs.config.server.mau_stats_only: - generate_monthly_active_users() - clock.looping_call(generate_monthly_active_users, Duration(minutes=5)) - # End of monthly active user settings + generate_non_deactivated_user_count() + clock.looping_call(generate_non_deactivated_user_count, Duration(minutes=5)) if hs.config.metrics.report_stats: logger.info("Scheduling stats reporting for 3 hour intervals") diff --git a/tests/metrics/__init__.py b/tests/metrics/__init__.py index adebf9d30a3..ea8066ef323 100644 --- a/tests/metrics/__init__.py +++ b/tests/metrics/__init__.py @@ -1,3 +1,16 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + from synapse.metrics import ( REGISTRY, generate_latest, diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index 485afae7419..b53cd1af3d3 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -2,6 +2,7 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright (C) 2025 New Vector, Ltd +# Copyright (C) 2026 Element Creations Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -16,13 +17,16 @@ from twisted.internet.testing import MemoryReactor -from synapse.app.phone_stats_home import PHONE_HOME_INTERVAL, start_phone_stats_home +from synapse.app.phone_stats_home import ( + COUNT_USERS_INTERVAL, + PHONE_HOME_INTERVAL, + start_phone_stats_home, +) from synapse.appservice import ApplicationService from synapse.rest import account, admin, login, register, room from synapse.server import HomeServer from synapse.types import JsonDict, UserID from synapse.util.clock import Clock -from synapse.util.duration import Duration from tests import unittest from tests.server import ThreadedMemoryReactorClock @@ -306,14 +310,14 @@ def prepare( self.register_user("user_1", "password", admin=True) self._admin_token = self.login("user_1", "password") - def _deactivate_user(self, user_id: str, tok: str) -> None: + def _deactivate_user(self, user_id: str, access_token: str) -> None: """ Helper to deactivate a user using the /account/deactivate endpoint, optionally with erasure Args: user_id: the string formatted mxid(not a UserID) - tok: the user's access token + access_token: the user's access token erase: bool of if this should be a full erasure request """ request_data = { @@ -328,26 +332,27 @@ def _deactivate_user(self, user_id: str, tok: str) -> None: # If an appservice calls this, append the user_id url = ( f"account/deactivate?user_id={user_id}" - if tok == self.appservice.token + if access_token == self.appservice.token else "account/deactivate" ) channel = self.make_request( "POST", url, request_data, - access_token=tok, + access_token=access_token, ) self.assertEqual(channel.code, 200, channel.json_body) def _get_user_count_metrics(self) -> dict[str, str]: - self.reactor.advance(Duration(minutes=5).as_secs()) + # Ensure we have the latest stats by advancing the timer. + self.reactor.advance(COUNT_USERS_INTERVAL.as_secs()) return get_latest_metrics() def _native_key(self) -> str: - return f'synapse_user_count{{app_service="native",server_name="{self.hs.config.server.server_name}"}}' + return f'synapse_non_deactivated_user_count{{app_service="native",server_name="{self.hs.config.server.server_name}"}}' def _appservice_key(self) -> str: - return f'synapse_user_count{{app_service="{self.appservice.id}",server_name="{self.hs.config.server.server_name}"}}' + return f'synapse_non_deactivated_user_count{{app_service="{self.appservice.id}",server_name="{self.hs.config.server.server_name}"}}' def test_two_native_users(self) -> None: """Two registered native users are counted correctly.""" @@ -360,6 +365,8 @@ def test_two_native_users(self) -> None: def test_deactivated_native_user_excluded(self) -> None: """A deactivated native user is not counted.""" user_2 = self.register_user("user_2", "password") + metrics = self._get_user_count_metrics() + self.assertEqual(metrics.get(self._native_key()), "2.0") self._deactivate_user(user_2, self.login("user_2", "password")) metrics = self._get_user_count_metrics() @@ -378,6 +385,9 @@ def test_native_and_appservice_users(self) -> None: def test_deactivated_appservice_user_excluded(self) -> None: """A deactivated appservice user is not counted.""" as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) + metrics = self._get_user_count_metrics() + + self.assertEqual(metrics.get(self._appservice_key()), "1.0") self._deactivate_user(as_user, self.appservice.token) metrics = self._get_user_count_metrics() From a9b58dd78f4b526cf066cf1caceb2fd88cda6164 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 23 Jun 2026 13:20:57 +0100 Subject: [PATCH 19/26] Add a comment --- synapse/app/phone_stats_home.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 7cc69e0cfd6..cf7157acff0 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -57,6 +57,8 @@ # currently either 0 or 1 _stats_process: list[tuple[int, "resource.struct_rusage"]] = [] +# These Gauges are NOT used by the phone home stats function +# but are calculated below. # Gauges to expose monthly active user control metrics current_mau_gauge = Gauge( "synapse_admin_mau_current", From 33b10c6935cd95465e6a29ae328bece4ad4d1cf3 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:51:45 +0100 Subject: [PATCH 20/26] Update synapse/app/phone_stats_home.py Co-authored-by: Eric Eastwood --- synapse/app/phone_stats_home.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index cf7157acff0..238329fe88d 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -57,8 +57,11 @@ # currently either 0 or 1 _stats_process: list[tuple[int, "resource.struct_rusage"]] = [] -# These Gauges are NOT used by the phone home stats function -# but are calculated below. +# FIXME: These gauges should probably be moved somewhere else as they are NOT included +# in the phone home stats payload. It appears that they were historically organized here +# during a refactor to ensure that we only calculate them on the workers designated to +# `hs.config.run_background_tasks` and because they are metrics. +# # Gauges to expose monthly active user control metrics current_mau_gauge = Gauge( "synapse_admin_mau_current", From 41761008a5b22d6d8ea366e0ebfadc3ce62c4188 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:51:56 +0100 Subject: [PATCH 21/26] Update tests/metrics/test_phone_home_stats.py Co-authored-by: Eric Eastwood --- tests/metrics/test_phone_home_stats.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index b53cd1af3d3..f5a8cf722f4 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -385,11 +385,13 @@ def test_native_and_appservice_users(self) -> None: def test_deactivated_appservice_user_excluded(self) -> None: """A deactivated appservice user is not counted.""" as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) + + # Sanity check that all users are counted before deactivation metrics = self._get_user_count_metrics() - self.assertEqual(metrics.get(self._appservice_key()), "1.0") + self._deactivate_user(as_user, self.appservice.token) + # The deactivated user should not be counted metrics = self._get_user_count_metrics() - self.assertIsNone(metrics.get(self._appservice_key())) From 78922747ed1ee932065c09c95076c90241b6dfc9 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:52:20 +0100 Subject: [PATCH 22/26] Apply suggestions from code review Co-authored-by: Eric Eastwood --- tests/metrics/test_phone_home_stats.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index f5a8cf722f4..d5a031149c0 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -307,18 +307,16 @@ def prepare( # Always register the first user as an admin so we can # deactivate. - self.register_user("user_1", "password", admin=True) - self._admin_token = self.login("user_1", "password") + self.register_user("admin", "password", admin=True) + self._admin_token = self.login("admin", "password") def _deactivate_user(self, user_id: str, access_token: str) -> None: """ - Helper to deactivate a user using the /account/deactivate endpoint, optionally - with erasure + Helper to deactivate a user using the /account/deactivate endpoint Args: user_id: the string formatted mxid(not a UserID) access_token: the user's access token - erase: bool of if this should be a full erasure request """ request_data = { "auth": { @@ -344,7 +342,8 @@ def _deactivate_user(self, user_id: str, access_token: str) -> None: self.assertEqual(channel.code, 200, channel.json_body) def _get_user_count_metrics(self) -> dict[str, str]: - # Ensure we have the latest stats by advancing the timer. + # Ensure we have the latest stats by waiting enough time for the + # counting loop to run again self.reactor.advance(COUNT_USERS_INTERVAL.as_secs()) return get_latest_metrics() @@ -365,12 +364,15 @@ def test_two_native_users(self) -> None: def test_deactivated_native_user_excluded(self) -> None: """A deactivated native user is not counted.""" user_2 = self.register_user("user_2", "password") + + # Sanity check that all users are counted before deactivation metrics = self._get_user_count_metrics() self.assertEqual(metrics.get(self._native_key()), "2.0") + self._deactivate_user(user_2, self.login("user_2", "password")) + # The deactivated user should not be counted metrics = self._get_user_count_metrics() - self.assertEqual(metrics.get(self._native_key()), "1.0") def test_native_and_appservice_users(self) -> None: From 539a0c919657c11a05c7da7abd9521b77f2e9e9b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 25 Jun 2026 07:55:15 +0100 Subject: [PATCH 23/26] comment COUNT_USERS_INTERVAL value source. --- synapse/app/phone_stats_home.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 238329fe88d..cc5ccd0774e 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -50,7 +50,8 @@ COUNT_USERS_INTERVAL = Duration(minutes=5) """ -We recalculate synapse_non_deactivated_user_count every 5 minutes. +We recalculate synapse_non_deactivated_user_count every 5 minutes, which allows +for a reasonable level of accuracy without consuming too much database time. """ # Contains the list of processes we will be monitoring From 49ed33efd37d8857c1c99dc899097a16f2041f8b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 25 Jun 2026 08:23:37 +0100 Subject: [PATCH 24/26] use poetry 2.4.1 for lock --- poetry.lock | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index caac8763f86..2d03b641e84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -284,6 +284,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -1061,7 +1062,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" @@ -1863,6 +1864,7 @@ files = [ {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, ] +markers = {main = "extra == \"test\""} [package.extras] dev = ["jinja2"] @@ -2106,11 +2108,11 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] +markers = {main = "implementation_name != \"PyPy\"", dev = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" @@ -3215,7 +3217,7 @@ files = [ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] -markers = {main = "python_version < \"3.14\""} +markers = {main = "python_version < \"3.11\""} [[package]] name = "tornado" @@ -3256,7 +3258,7 @@ jinja2 = "*" tomli = {version = "*", markers = "python_version < \"3.11\""} [package.extras] -dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"] +dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"] [[package]] name = "treq" @@ -3751,4 +3753,4 @@ url-preview = ["lxml"] [metadata] lock-version = "2.1" python-versions = ">=3.10.0,<4.0.0" -content-hash = "54037a80ccc59f29296ad2290a6f9c45905d93c9946c2fcb69ca666011eeeb1a" +content-hash = "53ecef32887866656603697f9e11043dae7b2917b1a2e7727f5133f604acf9de" From 4f403394334ef402511003710ed6627c17754815 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 25 Jun 2026 10:36:20 +0100 Subject: [PATCH 25/26] lint --- tests/metrics/test_phone_home_stats.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index d5a031149c0..6e0e71514c5 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -364,11 +364,11 @@ def test_two_native_users(self) -> None: def test_deactivated_native_user_excluded(self) -> None: """A deactivated native user is not counted.""" user_2 = self.register_user("user_2", "password") - + # Sanity check that all users are counted before deactivation metrics = self._get_user_count_metrics() self.assertEqual(metrics.get(self._native_key()), "2.0") - + self._deactivate_user(user_2, self.login("user_2", "password")) # The deactivated user should not be counted @@ -387,11 +387,11 @@ def test_native_and_appservice_users(self) -> None: def test_deactivated_appservice_user_excluded(self) -> None: """A deactivated appservice user is not counted.""" as_user, _ = self.register_appservice_user("as_user_1", self.appservice.token) - + # Sanity check that all users are counted before deactivation metrics = self._get_user_count_metrics() self.assertEqual(metrics.get(self._appservice_key()), "1.0") - + self._deactivate_user(as_user, self.appservice.token) # The deactivated user should not be counted From c3003e10f4b2a1afcb92f2442c242ed089a6abb2 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 25 Jun 2026 10:02:20 -0500 Subject: [PATCH 26/26] Use more standard import --- tests/metrics/test_metrics.py | 3 +-- tests/metrics/test_phone_home_stats.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/metrics/test_metrics.py b/tests/metrics/test_metrics.py index 14eddd229e5..cc9b8463412 100644 --- a/tests/metrics/test_metrics.py +++ b/tests/metrics/test_metrics.py @@ -41,8 +41,7 @@ from synapse.util.caches.deferred_cache import DeferredCache from tests import unittest - -from . import get_latest_metrics +from tests.metrics import get_latest_metrics def get_sample_labels_value(sample: Sample) -> tuple[dict[str, str], float]: diff --git a/tests/metrics/test_phone_home_stats.py b/tests/metrics/test_phone_home_stats.py index 6e0e71514c5..a1e0c978a18 100644 --- a/tests/metrics/test_phone_home_stats.py +++ b/tests/metrics/test_phone_home_stats.py @@ -29,10 +29,9 @@ from synapse.util.clock import Clock from tests import unittest +from tests.metrics import get_latest_metrics from tests.server import ThreadedMemoryReactorClock -from . import get_latest_metrics - TEST_REPORT_STATS_ENDPOINT = "https://fake.endpoint/stats" TEST_SERVER_CONTEXT = "test-server-context"