Skip to content

Commit 886ac45

Browse files
Centralize check registration / running logic (#85)
* Add central check registry / runner * Add pytest fixture to automatically clear check registry * Use centralized check registry in Flask * Use centralized check registry in Sanic * Use correct path for django test urlconf * Delete Django level_to_text helper * Delete commented-out default Django check * Refactor reset_checks django fixture - remove version check - autouse so that checks are cleared after every test run * Use centralized check runner in Django * Tests for running checks * Add flask tests for registering migration check with app * Adjust wording in docstring and logs * Remove superfluous return * Restore legacy methods with deprecation warning * Rename init_check to register_partial * Fix posargs passing to pytest * Remove problematic test * Revert "Remove problematic test" This reverts commit 80ef729. * Use a fixture to add logger handlers for request.summary * Reset logging before loading ini * Run black 24.1a1 --------- Co-authored-by: Mathieu Leplatre <mathieu@mozilla.com>
1 parent a7cad34 commit 886ac45

File tree

14 files changed

+676
-294
lines changed

14 files changed

+676
-294
lines changed

src/dockerflow/checks/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@
1616
Warning,
1717
level_to_text,
1818
)
19+
from .registry import ( # noqa
20+
clear_checks,
21+
get_checks,
22+
register,
23+
register_partial,
24+
run_checks,
25+
run_checks_async,
26+
)

src/dockerflow/checks/registry.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, you can obtain one at http://mozilla.org/MPL/2.0/.
4+
import asyncio
5+
import functools
6+
import inspect
7+
import logging
8+
from dataclasses import dataclass
9+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
10+
11+
from .messages import CheckMessage, level_to_text
12+
13+
logger = logging.getLogger(__name__)
14+
15+
CheckFn = Callable[..., List[CheckMessage]]
16+
17+
_REGISTERED_CHECKS = {}
18+
19+
20+
def _iscoroutinefunction_or_partial(obj):
21+
"""
22+
Determine if the provided object is a coroutine function or a partial function
23+
that wraps a coroutine function.
24+
25+
This function should be removed when we drop support for Python 3.7, as this is
26+
handled directly by `inspect.iscoroutinefunction` in Python 3.8.
27+
"""
28+
while isinstance(obj, functools.partial):
29+
obj = obj.func
30+
return inspect.iscoroutinefunction(obj)
31+
32+
33+
def register(func=None, name=None):
34+
"""
35+
Register a check callback to be executed from
36+
the heartbeat endpoint.
37+
"""
38+
if func is None:
39+
return functools.partial(register, name=name)
40+
41+
if name is None:
42+
name = func.__name__
43+
44+
logger.debug("Register Dockerflow check %s", name)
45+
46+
if _iscoroutinefunction_or_partial(func):
47+
48+
@functools.wraps(func)
49+
async def decorated_function_asyc(*args, **kwargs):
50+
logger.debug("Called Dockerflow check %s", name)
51+
return await func(*args, **kwargs)
52+
53+
_REGISTERED_CHECKS[name] = decorated_function_asyc
54+
return decorated_function_asyc
55+
56+
@functools.wraps(func)
57+
def decorated_function(*args, **kwargs):
58+
logger.debug("Called Dockerflow check %s", name)
59+
return func(*args, **kwargs)
60+
61+
_REGISTERED_CHECKS[name] = decorated_function
62+
return decorated_function
63+
64+
65+
def register_partial(func, *args, name=None):
66+
"""
67+
Registers a given check callback that will be called with the provided
68+
arguments using `functools.partial()`. For example:
69+
70+
.. code-block:: python
71+
72+
dockerflow.register_partial(check_redis_connected, redis)
73+
74+
"""
75+
if name is None:
76+
name = func.__name__
77+
78+
logger.debug("Register Dockerflow check %s with partially applied arguments" % name)
79+
partial = functools.wraps(func)(functools.partial(func, *args))
80+
return register(func=partial, name=name)
81+
82+
83+
def get_checks():
84+
return _REGISTERED_CHECKS
85+
86+
87+
def clear_checks():
88+
global _REGISTERED_CHECKS
89+
_REGISTERED_CHECKS = dict()
90+
91+
92+
@dataclass
93+
class ChecksResults:
94+
"""
95+
Represents the results of running checks.
96+
97+
This data class holds the results of running a collection of checks. It includes
98+
details about each check's outcome, their statuses, and the overall result level.
99+
100+
:param details: A dictionary containing detailed information about each check's
101+
outcome, with check names as keys and dictionaries of details as values.
102+
:type details: Dict[str, Dict[str, Any]]
103+
104+
:param statuses: A dictionary containing the status of each check, with check names
105+
as keys and statuses as values (e.g., 'pass', 'fail', 'warning').
106+
:type statuses: Dict[str, str]
107+
108+
:param level: An integer representing the overall result level of the checks
109+
:type level: int
110+
"""
111+
112+
details: Dict[str, Dict[str, Any]]
113+
statuses: Dict[str, str]
114+
level: int
115+
116+
117+
async def _run_check_async(check):
118+
name, check_fn = check
119+
if _iscoroutinefunction_or_partial(check_fn):
120+
errors = await check_fn()
121+
else:
122+
loop = asyncio.get_event_loop()
123+
errors = await loop.run_in_executor(None, check_fn)
124+
125+
return (name, errors)
126+
127+
128+
async def run_checks_async(
129+
checks: Iterable[Tuple[str, CheckFn]],
130+
silenced_check_ids: Optional[Iterable[str]] = None,
131+
) -> ChecksResults:
132+
"""
133+
Run checks concurrently and return the results.
134+
135+
Executes a collection of checks concurrently, supporting both synchronous and
136+
asynchronous checks. The results include the outcome of each check and can be
137+
further processed.
138+
139+
:param checks: An iterable of tuples where each tuple contains a check name and a
140+
check function.
141+
:type checks: Iterable[Tuple[str, CheckFn]]
142+
143+
:param silenced_check_ids: A list of check IDs that should be omitted from the
144+
results.
145+
:type silenced_check_ids: List[str]
146+
147+
:return: An instance of ChecksResults containing detailed information about each
148+
check's outcome, their statuses, and the overall result level.
149+
:rtype: ChecksResults
150+
"""
151+
if silenced_check_ids is None:
152+
silenced_check_ids = []
153+
154+
tasks = (_run_check_async(check) for check in checks)
155+
results = await asyncio.gather(*tasks)
156+
return _build_results_payload(results, silenced_check_ids)
157+
158+
159+
def run_checks(
160+
checks: Iterable[Tuple[str, CheckFn]],
161+
silenced_check_ids: Optional[Iterable[str]] = None,
162+
) -> ChecksResults:
163+
"""
164+
Run checks synchronously and return the results.
165+
166+
Executes a collection of checks and returns the results. The results include the
167+
outcome of each check and can be further processed.
168+
169+
:param checks: An iterable of tuples where each tuple contains a check name and a
170+
check function.
171+
:type checks: Iterable[Tuple[str, CheckFn]]
172+
173+
:param silenced_check_ids: A list of check IDs that should be omitted from the
174+
results.
175+
:type silenced_check_ids: List[str]
176+
177+
:return: An instance of ChecksResults containing detailed information about each
178+
check's outcome, their statuses, and the overall result level.
179+
:rtype: ChecksResults
180+
"""
181+
if silenced_check_ids is None:
182+
silenced_check_ids = []
183+
results = [(name, check()) for name, check in checks]
184+
return _build_results_payload(results, silenced_check_ids)
185+
186+
187+
def _build_results_payload(
188+
checks_results: Iterable[Tuple[str, Iterable[CheckMessage]]],
189+
silenced_check_ids,
190+
):
191+
details = {}
192+
statuses = {}
193+
max_level = 0
194+
195+
for name, errors in checks_results:
196+
# Log check results with appropriate level.
197+
for error in errors:
198+
logger.log(error.level, "%s: %s", error.id, error.msg)
199+
200+
errors = [e for e in errors if e.id not in silenced_check_ids]
201+
level = max([0] + [e.level for e in errors])
202+
203+
detail = {
204+
"status": level_to_text(level),
205+
"level": level,
206+
"messages": {e.id: e.msg for e in errors},
207+
}
208+
statuses[name] = level_to_text(level)
209+
max_level = max(max_level, level)
210+
if level > 0:
211+
details[name] = detail
212+
213+
return ChecksResults(statuses=statuses, details=details, level=max_level)

src/dockerflow/django/checks.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,6 @@
1111
from .. import health
1212

1313

14-
def level_to_text(level):
15-
statuses = {
16-
0: "ok",
17-
checks.messages.DEBUG: "debug",
18-
checks.messages.INFO: "info",
19-
checks.messages.WARNING: "warning",
20-
checks.messages.ERROR: "error",
21-
checks.messages.CRITICAL: "critical",
22-
}
23-
return statuses.get(level, "unknown")
24-
25-
2614
def check_database_connected(app_configs, **kwargs):
2715
"""
2816
A Django check to see if connecting to the configured default
@@ -119,7 +107,6 @@ def register():
119107
[
120108
"dockerflow.django.checks.check_database_connected",
121109
"dockerflow.django.checks.check_migrations_applied",
122-
# 'dockerflow.django.checks.check_redis_connected',
123110
],
124111
)
125112
for check_path in check_paths:

src/dockerflow/django/views.py

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import logging
55

66
from django.conf import settings
7-
from django.core import checks
7+
from django.core.checks.registry import registry as django_check_registry
88
from django.http import HttpResponse, HttpResponseNotFound, JsonResponse
99
from django.utils.module_loading import import_string
1010

11-
from .checks import level_to_text
11+
from dockerflow import checks
12+
1213
from .signals import heartbeat_failed, heartbeat_passed
1314

1415
version_callback = getattr(
@@ -47,44 +48,25 @@ def heartbeat(request):
4748
Any check that returns a warning or worse (error, critical) will
4849
return a 500 response.
4950
"""
50-
all_checks = checks.registry.registry.get_checks(
51-
include_deployment_checks=not settings.DEBUG
51+
checks_to_run = (
52+
(check.__name__, lambda: check(app_configs=None))
53+
for check in django_check_registry.get_checks(
54+
include_deployment_checks=not settings.DEBUG
55+
)
5256
)
53-
54-
details = {}
55-
statuses = {}
56-
level = 0
57-
58-
for check in all_checks:
59-
check_level, check_errors = heartbeat_check_detail(check)
60-
level_text = level_to_text(check_level)
61-
statuses[check.__name__] = level_text
62-
level = max(level, check_level)
63-
if level > 0:
64-
for error in check_errors:
65-
logger.log(error.level, "%s: %s", error.id, error.msg)
66-
details[check.__name__] = {
67-
"status": level_text,
68-
"level": level,
69-
"messages": {e.id: e.msg for e in check_errors},
70-
}
71-
72-
if level < checks.messages.ERROR:
57+
check_results = checks.run_checks(
58+
checks_to_run,
59+
silenced_check_ids=settings.SILENCED_SYSTEM_CHECKS,
60+
)
61+
if check_results.level < checks.ERROR:
7362
status_code = 200
74-
heartbeat_passed.send(sender=heartbeat, level=level)
63+
heartbeat_passed.send(sender=heartbeat, level=check_results.level)
7564
else:
7665
status_code = 500
77-
heartbeat_failed.send(sender=heartbeat, level=level)
66+
heartbeat_failed.send(sender=heartbeat, level=check_results.level)
7867

79-
payload = {"status": level_to_text(level)}
68+
payload = {"status": checks.level_to_text(check_results.level)}
8069
if settings.DEBUG:
81-
payload["checks"] = statuses
82-
payload["details"] = details
70+
payload["checks"] = check_results.statuses
71+
payload["details"] = check_results.details
8372
return JsonResponse(payload, status=status_code)
84-
85-
86-
def heartbeat_check_detail(check):
87-
errors = check(app_configs=None)
88-
errors = list(filter(lambda e: e.id not in settings.SILENCED_SYSTEM_CHECKS, errors))
89-
level = max([0] + [e.level for e in errors])
90-
return level, errors

0 commit comments

Comments
 (0)