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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Thresholds the detectors run against.
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.
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.
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"""
25from __future__ import annotations
27from collections.abc import Sequence
28from dataclasses import dataclass
30__all__ = ["Settings"]
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
46@dataclass(frozen=True, slots=True)
47class Settings:
48 """Detector thresholds.
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 """
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
77 def runs(self, name: str) -> bool:
78 return self.enabled is None or name in self.enabled