2828import mypy .types
2929from mypy .erasetype import remove_instance_last_known_values
3030from mypy .errorcodes import ErrorCode
31- from mypy .nodes import ARG_NAMED_OPT , TempNode , Var
32- from mypy .plugin import FunctionSigContext , MethodSigContext , Plugin
31+ from mypy .nodes import ARG_NAMED_OPT , ListExpr , NameExpr , TempNode , Var
32+ from mypy .plugin import (
33+ FunctionLike ,
34+ FunctionSigContext ,
35+ MethodSigContext ,
36+ Plugin ,
37+ )
3338from mypy .typeops import bind_self
3439from mypy .types import (
3540 AnyType ,
4348 UnionType ,
4449)
4550
51+ PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL = ErrorCode (
52+ "missing-server-name-label" ,
53+ "`SERVER_NAME_LABEL` required in metric" ,
54+ category = "per-homeserver-tenant-metrics" ,
55+ )
56+
4657
4758class SynapsePlugin (Plugin ):
59+ def get_function_signature_hook (
60+ self , fullname : str
61+ ) -> Optional [Callable [[FunctionSigContext ], FunctionLike ]]:
62+ if fullname in (
63+ "prometheus_client.metrics.Gauge" ,
64+ # TODO: Add other prometheus_client metrics that need checking as we
65+ # refactor, see https://github.com/element-hq/synapse/issues/18592
66+ ):
67+ return check_prometheus_metric_instantiation
68+
69+ return None
70+
4871 def get_method_signature_hook (
4972 self , fullname : str
5073 ) -> Optional [Callable [[MethodSigContext ], CallableType ]]:
74+ # print(f"m fullname={fullname}")
5175 if fullname .startswith (
5276 (
5377 "synapse.util.caches.descriptors.CachedFunction.__call__" ,
@@ -65,6 +89,85 @@ def get_method_signature_hook(
6589 return None
6690
6791
92+ def check_prometheus_metric_instantiation (ctx : FunctionSigContext ) -> CallableType :
93+ """
94+ Ensure that the `prometheus_client` metrics include the `SERVER_NAME_LABEL` label
95+ when instantiated.
96+
97+ This is important because we support multiple Synapse instances running in the same
98+ process, where all metrics share a single global `REGISTRY`. The `server_name` label
99+ ensures metrics are correctly separated by homeserver.
100+
101+ There are also some metrics that apply at the process level, such as CPU usage,
102+ Python garbage collection, Twisted reactor tick time which shouldn't have the
103+ `SERVER_NAME_LABEL`. In those cases, use use a type ignore comment to disable the
104+ check, e.g. `# type: ignore[missing-server-name-label]`.
105+ """
106+ # The true signature, this isn't being modified so this is what will be returned.
107+ signature : CallableType = ctx .default_signature
108+
109+ # Sanity check the arguments are still as expected in this version of
110+ # `prometheus_client`. ex. `Counter(name, documentation, labelnames, ...)`
111+ #
112+ # `signature.arg_names` should be: ["name", "documentation", "labelnames", ...]
113+ if len (signature .arg_names ) < 3 or signature .arg_names [2 ] != "labelnames" :
114+ ctx .api .fail (
115+ f"Expected the 3rd argument of { signature .name } to be 'labelnames', but got "
116+ f"{ signature .arg_names [2 ]} " ,
117+ ctx .context ,
118+ )
119+ return signature
120+
121+ # Ensure mypy is passing the correct number of arguments because we are doing some
122+ # dirty indexing into `ctx.args` later on.
123+ assert len (ctx .args ) == len (signature .arg_names ), (
124+ f"Expected the list of arguments in the { signature .name } signature ({ len (signature .arg_names )} )"
125+ f"to match the number of arguments from the function signature context ({ len (ctx .args )} )"
126+ )
127+
128+ # Check if the `labelnames` argument includes `SERVER_NAME_LABEL`
129+ #
130+ # `ctx.args` should look like this:
131+ # ```
132+ # [
133+ # [StrExpr("name")],
134+ # [StrExpr("documentation")],
135+ # [ListExpr([StrExpr("label1"), StrExpr("label2")])]
136+ # ...
137+ # ]
138+ # ```
139+ labelnames_arg_expression = ctx .args [2 ][0 ] if len (ctx .args [2 ]) > 0 else None
140+ if isinstance (labelnames_arg_expression , ListExpr ):
141+ # Check if the `labelnames` argument includes the `server_name` label (`SERVER_NAME_LABEL`).
142+ for labelname_expression in labelnames_arg_expression .items :
143+ if (
144+ isinstance (labelname_expression , NameExpr )
145+ and labelname_expression .fullname == "synapse.metrics.SERVER_NAME_LABEL"
146+ ):
147+ # Found the `SERVER_NAME_LABEL`, all good!
148+ break
149+ else :
150+ ctx .api .fail (
151+ f"Expected { signature .name } to include `SERVER_NAME_LABEL` in the list of labels. "
152+ "If this is a process-level metric (vs homeserver-level), use a type ignore comment "
153+ "to disable this check." ,
154+ ctx .context ,
155+ code = PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL ,
156+ )
157+ else :
158+ ctx .api .fail (
159+ f"Expected the `labelnames` argument of { signature .name } to be a list of label names "
160+ f"(including `SERVER_NAME_LABEL`), but got { labelnames_arg_expression } . "
161+ "If this is a process-level metric (vs homeserver-level), use a type ignore comment "
162+ "to disable this check." ,
163+ ctx .context ,
164+ code = PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL ,
165+ )
166+ return signature
167+
168+ return signature
169+
170+
68171def _get_true_return_type (signature : CallableType ) -> mypy .types .Type :
69172 """
70173 Get the "final" return type of a callable which might return an Awaitable/Deferred.
0 commit comments