diff --git a/changelog.d/19848.feature b/changelog.d/19848.feature new file mode 100644 index 00000000000..a7b891e547a --- /dev/null +++ b/changelog.d/19848.feature @@ -0,0 +1 @@ +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/poetry.lock b/poetry.lock index 86cadd3ac39..2d03b641e84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, @@ -532,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"}, @@ -557,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"}, @@ -607,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"}, @@ -890,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"}, @@ -931,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"}, ] @@ -1123,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"}, @@ -1240,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"}, @@ -1548,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"}, @@ -1833,7 +1833,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"}, ] @@ -2032,7 +2032,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"}, @@ -2050,7 +2050,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"}, ] @@ -2066,7 +2066,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"}, ] @@ -2346,7 +2346,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"}, @@ -2478,7 +2478,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"}, @@ -2503,7 +2503,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"}, @@ -2531,7 +2531,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"}, @@ -2935,7 +2935,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"}, @@ -3135,7 +3135,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"}, @@ -3151,7 +3151,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"}, ] @@ -3226,7 +3226,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.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163"}, {file = "tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100"}, @@ -3358,7 +3358,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"}, @@ -3619,7 +3619,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"}, @@ -3640,7 +3640,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"}, @@ -3753,4 +3753,4 @@ url-preview = ["lxml"] [metadata] lock-version = "2.1" python-versions = ">=3.10.0,<4.0.0" -content-hash = "88f1913a5839cb3cf2e98ff88dbbe1223f59e00bc428c1cd15b3d2ae906675aa" +content-hash = "53ecef32887866656603697f9e11043dae7b2917b1a2e7727f5133f604acf9de" diff --git a/pyproject.toml b/pyproject.toml index fa70791f7a5..316850bd22d 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", diff --git a/synapse/app/phone_stats_home.py b/synapse/app/phone_stats_home.py index 7b4bf25c280..cc5ccd0774e 100644 --- a/synapse/app/phone_stats_home.py +++ b/synapse/app/phone_stats_home.py @@ -48,10 +48,21 @@ Phone home stats are sent every 3 hours """ +COUNT_USERS_INTERVAL = Duration(minutes=5) +""" +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 # currently either 0 or 1 _stats_process: list[tuple[int, "resource.struct_rusage"]] = [] +# 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", @@ -73,6 +84,11 @@ "Registered users with reserved threepids", labelnames=[SERVER_NAME_LABEL], ) +user_count_gauge = Gauge( + "synapse_non_deactivated_user_count", + "Total non-deactivated user count within the Synapse database, split by appservice", + labelnames=["app_service", SERVER_NAME_LABEL], +) def phone_stats_home( @@ -263,9 +279,35 @@ async def _generate_monthly_active_users() -> 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, Duration(minutes=5)) + 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 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}, + ).set(float(count)) + + return hs.run_as_background_process( + "generate_total_users", + _generate_total_users, + ) + + if hs.config.metrics.enable_metrics: + 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") clock.looping_call( diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py index 2455057b027..4bc1a0be1b0 100644 --- a/synapse/storage/databases/main/registration.py +++ b/synapse/storage/databases/main/registration.py @@ -1133,6 +1133,33 @@ 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(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()) + + 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/__init__.py b/tests/metrics/__init__.py index e69de29bb2d..ea8066ef323 100644 --- a/tests/metrics/__init__.py +++ b/tests/metrics/__init__.py @@ -0,0 +1,40 @@ +# +# 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, +) + + +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..cc9b8463412 100644 --- a/tests/metrics/test_metrics.py +++ b/tests/metrics/test_metrics.py @@ -41,6 +41,7 @@ from synapse.util.caches.deferred_cache import DeferredCache from tests import unittest +from tests.metrics import get_latest_metrics def get_sample_labels_value(sample: Sample) -> tuple[dict[str, str], float]: @@ -390,26 +391,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 dfb88588cdf..a1e0c978a18 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 @@ -12,20 +13,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 ( + COUNT_USERS_INTERVAL, PHONE_HOME_INTERVAL, start_phone_stats_home, ) -from synapse.rest import admin, login, register, room +from synapse.appservice import ApplicationService +from synapse.rest import account, 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 tests import unittest +from tests.metrics import get_latest_metrics from tests.server import ThreadedMemoryReactorClock TEST_REPORT_STATS_ENDPOINT = "https://fake.endpoint/stats" @@ -261,3 +265,134 @@ 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)) + + +class TotalUsersGaugeTestCase(unittest.HomeserverTestCase): + servlets = [ + account.register_servlets, + admin.register_servlets, + register.register_servlets, + login.register_servlets, + ] + + def make_homeserver( + self, reactor: ThreadedMemoryReactorClock, clock: Clock + ) -> HomeServer: + config = self.default_config() + config["enable_metrics"] = True + 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 + ) -> None: + self.store = homeserver.get_datastores().main + self.wait_for_background_updates() + 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("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 + + Args: + user_id: the string formatted mxid(not a UserID) + access_token: the user's access token + """ + request_data = { + "auth": { + "type": "m.login.password", + "user": user_id, + "password": "password", + }, + "erase": False, + } + + # If an appservice calls this, append the user_id + url = ( + f"account/deactivate?user_id={user_id}" + if access_token == self.appservice.token + else "account/deactivate" + ) + channel = self.make_request( + "POST", + url, + request_data, + access_token=access_token, + ) + self.assertEqual(channel.code, 200, channel.json_body) + + def _get_user_count_metrics(self) -> dict[str, str]: + # 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() + + def _native_key(self) -> str: + 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_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.""" + self.register_user("user_2", "password") + + metrics = self._get_user_count_metrics() + + self.assertEqual(metrics.get(self._native_key()), "2.0") + + 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: + """A native user and an appservice user are counted under separate labels.""" + 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) + + # 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()))