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

1"""Turning recorded statements into findings. 

2 

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. 

6 

7Detectors run in :meth:`asgi_profiler.middleware.ProfilerMiddleware.__call__` 

8after the response has been sent, so their cost is off the latency path. 

9 

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""" 

15 

16from __future__ import annotations 

17 

18import logging 

19from collections.abc import Callable, Iterable 

20 

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) 

30 

31logger = logging.getLogger("asgi_profiler") 

32 

33Detector = Callable[[Profile, Settings], Iterable[Problem]] 

34 

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) 

41 

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) 

45 

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} 

53 

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} 

62 

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] 

76 

77 

78def detect(profile: Profile, settings: Settings | None = None) -> list[Problem]: 

79 """Every problem found in `profile`, most expensive first. 

80 

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