Coverage for src/asgi_profiler/detectors/settings.py: 100%

18 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-22 16:59 +0000

1"""Thresholds the detectors run against. 

2 

3Separate from :class:`~asgi_profiler.config.ProfilerConfig` so a detector can 

4be called on a profile without constructing an application's worth of 

5configuration -- in a test, from a script reading a capture file, or from the 

6CLI. `ProfilerConfig.detector_settings()` builds one. 

7 

8The defaults are Sentry's where Sentry has one and the reasoning carries over, 

9and lower where it does not. Sentry calls a query slow at 1000 ms because it 

10is watching production and wants a handful of issues a day. This runs on a 

11development machine against a development database, where 1000 ms means the 

12slow query has to be catastrophic before anyone hears about it. 

13 

14`n_plus_one_ms` is zero for a stronger version of the same reason. Sentry 

15requires 50 ms of database time before it calls repetition an N+1, which is 

16sound when the database it is watching holds the production dataset. It is 

17exactly wrong here. The whole value of finding an N+1 in development is that 

18it has not reached production yet -- and in development the table has twelve 

19rows, so five hundred repeated queries return in four milliseconds and a 

20duration floor is a filter that removes the findings you most wanted. The 

21count and the shared call site are the signal; duration is available to raise 

22when profiling against production-sized data, where the noise floor is real. 

23""" 

24 

25from __future__ import annotations 

26 

27from collections.abc import Sequence 

28from dataclasses import dataclass 

29 

30__all__ = ["Settings"] 

31 

32#: The defaults, as values rather than only as dataclass fields. 

33#: 

34#: `ProfilerConfig` exposes the same four thresholds to users and has to 

35#: default them to the same numbers. Writing the numbers twice meant they 

36#: could disagree, and they promptly did: a default changed here went on 

37#: being overridden by the stale copy there, so the detector silently ran 

38#: with the old threshold and the only symptom was a finding that never 

39#: appeared. One definition, referenced twice. 

40N_PLUS_ONE_COUNT = 5 

41N_PLUS_ONE_MS = 0.0 

42SLOW_QUERY_MS = 100.0 

43BLOCKING_MS = 0.0 

44 

45 

46@dataclass(frozen=True, slots=True) 

47class Settings: 

48 """Detector thresholds. 

49 

50 Args: 

51 enabled: detector type names to run. `None`, the default, runs all of 

52 them. An unknown name is ignored rather than raising, so removing 

53 a detector in a later release cannot break an application that 

54 named it. 

55 n_plus_one_count: how many identical statements make an N+1. 

56 n_plus_one_ms: how much non-overlapping time they must occupy. Zero 

57 by default, and deliberately: see below. 

58 stacks: whether stacks were captured. When they were, a repeated 

59 statement with no shared application frame is not reported -- 

60 that is library bookkeeping, not a loop in your code. 

61 slow_query_ms: a single statement at or over this is a problem. 

62 blocking_ms: how much loop-blocking time a request needs before it 

63 is reported. Zero by default: a synchronous driver call inside an 

64 `async def` endpoint is the wrong shape whether the development 

65 database answers in one millisecond or the production one takes a 

66 hundred, and the millisecond version is the one there is still 

67 time to fix. Raise it to silence a call you have decided to keep. 

68 """ 

69 

70 enabled: Sequence[str] | None = None 

71 n_plus_one_count: int = N_PLUS_ONE_COUNT 

72 n_plus_one_ms: float = N_PLUS_ONE_MS 

73 slow_query_ms: float = SLOW_QUERY_MS 

74 blocking_ms: float = BLOCKING_MS 

75 stacks: bool = True 

76 

77 def runs(self, name: str) -> bool: 

78 return self.enabled is None or name in self.enabled