Coverage for src/qdrant_loader_core/logging.py: 88%

129 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:12 +0000

1"""Unified logging configuration for qdrant-loader ecosystem. 

2 

3Provides: 

4- structlog setup (console/json/file) with redaction 

5- stdlib logging bridge with redaction filter 

6- optional suppression of noisy third-party logs 

7""" 

8 

9from __future__ import annotations 

10 

11import logging 

12import os 

13 

14import structlog 

15from structlog.stdlib import LoggerFactory 

16 

17from .logging_filters import ( 

18 ApplicationFilter, 

19 QdrantVersionFilter, 

20 RedactionFilter, 

21 UvicornAccessRedactFilter, 

22) 

23from .logging_processors import CleanFormatter, redact_processor 

24 

25try: 

26 # ExtraAdder is available in structlog >= 20 

27 from structlog.stdlib import ExtraAdder # type: ignore 

28except Exception: # pragma: no cover - fallback when absent 

29 ExtraAdder = None # type: ignore 

30 

31 

32class LoggingConfig: 

33 """Core logging setup with structlog + stdlib redaction and filters.""" 

34 

35 _initialized = False 

36 _installed_handlers: list[logging.Handler] = [] 

37 _file_handler: logging.FileHandler | None = None 

38 _current_config: ( 

39 tuple[ 

40 str, # level 

41 str, # format 

42 str | None, # file 

43 bool, # clean_output 

44 bool, # suppress_qdrant_warnings 

45 bool, # disable_console 

46 ] 

47 | None 

48 ) = None 

49 

50 @classmethod 

51 def setup( 

52 cls, 

53 *, 

54 level: str = "INFO", 

55 format: str = "console", # "console" | "json" 

56 file: str | None = None, 

57 clean_output: bool = True, 

58 suppress_qdrant_warnings: bool = True, 

59 disable_console: bool | None = None, 

60 ) -> None: 

61 # Env override for console toggling (e.g., MCP server) 

62 if disable_console is None: 

63 disable_console = ( 

64 os.getenv("MCP_DISABLE_CONSOLE_LOGGING", "").lower() == "true" 

65 ) 

66 

67 try: 

68 numeric_level = getattr(logging, level.upper()) 

69 except AttributeError: 

70 raise ValueError(f"Invalid log level: {level}") from None 

71 

72 # Short-circuit when configuration is unchanged 

73 current_tuple = ( 

74 level.upper(), 

75 format, 

76 file, 

77 bool(clean_output), 

78 bool(suppress_qdrant_warnings), 

79 bool(disable_console), 

80 ) 

81 if cls._initialized and cls._current_config == current_tuple: 

82 return 

83 

84 # Reset structlog defaults but preserve existing stdlib handlers (e.g., pytest caplog) 

85 structlog.reset_defaults() 

86 

87 # Remove any handlers previously added by this class, and also clear 

88 # any pre-existing root handlers that may cause duplicated outputs. 

89 # We keep this conservative by only touching the root logger. 

90 root_logger = logging.getLogger() 

91 # First remove our previously installed handlers 

92 for h in list(cls._installed_handlers): 

93 try: 

94 root_logger.removeHandler(h) 

95 if isinstance(h, logging.FileHandler): 

96 try: 

97 h.close() 

98 except Exception: 

99 pass 

100 except Exception: 

101 pass 

102 cls._installed_handlers.clear() 

103 

104 # Then remove any remaining handlers on the root logger (e.g., added by 

105 # earlier setup calls or third-parties) to avoid duplicate emissions. 

106 # This is safe for CLI usage; tests relying on caplog attach to non-root loggers. 

107 for h in list(root_logger.handlers): 

108 try: 

109 root_logger.removeHandler(h) 

110 if isinstance(h, logging.FileHandler): 

111 try: 

112 h.close() 

113 except Exception: 

114 pass 

115 except Exception: 

116 pass 

117 

118 handlers: list[logging.Handler] = [] 

119 

120 # Choose timestamp format and final renderer for structlog messages 

121 if clean_output and format == "console": 

122 ts_fmt = "%H:%M:%S" 

123 final_renderer = structlog.dev.ConsoleRenderer(colors=True) 

124 else: 

125 ts_fmt = "iso" 

126 final_renderer = ( 

127 structlog.processors.JSONRenderer() 

128 if format == "json" 

129 else structlog.dev.ConsoleRenderer(colors=True) 

130 ) 

131 

132 if not disable_console: 

133 console_handler = logging.StreamHandler() 

134 console_handler.setFormatter(logging.Formatter("%(message)s")) 

135 console_handler.addFilter(ApplicationFilter()) 

136 console_handler.addFilter(RedactionFilter()) 

137 handlers.append(console_handler) 

138 

139 if file: 

140 file_handler = logging.FileHandler(file) 

141 # Use CleanFormatter to strip ANSI sequences from structlog console renderer output 

142 file_handler.setFormatter(CleanFormatter("%(message)s")) 

143 file_handler.addFilter(ApplicationFilter()) 

144 file_handler.addFilter(RedactionFilter()) 

145 handlers.append(file_handler) 

146 

147 # Attach our handlers without removing existing ones (so pytest caplog keeps working) 

148 root_logger.setLevel(numeric_level) 

149 for h in handlers: 

150 root_logger.addHandler(h) 

151 # Track handlers we installed to avoid duplicates on re-setup 

152 cls._installed_handlers.extend(handlers) 

153 # Track file handler for lightweight reconfiguration 

154 cls._file_handler = next( 

155 (h for h in handlers if isinstance(h, logging.FileHandler)), None 

156 ) 

157 

158 # Add global filters so captured logs (e.g., pytest caplog) are also redacted 

159 # Avoid duplicate filters if setup() is called multiple times 

160 has_redaction = any(isinstance(f, RedactionFilter) for f in root_logger.filters) 

161 if not has_redaction: 

162 root_logger.addFilter(RedactionFilter()) 

163 has_app_filter = any( 

164 isinstance(f, ApplicationFilter) for f in root_logger.filters 

165 ) 

166 if not has_app_filter: 

167 root_logger.addFilter(ApplicationFilter()) 

168 

169 # uvicorn.access has propagate=False in uvicorn's own logging config, so 

170 # it bypasses the root logger's RedactionFilter above. Attach directly 

171 # so webhook secrets/tokens in the request query string aren't logged 

172 # in plaintext (see UvicornAccessRedactFilter docstring for details). 

173 uvicorn_access_logger = logging.getLogger("uvicorn.access") 

174 if not any( 

175 isinstance(f, UvicornAccessRedactFilter) 

176 for f in uvicorn_access_logger.filters 

177 ): 

178 uvicorn_access_logger.addFilter(UvicornAccessRedactFilter()) 

179 

180 # Optional suppressions 

181 if suppress_qdrant_warnings: 

182 logging.getLogger("qdrant_client").addFilter(QdrantVersionFilter()) 

183 

184 # Quiet noisy libs a bit 

185 for name in ("httpx", "httpcore", "urllib3", "gensim"): 

186 logging.getLogger(name).setLevel(logging.WARNING) 

187 

188 # structlog processors – render to a final string directly 

189 structlog.configure( 

190 processors=[ 

191 structlog.stdlib.filter_by_level, 

192 structlog.stdlib.add_logger_name, 

193 structlog.stdlib.add_log_level, 

194 structlog.processors.TimeStamper(fmt=ts_fmt), 

195 redact_processor, 

196 final_renderer, 

197 ], 

198 wrapper_class=structlog.make_filtering_bound_logger(numeric_level), 

199 logger_factory=LoggerFactory(), 

200 cache_logger_on_first_use=False, 

201 ) 

202 

203 cls._initialized = True 

204 cls._current_config = current_tuple 

205 

206 @classmethod 

207 def get_logger(cls, name: str | None = None) -> structlog.BoundLogger: 

208 if not cls._initialized: 

209 cls.setup() 

210 return structlog.get_logger(name) 

211 

212 @classmethod 

213 def reconfigure(cls, *, file: str | None = None, level: str | None = None) -> None: 

214 """Lightweight reconfiguration for file destination and optionally log level. 

215 

216 Replaces only the file handler while keeping console handlers and 

217 structlog processors intact. Optionally updates the log level. 

218 

219 Args: 

220 file: Path to log file (optional) 

221 level: New log level (optional, e.g., "DEBUG", "INFO") 

222 """ 

223 root_logger = logging.getLogger() 

224 

225 # Update log level if provided 

226 if level is not None: 

227 try: 

228 numeric_level = getattr(logging, level.upper()) 

229 root_logger.setLevel(numeric_level) 

230 

231 # Update structlog wrapper to use new level 

232 if cls._current_config is not None: 

233 ( 

234 _, 

235 fmt, 

236 _, 

237 clean_output, 

238 suppress_qdrant_warnings, 

239 disable_console, 

240 ) = cls._current_config 

241 

242 # Choose timestamp format and final renderer 

243 if clean_output and fmt == "console": 

244 ts_fmt = "%H:%M:%S" 

245 final_renderer = structlog.dev.ConsoleRenderer(colors=True) 

246 else: 

247 ts_fmt = "iso" 

248 final_renderer = ( 

249 structlog.processors.JSONRenderer() 

250 if fmt == "json" 

251 else structlog.dev.ConsoleRenderer(colors=True) 

252 ) 

253 

254 # Reconfigure structlog with new level 

255 structlog.configure( 

256 processors=[ 

257 structlog.stdlib.filter_by_level, 

258 structlog.stdlib.add_logger_name, 

259 structlog.stdlib.add_log_level, 

260 structlog.processors.TimeStamper(fmt=ts_fmt), 

261 redact_processor, 

262 final_renderer, 

263 ], 

264 wrapper_class=structlog.make_filtering_bound_logger( 

265 numeric_level 

266 ), 

267 logger_factory=LoggerFactory(), 

268 cache_logger_on_first_use=False, 

269 ) 

270 except AttributeError: 

271 raise ValueError(f"Invalid log level: {level}") from None 

272 

273 # Remove existing file handler if present 

274 if cls._file_handler is not None: 

275 try: 

276 root_logger.removeHandler(cls._file_handler) 

277 cls._file_handler.close() 

278 except Exception: 

279 pass 

280 cls._installed_handlers = [ 

281 h for h in cls._installed_handlers if h is not cls._file_handler 

282 ] 

283 cls._file_handler = None 

284 

285 # Add new file handler if requested 

286 if file: 

287 fh = logging.FileHandler(file) 

288 fh.setFormatter(CleanFormatter("%(message)s")) 

289 fh.addFilter(ApplicationFilter()) 

290 fh.addFilter(RedactionFilter()) 

291 root_logger.addHandler(fh) 

292 cls._installed_handlers.append(fh) 

293 cls._file_handler = fh 

294 

295 # Update current config tuple if available 

296 if cls._current_config is not None: 

297 ( 

298 old_level, 

299 fmt, 

300 _, 

301 clean_output, 

302 suppress_qdrant_warnings, 

303 disable_console, 

304 ) = cls._current_config 

305 new_level = level.upper() if level is not None else old_level 

306 cls._current_config = ( 

307 new_level, 

308 fmt, 

309 file, 

310 clean_output, 

311 suppress_qdrant_warnings, 

312 disable_console, 

313 )