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

1"""Configuration.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from collections.abc import Callable, Sequence 

7from dataclasses import dataclass 

8 

9from starlette.requests import Request 

10 

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 

20 

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) 

30 

31 

32@dataclass 

33class ProfilerConfig: 

34 """Options for :func:`asgi_profiler.install`. 

35 

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. 

52 

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

90 

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 

111 

112 def detector_settings(self) -> Settings: 

113 """The thresholds, in the form the detectors take. 

114 

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 ) 

128 

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) 

134 

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 ) 

141 

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