Coverage for src/asgi_profiler/detectors/__init__.py: 100%
21 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Turning recorded statements into findings.
3A detector is a function `(Profile, Settings) -> Iterable[Problem]`. It reads
4a finalised profile and concludes something about it; it never touches
5storage, configuration or the request.
7Detectors run in :meth:`asgi_profiler.middleware.ProfilerMiddleware.__call__`
8after the response has been sent, so their cost is off the latency path.
10Adding one: write the module, export `TYPE` and `detect`, and add it to
11`DETECTORS` below. The name in `TYPE` is public -- it appears in the JSON, in
12`ProfilerConfig.detectors`, and in anything a user has written a CI assertion
13against -- so it is chosen once and not renamed.
14"""
16from __future__ import annotations
18import logging
19from collections.abc import Callable, Iterable
21from ..models import Problem, Profile
22from . import blocking, n_plus_one, slow_query
23from .settings import (
24 BLOCKING_MS,
25 N_PLUS_ONE_COUNT,
26 N_PLUS_ONE_MS,
27 SLOW_QUERY_MS,
28 Settings,
29)
31logger = logging.getLogger("asgi_profiler")
33Detector = Callable[[Profile, Settings], Iterable[Problem]]
35#: In the order they are defined, though the result is sorted by cost.
36DETECTORS: tuple[tuple[str, Detector], ...] = (
37 (n_plus_one.TYPE, n_plus_one.detect),
38 (blocking.TYPE, blocking.detect),
39 (slow_query.TYPE, slow_query.detect),
40)
42#: Every detector name, for validating `ProfilerConfig.detectors` and for
43#: documenting what can be turned off.
44TYPES: tuple[str, ...] = tuple(name for name, _ in DETECTORS)
46#: Short names for the badges. Defined beside the detectors rather than in a
47#: template so that adding a detector updates every page that lists them.
48LABELS: dict[str, str] = {
49 n_plus_one.TYPE: "N+1",
50 blocking.TYPE: "Blocking",
51 slow_query.TYPE: "Slow query",
52}
54#: Which of the viewer's existing pill colours each one wears. Blocking reads
55#: as an error because it damages requests other than the one being looked at,
56#: which is worse than being slow.
57SEVERITY: dict[str, str] = {
58 n_plus_one.TYPE: "warn",
59 blocking.TYPE: "err",
60 slow_query.TYPE: "warn",
61}
63__all__ = [
64 "BLOCKING_MS",
65 "DETECTORS",
66 "LABELS",
67 "N_PLUS_ONE_COUNT",
68 "N_PLUS_ONE_MS",
69 "SEVERITY",
70 "SLOW_QUERY_MS",
71 "TYPES",
72 "Detector",
73 "Settings",
74 "detect",
75]
78def detect(profile: Profile, settings: Settings | None = None) -> list[Problem]:
79 """Every problem found in `profile`, most expensive first.
81 Never raises. A detector that throws is logged and skipped, and the other
82 detectors still run: a profiler that turns a served request into a 500
83 because it could not decide whether the request was slow has failed at the
84 only thing it must not do.
85 """
86 settings = settings or Settings()
87 found: list[Problem] = []
88 for name, detector in DETECTORS:
89 if not settings.runs(name):
90 continue
91 try:
92 found.extend(detector(profile, settings))
93 except Exception: # pragma: no cover - a detector bug
94 logger.warning("Detector %s failed on profile %s", name, profile.id, exc_info=True)
95 found.sort(key=lambda p: p.time_ms, reverse=True)
96 return found