Coverage for src/asgi_profiler/config.py: 100%
34 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"""Configuration."""
3from __future__ import annotations
5import re
6from collections.abc import Callable, Sequence
7from dataclasses import dataclass
9from starlette.requests import Request
11from .detectors import (
12 BLOCKING_MS,
13 N_PLUS_ONE_COUNT,
14 N_PLUS_ONE_MS,
15 SLOW_QUERY_MS,
16 TYPES,
17 Settings,
18)
19from .rules import compile_pattern
21#: Header names never stored, in lower case.
22DEFAULT_REDACTED_HEADERS = (
23 "authorization",
24 "proxy-authorization",
25 "cookie",
26 "set-cookie",
27 "x-api-key",
28 "x-auth-token",
29)
32@dataclass
33class ProfilerConfig:
34 """Options for :func:`asgi_profiler.install`.
36 Args:
37 mount_path: where the viewer is mounted. Every link the viewer emits is
38 relative to the mount, so this can be anything. `None` records
39 without mounting the viewer at all -- for when the pages are served
40 somewhere else, such as inside a SQLAdmin instance.
41 max_requests: how many requests to keep.
42 exclude_paths: paths that are never recorded, matched on segment
43 boundaries -- `/health` excludes `/health` and `/health/db` but not
44 `/healthcheck`. The viewer's own mount is added automatically.
45 include_regex: only record paths this matches. `None`, the default,
46 records everything. Matched with `search`, so anchor with `^` for
47 the strict reading. Note these are regular expressions, not globs:
48 a bare `"*"` is a syntax error, and `".*"` is how you spell
49 "everything" if you would rather be explicit than pass `None`.
50 exclude_regex: never record paths this matches. Applied before
51 `include_regex`. `None`, the default, excludes nothing.
53 Both are overridden per route by `@profiler_include` and
54 `@profiler_exclude`, which are decisive in either direction.
55 capture_stacks: record the application frames behind each query. This
56 is what makes an N+1 actionable, at some cost per query.
57 stack_depth: how many frames to keep.
58 slow_request_ms / slow_query_ms: thresholds for *highlighting* a row
59 in the viewer. Cosmetic, and separate from the detector
60 thresholds below, which decide whether something is reported as a
61 problem.
62 detectors: which detectors to run, by name. `None`, the default, runs
63 all of them; `()` runs none. Names are in
64 `asgi_profiler.detectors.TYPES`. An unknown name is ignored, so a
65 detector removed in a later release cannot break your config.
66 n_plus_one_count: how many identical statements make an N+1.
67 n_plus_one_ms: how much non-overlapping time they must occupy before
68 the repetition is worth reporting. Zero by default -- a
69 development database answers in microseconds what production
70 answers in milliseconds, so a duration floor here hides the
71 N+1s that have not shipped yet. Raise it when profiling against
72 production-sized data.
73 slow_query_issue_ms: a single statement at or over this is reported as
74 a problem. Higher than `slow_query_ms`, which only colours a row.
75 blocking_query_ms: how much loop-blocking time a request needs
76 before it is reported. Zero by default, for the same reason
77 `n_plus_one_ms` is: the mistake is architectural, and waiting for
78 it to be slow enough means finding it in production.
79 capture_headers: store request/response headers.
80 redacted_headers: header names replaced with "<redacted>".
81 response_header: name of a response header carrying the profile id, so
82 you can jump from a slow response straight to its trace. Set to
83 `None` to add nothing.
84 page_size: rows per page in the viewer.
85 statement_limit: how many rows the cross-request statement view shows.
86 authorize: called for every viewer request. Return False to deny.
87 **There is no default authentication.** Guard the viewer yourself,
88 or keep it off outside development.
89 """
91 mount_path: str | None = "/profiler"
92 max_requests: int = 500
93 exclude_paths: Sequence[str] = ("/favicon.ico",)
94 include_regex: str | None = None
95 exclude_regex: str | None = None
96 capture_stacks: bool = True
97 stack_depth: int = 8
98 slow_request_ms: float = 500.0
99 slow_query_ms: float = 50.0
100 capture_headers: bool = True
101 redacted_headers: Sequence[str] = DEFAULT_REDACTED_HEADERS
102 response_header: str | None = "x-profiler-id"
103 page_size: int = 50
104 statement_limit: int = 100
105 detectors: Sequence[str] | None = None
106 n_plus_one_count: int = N_PLUS_ONE_COUNT
107 n_plus_one_ms: float = N_PLUS_ONE_MS
108 slow_query_issue_ms: float = SLOW_QUERY_MS
109 blocking_query_ms: float = BLOCKING_MS
110 authorize: Callable[[Request], bool] | None = None
112 def detector_settings(self) -> Settings:
113 """The thresholds, in the form the detectors take.
115 Built per request rather than cached, because it is a five-field
116 frozen dataclass and because caching it would make a threshold changed
117 after `install()` silently not apply -- the kind of bug that costs an
118 afternoon to find and nothing to avoid.
119 """
120 return Settings(
121 enabled=self.detectors,
122 n_plus_one_count=self.n_plus_one_count,
123 n_plus_one_ms=self.n_plus_one_ms,
124 slow_query_ms=self.slow_query_issue_ms,
125 blocking_ms=self.blocking_query_ms,
126 stacks=self.capture_stacks,
127 )
129 def unknown_detectors(self) -> tuple[str, ...]:
130 """Configured names no detector answers to. For a warning, not an error."""
131 if self.detectors is None:
132 return ()
133 return tuple(name for name in self.detectors if name not in TYPES)
135 def compiled_patterns(self) -> tuple[re.Pattern[str] | None, re.Pattern[str] | None]:
136 """`(include, exclude)`, compiled. Invalid patterns raise here."""
137 return (
138 compile_pattern(self.include_regex, "include_regex"),
139 compile_pattern(self.exclude_regex, "exclude_regex"),
140 )
142 def build_excludes(self) -> tuple[str, ...]:
143 paths = list(self.exclude_paths)
144 if self.mount_path is not None:
145 # An empty `mount_path` would rstrip to "" and then be read as "/",
146 # which excludes the entire application -- the profiler would
147 # record nothing at all and look broken rather than misconfigured.
148 mount = self.mount_path.rstrip("/")
149 if not mount:
150 raise ValueError(
151 'mount_path="" would exclude every path in the application. '
152 "Use mount_path=None to skip mounting the viewer."
153 )
154 paths.insert(0, mount)
155 return tuple(dict.fromkeys(paths))