mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-15 04:50:25 +00:00
Add metric to count number of non-deactivated users. (#19848)
This reports the total count of users (split by appservice) which is meant to be the monthless counterpart to the MAU metric. Context: > So this is largely for billing purposes and wanting to know the change in the number of users. If a user is deactivated then we no longer want to count them. Consumers *might* want to count appservice users, and maybe count them based on the service (perhaps you change more for users under bridge X or bridge Y). > > *-- https://github.com/element-hq/synapse/pull/19848#discussion_r3402216234*
This commit is contained in:
@@ -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`.
|
||||
Generated
+26
-26
@@ -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"
|
||||
|
||||
+2
-3
@@ -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",
|
||||
|
||||
@@ -48,10 +48,21 @@ PHONE_HOME_INTERVAL = Duration(hours=3)
|
||||
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_reserved_users_mau_gauge = Gauge(
|
||||
"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 @@ def start_phone_stats_home(hs: "HomeServer") -> 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(
|
||||
|
||||
@@ -1133,6 +1133,33 @@ class RegistrationWorkerStore(StatsStore, CacheInvalidationWorkerStore):
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
|
||||
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
|
||||
|
||||
@@ -41,6 +41,7 @@ from synapse.metrics import (
|
||||
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 @@ class LaterGaugeTests(unittest.HomeserverTestCase):
|
||||
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
|
||||
|
||||
@@ -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 @@
|
||||
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
|
||||
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 @@ class PhoneHomeStatsTestCase(unittest.HomeserverTestCase):
|
||||
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()))
|
||||
|
||||
Reference in New Issue
Block a user