Skip to content

Commit e16fbdc

Browse files
Update metrics linting to be able to handle custom metrics (#18733)
Part of #18592
1 parent e43a1ce commit e16fbdc

File tree

10 files changed

+293
-35
lines changed

10 files changed

+293
-35
lines changed

changelog.d/18733.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update metrics linting to be able to handle custom metrics.

mypy.ini

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
[mypy]
22
namespace_packages = True
3-
plugins = pydantic.mypy, mypy_zope:plugin, scripts-dev/mypy_synapse_plugin.py
3+
# Our custom mypy plugin should remain first in this list.
4+
#
5+
# mypy has a limitation where it only chooses the first plugin that returns a non-None
6+
# value for each hook (known-limitation, c.f.
7+
# https://github.com/python/mypy/issues/19524). We workaround this by putting our custom
8+
# plugin first in the plugin order and then manually calling any other conflicting
9+
# plugin hooks in our own plugin followed by our own checks.
10+
#
11+
# If you add a new plugin, make sure to check whether the hooks being used conflict with
12+
# our custom plugin hooks and if so, manually call the other plugin's hooks in our
13+
# custom plugin. (also applies to if the plugin is updated in the future)
14+
plugins = scripts-dev/mypy_synapse_plugin.py, pydantic.mypy, mypy_zope:plugin
415
follow_imports = normal
516
show_error_codes = True
617
show_traceback = True
@@ -99,3 +110,6 @@ ignore_missing_imports = True
99110

100111
[mypy-multipart.*]
101112
ignore_missing_imports = True
113+
114+
[mypy-mypy_zope.*]
115+
ignore_missing_imports = True

scripts-dev/mypy_synapse_plugin.py

Lines changed: 232 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,21 @@
2323
can crop up, e.g the cache descriptors.
2424
"""
2525

26-
from typing import Callable, Optional, Tuple, Type, Union
26+
import enum
27+
from typing import Callable, Mapping, Optional, Tuple, Type, Union
2728

29+
import attr
2830
import mypy.types
2931
from mypy.erasetype import remove_instance_last_known_values
3032
from mypy.errorcodes import ErrorCode
3133
from mypy.nodes import ARG_NAMED_OPT, ListExpr, NameExpr, TempNode, TupleExpr, Var
3234
from mypy.plugin import (
35+
ClassDefContext,
36+
Context,
3337
FunctionLike,
3438
FunctionSigContext,
3539
MethodSigContext,
40+
MypyFile,
3641
Plugin,
3742
)
3843
from mypy.typeops import bind_self
@@ -41,32 +46,169 @@
4146
CallableType,
4247
Instance,
4348
NoneType,
49+
Options,
4450
TupleType,
4551
TypeAliasType,
4652
TypeVarType,
4753
UninhabitedType,
4854
UnionType,
4955
)
56+
from mypy_zope import plugin as mypy_zope_plugin
57+
from pydantic.mypy import plugin as mypy_pydantic_plugin
5058

5159
PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL = ErrorCode(
5260
"missing-server-name-label",
5361
"`SERVER_NAME_LABEL` required in metric",
5462
category="per-homeserver-tenant-metrics",
5563
)
5664

65+
PROMETHEUS_METRIC_MISSING_FROM_LIST_TO_CHECK = ErrorCode(
66+
"metric-type-missing-from-list",
67+
"Every Prometheus metric type must be included in the `prometheus_metric_fullname_to_label_arg_map`.",
68+
category="per-homeserver-tenant-metrics",
69+
)
70+
71+
72+
class Sentinel(enum.Enum):
73+
# defining a sentinel in this way allows mypy to correctly handle the
74+
# type of a dictionary lookup and subsequent type narrowing.
75+
UNSET_SENTINEL = object()
76+
77+
78+
@attr.s(auto_attribs=True)
79+
class ArgLocation:
80+
keyword_name: str
81+
"""
82+
The keyword argument name for this argument
83+
"""
84+
position: int
85+
"""
86+
The 0-based positional index of this argument
87+
"""
88+
89+
90+
prometheus_metric_fullname_to_label_arg_map: Mapping[str, Optional[ArgLocation]] = {
91+
# `Collector` subclasses:
92+
"prometheus_client.metrics.MetricWrapperBase": ArgLocation("labelnames", 2),
93+
"prometheus_client.metrics.Counter": ArgLocation("labelnames", 2),
94+
"prometheus_client.metrics.Histogram": ArgLocation("labelnames", 2),
95+
"prometheus_client.metrics.Gauge": ArgLocation("labelnames", 2),
96+
"prometheus_client.metrics.Summary": ArgLocation("labelnames", 2),
97+
"prometheus_client.metrics.Info": ArgLocation("labelnames", 2),
98+
"prometheus_client.metrics.Enum": ArgLocation("labelnames", 2),
99+
"synapse.metrics.LaterGauge": ArgLocation("labelnames", 2),
100+
"synapse.metrics.InFlightGauge": ArgLocation("labels", 2),
101+
"synapse.metrics.GaugeBucketCollector": ArgLocation("labelnames", 2),
102+
"prometheus_client.registry.Collector": None,
103+
"prometheus_client.registry._EmptyCollector": None,
104+
"prometheus_client.registry.CollectorRegistry": None,
105+
"prometheus_client.process_collector.ProcessCollector": None,
106+
"prometheus_client.platform_collector.PlatformCollector": None,
107+
"prometheus_client.gc_collector.GCCollector": None,
108+
"synapse.metrics._gc.GCCounts": None,
109+
"synapse.metrics._gc.PyPyGCStats": None,
110+
"synapse.metrics._reactor_metrics.ReactorLastSeenMetric": None,
111+
"synapse.metrics.CPUMetrics": None,
112+
"synapse.metrics.jemalloc.JemallocCollector": None,
113+
"synapse.util.metrics.DynamicCollectorRegistry": None,
114+
"synapse.metrics.background_process_metrics._Collector": None,
115+
#
116+
# `Metric` subclasses:
117+
"prometheus_client.metrics_core.Metric": None,
118+
"prometheus_client.metrics_core.UnknownMetricFamily": ArgLocation("labels", 3),
119+
"prometheus_client.metrics_core.CounterMetricFamily": ArgLocation("labels", 3),
120+
"prometheus_client.metrics_core.GaugeMetricFamily": ArgLocation("labels", 3),
121+
"prometheus_client.metrics_core.SummaryMetricFamily": ArgLocation("labels", 3),
122+
"prometheus_client.metrics_core.InfoMetricFamily": ArgLocation("labels", 3),
123+
"prometheus_client.metrics_core.HistogramMetricFamily": ArgLocation("labels", 3),
124+
"prometheus_client.metrics_core.GaugeHistogramMetricFamily": ArgLocation(
125+
"labels", 4
126+
),
127+
"prometheus_client.metrics_core.StateSetMetricFamily": ArgLocation("labels", 3),
128+
"synapse.metrics.GaugeHistogramMetricFamilyWithLabels": ArgLocation(
129+
"labelnames", 4
130+
),
131+
}
132+
"""
133+
Map from the fullname of the Prometheus `Metric`/`Collector` classes to the keyword
134+
argument name and positional index of the label names. This map is useful because
135+
different metrics have different signatures for passing in label names and we just need
136+
to know where to look.
137+
138+
This map should include any metrics that we collect with Prometheus. Which corresponds
139+
to anything that inherits from `prometheus_client.registry.Collector`
140+
(`synapse.metrics._types.Collector`) or `prometheus_client.metrics_core.Metric`. The
141+
exhaustiveness of this list is enforced by `analyze_prometheus_metric_classes`.
142+
143+
The entries with `None` always fail the lint because they don't have a `labelnames`
144+
argument (therefore, no `SERVER_NAME_LABEL`), but we include them here so that people
145+
can notice and manually allow via a type ignore comment as the source of truth
146+
should be in the source code.
147+
"""
148+
149+
# Unbound at this point because we don't know the mypy version yet.
150+
# This is set in the `plugin(...)` function below.
151+
MypyPydanticPluginClass: Type[Plugin]
152+
MypyZopePluginClass: Type[Plugin]
153+
57154

58155
class SynapsePlugin(Plugin):
156+
def __init__(self, options: Options):
157+
super().__init__(options)
158+
self.mypy_pydantic_plugin = MypyPydanticPluginClass(options)
159+
self.mypy_zope_plugin = MypyZopePluginClass(options)
160+
161+
def set_modules(self, modules: dict[str, MypyFile]) -> None:
162+
"""
163+
This is called by mypy internals. We have to override this to ensure it's also
164+
called for any other plugins that we're manually handling.
165+
166+
Here is how mypy describes it:
167+
168+
> [`self._modules`] can't be set in `__init__` because it is executed too soon
169+
> in `build.py`. Therefore, `build.py` *must* set it later before graph processing
170+
> starts by calling `set_modules()`.
171+
"""
172+
super().set_modules(modules)
173+
self.mypy_pydantic_plugin.set_modules(modules)
174+
self.mypy_zope_plugin.set_modules(modules)
175+
176+
def get_base_class_hook(
177+
self, fullname: str
178+
) -> Optional[Callable[[ClassDefContext], None]]:
179+
def _get_base_class_hook(ctx: ClassDefContext) -> None:
180+
# Run any `get_base_class_hook` checks from other plugins first.
181+
#
182+
# Unfortunately, because mypy only chooses the first plugin that returns a
183+
# non-None value (known-limitation, c.f.
184+
# https://github.com/python/mypy/issues/19524), we workaround this by
185+
# putting our custom plugin first in the plugin order and then calling the
186+
# other plugin's hook manually followed by our own checks.
187+
if callback := self.mypy_pydantic_plugin.get_base_class_hook(fullname):
188+
callback(ctx)
189+
if callback := self.mypy_zope_plugin.get_base_class_hook(fullname):
190+
callback(ctx)
191+
192+
# Now run our own checks
193+
analyze_prometheus_metric_classes(ctx)
194+
195+
return _get_base_class_hook
196+
59197
def get_function_signature_hook(
60198
self, fullname: str
61199
) -> Optional[Callable[[FunctionSigContext], FunctionLike]]:
62-
if fullname in (
63-
"prometheus_client.metrics.Counter",
64-
"prometheus_client.metrics.Histogram",
65-
"prometheus_client.metrics.Gauge",
66-
# TODO: Add other prometheus_client metrics that need checking as we
67-
# refactor, see https://github.com/element-hq/synapse/issues/18592
68-
):
69-
return check_prometheus_metric_instantiation
200+
# Strip off the unique identifier for classes that are dynamically created inside
201+
# functions. ex. `synapse.metrics.jemalloc.JemallocCollector@185` (this is the line
202+
# number)
203+
if "@" in fullname:
204+
fullname = fullname.split("@", 1)[0]
205+
206+
# Look for any Prometheus metrics to make sure they have the `SERVER_NAME_LABEL`
207+
# label.
208+
if fullname in prometheus_metric_fullname_to_label_arg_map.keys():
209+
# Because it's difficult to determine the `fullname` of the function in the
210+
# callback, let's just pass it in while we have it.
211+
return lambda ctx: check_prometheus_metric_instantiation(ctx, fullname)
70212

71213
return None
72214

@@ -90,7 +232,44 @@ def get_method_signature_hook(
90232
return None
91233

92234

93-
def check_prometheus_metric_instantiation(ctx: FunctionSigContext) -> CallableType:
235+
def analyze_prometheus_metric_classes(ctx: ClassDefContext) -> None:
236+
"""
237+
Cross-check the list of Prometheus metric classes against the
238+
`prometheus_metric_fullname_to_label_arg_map` to ensure the list is exhaustive and
239+
up-to-date.
240+
"""
241+
242+
fullname = ctx.cls.fullname
243+
# Strip off the unique identifier for classes that are dynamically created inside
244+
# functions. ex. `synapse.metrics.jemalloc.JemallocCollector@185` (this is the line
245+
# number)
246+
if "@" in fullname:
247+
fullname = fullname.split("@", 1)[0]
248+
249+
if any(
250+
ancestor_type.fullname
251+
in (
252+
# All of the Prometheus metric classes inherit from the `Collector`.
253+
"prometheus_client.registry.Collector",
254+
"synapse.metrics._types.Collector",
255+
# And custom metrics that inherit from `Metric`.
256+
"prometheus_client.metrics_core.Metric",
257+
)
258+
for ancestor_type in ctx.cls.info.mro
259+
):
260+
if fullname not in prometheus_metric_fullname_to_label_arg_map:
261+
ctx.api.fail(
262+
f"Expected {fullname} to be in `prometheus_metric_fullname_to_label_arg_map`, "
263+
f"but it was not found. This is a problem with our custom mypy plugin. "
264+
f"Please add it to the map.",
265+
Context(),
266+
code=PROMETHEUS_METRIC_MISSING_FROM_LIST_TO_CHECK,
267+
)
268+
269+
270+
def check_prometheus_metric_instantiation(
271+
ctx: FunctionSigContext, fullname: str
272+
) -> CallableType:
94273
"""
95274
Ensure that the `prometheus_client` metrics include the `SERVER_NAME_LABEL` label
96275
when instantiated.
@@ -103,18 +282,49 @@ def check_prometheus_metric_instantiation(ctx: FunctionSigContext) -> CallableTy
103282
Python garbage collection, and Twisted reactor tick time, which shouldn't have the
104283
`SERVER_NAME_LABEL`. In those cases, use a type ignore comment to disable the
105284
check, e.g. `# type: ignore[missing-server-name-label]`.
285+
286+
Args:
287+
ctx: The `FunctionSigContext` from mypy.
288+
fullname: The fully qualified name of the function being called,
289+
e.g. `"prometheus_client.metrics.Counter"`
106290
"""
107291
# The true signature, this isn't being modified so this is what will be returned.
108-
signature: CallableType = ctx.default_signature
292+
signature = ctx.default_signature
293+
294+
# Find where the label names argument is in the function signature.
295+
arg_location = prometheus_metric_fullname_to_label_arg_map.get(
296+
fullname, Sentinel.UNSET_SENTINEL
297+
)
298+
assert arg_location is not Sentinel.UNSET_SENTINEL, (
299+
f"Expected to find {fullname} in `prometheus_metric_fullname_to_label_arg_map`, "
300+
f"but it was not found. This is a problem with our custom mypy plugin. "
301+
f"Please add it to the map. Context: {ctx.context}"
302+
)
303+
# People should be using `# type: ignore[missing-server-name-label]` for
304+
# process-level metrics that should not have the `SERVER_NAME_LABEL`.
305+
if arg_location is None:
306+
ctx.api.fail(
307+
f"{signature.name} does not have a `labelnames`/`labels` argument "
308+
"(if this is untrue, update `prometheus_metric_fullname_to_label_arg_map` "
309+
"in our custom mypy plugin) and should probably have a type ignore comment, "
310+
"e.g. `# type: ignore[missing-server-name-label]`. The reason we don't "
311+
"automatically ignore this is the source of truth should be in the source code.",
312+
ctx.context,
313+
code=PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL,
314+
)
315+
return signature
109316

110317
# Sanity check the arguments are still as expected in this version of
111318
# `prometheus_client`. ex. `Counter(name, documentation, labelnames, ...)`
112319
#
113320
# `signature.arg_names` should be: ["name", "documentation", "labelnames", ...]
114-
if len(signature.arg_names) < 3 or signature.arg_names[2] != "labelnames":
321+
if (
322+
len(signature.arg_names) < (arg_location.position + 1)
323+
or signature.arg_names[arg_location.position] != arg_location.keyword_name
324+
):
115325
ctx.api.fail(
116-
f"Expected the 3rd argument of {signature.name} to be 'labelnames', but got "
117-
f"{signature.arg_names[2]}",
326+
f"Expected argument number {arg_location.position + 1} of {signature.name} to be `labelnames`/`labels`, "
327+
f"but got {signature.arg_names[arg_location.position]}",
118328
ctx.context,
119329
)
120330
return signature
@@ -137,7 +347,11 @@ def check_prometheus_metric_instantiation(ctx: FunctionSigContext) -> CallableTy
137347
# ...
138348
# ]
139349
# ```
140-
labelnames_arg_expression = ctx.args[2][0] if len(ctx.args[2]) > 0 else None
350+
labelnames_arg_expression = (
351+
ctx.args[arg_location.position][0]
352+
if len(ctx.args[arg_location.position]) > 0
353+
else None
354+
)
141355
if isinstance(labelnames_arg_expression, (ListExpr, TupleExpr)):
142356
# Check if the `labelnames` argument includes the `server_name` label (`SERVER_NAME_LABEL`).
143357
for labelname_expression in labelnames_arg_expression.items:
@@ -476,10 +690,13 @@ def is_cacheable(
476690

477691

478692
def plugin(version: str) -> Type[SynapsePlugin]:
693+
global MypyPydanticPluginClass, MypyZopePluginClass
479694
# This is the entry point of the plugin, and lets us deal with the fact
480695
# that the mypy plugin interface is *not* stable by looking at the version
481696
# string.
482697
#
483698
# However, since we pin the version of mypy Synapse uses in CI, we don't
484699
# really care.
700+
MypyPydanticPluginClass = mypy_pydantic_plugin(version)
701+
MypyZopePluginClass = mypy_zope_plugin(version)
485702
return SynapsePlugin

0 commit comments

Comments
 (0)