Coverage for haystack/logging.py: 97%

129 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import builtins 

6import functools 

7import logging 

8import os 

9import sys 

10import typing 

11from collections.abc import Sequence 

12from typing import Any 

13 

14import haystack.utils.jupyter 

15 

16if typing.TYPE_CHECKING: 

17 from structlog.typing import EventDict, Processor, WrappedLogger 

18 

19HAYSTACK_LOGGING_USE_JSON_ENV_VAR = "HAYSTACK_LOGGING_USE_JSON" 

20HAYSTACK_LOGGING_IGNORE_STRUCTLOG_ENV_VAR = "HAYSTACK_LOGGING_IGNORE_STRUCTLOG" 

21 

22# Attribute set on a logger once we have patched its methods. `logging.getLogger` returns a shared singleton, so we 

23# use this marker to patch each logger only once and avoid wrapping the already-wrapped methods on repeated calls. 

24_PATCHED_MARKER = "_haystack_patched" 

25 

26# Name of the formatting handler we install. We use it to find and remove our own handler across (re)configurations. 

27_HANDLER_NAME = "HaystackLoggingHandler" 

28 

29 

30def _is_haystack_logging_handler(handler: logging.Handler) -> bool: 

31 """Return whether the given handler is the one installed by `configure_logging`.""" 

32 return isinstance(handler, logging.StreamHandler) and getattr(handler, "name", None) == _HANDLER_NAME 

33 

34 

35class PatchedLogger(typing.Protocol): 

36 """Class which enables using type checkers to find wrong logger usage.""" 

37 

38 def debug( 

39 self, 

40 msg: str, 

41 *, 

42 _: Any = None, 

43 exc_info: Any = None, 

44 stack_info: Any = False, 

45 stacklevel: int = 1, 

46 **kwargs: Any, 

47 ) -> None: 

48 """Log a debug message.""" 

49 

50 def info( 

51 self, 

52 msg: str, 

53 *, 

54 _: Any = None, 

55 exc_info: Any = None, 

56 stack_info: Any = False, 

57 stacklevel: int = 1, 

58 **kwargs: Any, 

59 ) -> None: 

60 """Log an info message.""" 

61 

62 def warn( 

63 self, 

64 msg: str, 

65 *, 

66 _: Any = None, 

67 exc_info: Any = None, 

68 stack_info: Any = False, 

69 stacklevel: int = 1, 

70 **kwargs: Any, 

71 ) -> None: 

72 """Log a warning message.""" 

73 

74 def warning( 

75 self, 

76 msg: str, 

77 *, 

78 _: Any = None, 

79 exc_info: Any = None, 

80 stack_info: Any = False, 

81 stacklevel: int = 1, 

82 **kwargs: Any, 

83 ) -> None: 

84 """Log a warning message.""" 

85 

86 def error( 

87 self, 

88 msg: str, 

89 *, 

90 _: Any = None, 

91 exc_info: Any = None, 

92 stack_info: Any = False, 

93 stacklevel: int = 1, 

94 **kwargs: Any, 

95 ) -> None: 

96 """Log an error message.""" 

97 

98 def critical( 

99 self, 

100 msg: str, 

101 *, 

102 _: Any = None, 

103 exc_info: Any = None, 

104 stack_info: Any = False, 

105 stacklevel: int = 1, 

106 **kwargs: Any, 

107 ) -> None: 

108 """Log a critical message.""" 

109 

110 def exception( 

111 self, 

112 msg: str, 

113 *, 

114 _: Any = None, 

115 exc_info: Any = None, 

116 stack_info: Any = False, 

117 stacklevel: int = 1, 

118 **kwargs: Any, 

119 ) -> None: 

120 """Log an exception message.""" 

121 

122 def fatal( 

123 self, 

124 msg: str, 

125 *, 

126 _: Any = None, 

127 exc_info: Any = None, 

128 stack_info: Any = False, 

129 stacklevel: int = 1, 

130 **kwargs: Any, 

131 ) -> None: 

132 """Log a fatal message.""" 

133 

134 def log( 

135 self, 

136 level: int, 

137 msg: str, 

138 *, 

139 _: Any = None, 

140 exc_info: Any = None, 

141 stack_info: Any = False, 

142 stacklevel: int = 1, 

143 **kwargs: Any, 

144 ) -> None: 

145 """Log a message.""" 

146 

147 def setLevel(self, level: int) -> None: 

148 """Set the logging level.""" 

149 

150 

151def patch_log_method_to_kwargs_only(func: typing.Callable) -> typing.Callable: 

152 """A decorator to make sure that a function is only called with keyword arguments.""" 

153 

154 @functools.wraps(func) 

155 def _log_only_with_kwargs( 

156 msg: str, *, _: Any = None, exc_info: Any = None, stack_info: Any = False, stacklevel: int = 1, **kwargs: Any 

157 ) -> typing.Callable: # we need the `_` to avoid a syntax error 

158 existing_extra = kwargs.pop("extra", {}) 

159 return func( 

160 # we need to increase the stacklevel by 1 to point to the correct caller 

161 # (otherwise it points to this function) 

162 msg, 

163 exc_info=exc_info, 

164 stack_info=stack_info, 

165 stacklevel=stacklevel + 1, 

166 extra={**existing_extra, **kwargs}, 

167 ) 

168 

169 return _log_only_with_kwargs 

170 

171 

172def patch_log_with_level_method_to_kwargs_only(func: typing.Callable) -> typing.Callable: 

173 """A decorator to make sure that a function is only called with keyword arguments.""" 

174 

175 @functools.wraps(func) 

176 def _log_only_with_kwargs( 

177 level: int | str, 

178 msg: str, 

179 *, 

180 _: Any = None, 

181 exc_info: Any = None, 

182 stack_info: Any = False, 

183 stacklevel: int = 1, 

184 **kwargs: Any, # we need the `_` to avoid a syntax error 

185 ) -> typing.Callable: 

186 existing_extra = kwargs.pop("extra", {}) 

187 

188 return func( 

189 level, 

190 msg, 

191 exc_info=exc_info, 

192 stack_info=stack_info, 

193 # we need to increase the stacklevel by 1 to point to the correct caller 

194 # (otherwise it points to this function) 

195 stacklevel=stacklevel + 1, 

196 extra={**existing_extra, **kwargs}, 

197 ) 

198 

199 return _log_only_with_kwargs 

200 

201 

202def patch_make_records_to_use_kwarg_string_interpolation(original_make_records: typing.Callable) -> typing.Callable: 

203 """A decorator to ensure string interpolation is used.""" 

204 

205 @functools.wraps(original_make_records) 

206 def _wrapper( 

207 name: str, 

208 level: int | str, 

209 fn: str, 

210 lno: int, 

211 msg: str, 

212 args: Any, # noqa: ARG001 

213 exc_info: Any, 

214 func: Any = None, 

215 extra: Any = None, 

216 sinfo: Any = None, 

217 ) -> typing.Callable: 

218 safe_extra = extra or {} 

219 try: 

220 interpolated_msg = msg.format(**safe_extra) 

221 except (KeyError, ValueError, IndexError): 

222 interpolated_msg = msg 

223 return original_make_records(name, level, fn, lno, interpolated_msg, (), exc_info, func, extra, sinfo) 

224 

225 return _wrapper 

226 

227 

228def _patch_structlog_call_information(logger: logging.Logger) -> None: 

229 # structlog patches the findCaller to hide itself from the traceback. 

230 # We need to patch their patch to hide `haystack.logging` from the traceback. 

231 try: 

232 from structlog._frames import _find_first_app_frame_and_name, _format_stack 

233 from structlog.stdlib import _FixedFindCallerLogger 

234 

235 if not isinstance(logger, _FixedFindCallerLogger): 

236 return 

237 

238 # Copied from structlog's `_FixedFindCallerLogger.findCaller`, adding `haystack.logging` to the ignored 

239 # frames. We don't forward `stacklevel` to `_find_first_app_frame_and_name` (added in structlog 25.5.0): 

240 # structlog is optional and may be older. 

241 def findCaller(stack_info: bool = False, stacklevel: int = 1) -> tuple[str, int, str, str | None]: # noqa: ARG001 

242 f, _name = _find_first_app_frame_and_name(["logging", "haystack.logging"]) 

243 sinfo = _format_stack(f) if stack_info else None 

244 return f.f_code.co_filename, f.f_lineno, f.f_code.co_name, sinfo 

245 

246 logger.findCaller = findCaller # type: ignore 

247 except ImportError: 

248 pass 

249 

250 

251def getLogger(name: str) -> PatchedLogger: 

252 """ 

253 Get the Haystack logger, a patched version of the one from the standard library. 

254 

255 We patch the default logger methods to make sure that they are only called with keyword arguments. 

256 We enforce keyword-arguments because 

257 - it brings in consistency 

258 - it makes structure logging effective, not just an available feature 

259 """ 

260 logger = logging.getLogger(name) 

261 if getattr(logger, _PATCHED_MARKER, False): 

262 # Already patched: `logging.getLogger` returned the same singleton, so re-patching would stack the wrappers 

263 # and interpolate the message more than once. 

264 return typing.cast(PatchedLogger, logger) 

265 

266 logger.debug = patch_log_method_to_kwargs_only(logger.debug) # type: ignore 

267 logger.info = patch_log_method_to_kwargs_only(logger.info) # type: ignore 

268 logger.warn = patch_log_method_to_kwargs_only(logger.warn) # type: ignore 

269 logger.warning = patch_log_method_to_kwargs_only(logger.warning) # type: ignore 

270 logger.error = patch_log_method_to_kwargs_only(logger.error) # type: ignore 

271 logger.critical = patch_log_method_to_kwargs_only(logger.critical) # type: ignore 

272 logger.exception = patch_log_method_to_kwargs_only(logger.exception) # type: ignore 

273 logger.fatal = patch_log_method_to_kwargs_only(logger.fatal) # type: ignore 

274 logger.log = patch_log_with_level_method_to_kwargs_only(logger.log) # type: ignore 

275 

276 _patch_structlog_call_information(logger) 

277 

278 # We also patch the `makeRecord` method to use keyword string interpolation 

279 logger.makeRecord = patch_make_records_to_use_kwarg_string_interpolation(logger.makeRecord) # type: ignore 

280 

281 setattr(logger, _PATCHED_MARKER, True) 

282 

283 return typing.cast(PatchedLogger, logger) 

284 

285 

286def add_line_and_file(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": 

287 """Add line and file to log entries.""" 

288 stdlib_record = event_dict.get("_record") 

289 if not stdlib_record: 

290 return event_dict 

291 

292 event_dict["lineno"] = stdlib_record.lineno 

293 event_dict["module"] = stdlib_record.name 

294 

295 return event_dict 

296 

297 

298def correlate_logs_with_traces(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": 

299 """ 

300 Add correlation data for logs. 

301 

302 This is useful if you want to correlate logs with traces. 

303 """ 

304 import haystack.tracing.tracer # to avoid circular imports 

305 

306 if not haystack.tracing.is_tracing_enabled(): 

307 return event_dict 

308 

309 current_span = haystack.tracing.tracer.current_span() 

310 if current_span: 

311 event_dict.update(current_span.get_correlation_data_for_logs()) 

312 

313 return event_dict 

314 

315 

316def configure_logging( 

317 use_json: bool | None = None, 

318 logger_name: str | Sequence[str] = ("haystack", "haystack_integrations", "haystack_experimental"), 

319 propagate: bool = True, 

320 configure_structlog: bool = True, 

321) -> None: 

322 """ 

323 Configure logging for Haystack. 

324 

325 - If `structlog` is not installed, we keep everything as it is. The user is responsible for configuring logging 

326 themselves. 

327 - If `structlog` is installed, we configure it to format log entries including its key-value data. To disable this 

328 behavior set the environment variable `HAYSTACK_LOGGING_IGNORE_STRUCTLOG` to `true`. 

329 - If `structlog` is installed, you can JSON format all logs. Enable this by 

330 - setting the `use_json` parameter to `True` when calling this function 

331 - setting the environment variable `HAYSTACK_LOGGING_USE_JSON` to `true` 

332 

333 :param use_json: Whether to format logs as JSON. If `None`, we try to guess based on the environment. 

334 :param logger_name: 

335 The name (or names) of the logger our formatting handler is attached to. Defaults to Haystack's own 

336 namespaces (`"haystack"`, `"haystack_integrations"` and `"haystack_experimental"`), so that we only touch 

337 Haystack's own loggers and leave the logging configuration of the host application and any other libraries 

338 running in the same process untouched. Pass an empty string (`""`) to attach the handler to the root logger 

339 instead - this restores the legacy behavior of formatting *every* log record in the process. 

340 :param propagate: 

341 Whether the configured loggers should propagate their records to ancestor loggers (ultimately the root 

342 logger). The default (`True`) keeps records flowing to handlers configured by the host application and to 

343 capturing tools such as `pytest`'s `caplog`. Set it to `False` to make Haystack fully own the output of its 

344 own logs - this avoids duplicate log lines when the host application also configures the root logger. It has 

345 no effect when `logger_name=""` (the root logger has no ancestors). 

346 :param configure_structlog: 

347 Whether to configure the process-global `structlog` (thereby taking over any configuration set up by someone 

348 else). The default (`True`) is what an explicit call should do. Pass `False` (as the import-time call in 

349 `haystack/__init__.py` does) to leave the global `structlog` configuration untouched and only install our own 

350 scoped handler so Haystack's own logs are formatted. This keeps merely importing Haystack from reconfiguring 

351 `structlog` for the host application's own native `structlog` loggers. 

352 """ 

353 try: 

354 import structlog 

355 from structlog.processors import ExceptionRenderer 

356 from structlog.tracebacks import ExceptionDictTransformer 

357 

358 except ImportError: 

359 # structlog is not installed - fall back to standard logging 

360 return 

361 

362 if os.getenv(HAYSTACK_LOGGING_IGNORE_STRUCTLOG_ENV_VAR, "false").lower() == "true": 

363 # If the user wants to ignore structlog, we don't configure it and fall back to standard logging 

364 return 

365 

366 # We roughly follow the structlog documentation here: 

367 # https://www.structlog.org/en/stable/standard-library.html#rendering-using-structlog-based-formatters-within-logging 

368 # This means that we use structlog to format the log entries for entries emitted via `logging` and `structlog`. 

369 

370 if use_json is None: # explicit parameter takes precedence over everything else 

371 use_json_env_var = os.getenv(HAYSTACK_LOGGING_USE_JSON_ENV_VAR) 

372 if use_json_env_var is None: 

373 # We try to guess if we are in an interactive terminal or not 

374 interactive_terminal = ( 

375 sys.stderr.isatty() or hasattr(builtins, "__IPYTHON__") or haystack.utils.jupyter.is_in_jupyter() 

376 ) 

377 use_json = not interactive_terminal 

378 else: 

379 # User gave us an explicit value via environment variable 

380 use_json = use_json_env_var.lower() == "true" 

381 

382 shared_processors: list[Processor] = [ 

383 # Add the log level to the event_dict for structlog to use 

384 structlog.stdlib.add_log_level, 

385 # Adds the current timestamp in ISO format to logs 

386 structlog.processors.TimeStamper(fmt="iso"), 

387 structlog.contextvars.merge_contextvars, 

388 add_line_and_file, 

389 ] 

390 

391 if use_json: 

392 # We only need that in sophisticated production setups where we want to correlate logs with traces 

393 shared_processors.append(correlate_logs_with_traces) 

394 

395 # `structlog.configure` is process-global: it affects every native structlog logger, not just Haystack's. We only 

396 # configure it when explicitly asked (`configure_structlog`). 

397 if configure_structlog: 

398 structlog.configure( 

399 # `filter_by_level` reads the effective level from the underlying stdlib logger on *every* call, so 

400 # changes to the log level made after `configure_logging` runs (e.g. by the host app) are respected. 

401 processors=[ 

402 structlog.stdlib.filter_by_level, 

403 *shared_processors, 

404 structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 

405 ], 

406 logger_factory=structlog.stdlib.LoggerFactory(ignore_frame_names=["haystack.logging"]), 

407 cache_logger_on_first_use=True, 

408 wrapper_class=structlog.stdlib.BoundLogger, 

409 ) 

410 

411 renderers: list[Processor] 

412 if use_json: 

413 renderers = [ 

414 ExceptionRenderer( 

415 # don't show locals in production logs - this can be quite sensitive information 

416 ExceptionDictTransformer(show_locals=False) 

417 ), 

418 structlog.processors.JSONRenderer(), 

419 ] 

420 else: 

421 renderers = [structlog.dev.ConsoleRenderer()] 

422 

423 formatter = structlog.stdlib.ProcessorFormatter( 

424 # These run ONLY on `logging` entries that do NOT originate within 

425 # structlog. 

426 foreign_pre_chain=shared_processors 

427 + [ 

428 # Add the information from the `logging` `extras` to the event dictionary 

429 structlog.stdlib.ExtraAdder() 

430 ], 

431 # These run on ALL entries after the pre_chain is done. 

432 processors=[ 

433 # Remove _record & _from_structlog. to avoid that this metadata is added to the final log record 

434 structlog.stdlib.ProcessorFormatter.remove_processors_meta, 

435 *renderers, 

436 ], 

437 ) 

438 

439 handler = logging.StreamHandler() 

440 handler.name = _HANDLER_NAME 

441 # Use OUR `ProcessorFormatter` to format all `logging` entries. 

442 handler.setFormatter(formatter) 

443 

444 # Attach the handler to the target logger(s) - Haystack's own namespaces by default (see `logger_name`). 

445 logger_names = [logger_name] if isinstance(logger_name, str) else list(logger_name) 

446 

447 # Remove our handler from every logger that carries it before re-installing: keeps re-configuration idempotent and 

448 # prevents double emission when the target changes (e.g. switching to the root logger via `logger_name=""`). 

449 existing_loggers = [logging.getLogger(), *logging.Logger.manager.loggerDict.values()] 

450 for existing_logger in existing_loggers: 

451 if isinstance(existing_logger, logging.Logger) and any( 

452 _is_haystack_logging_handler(h) for h in existing_logger.handlers 

453 ): 

454 existing_logger.handlers = [h for h in existing_logger.handlers if not _is_haystack_logging_handler(h)] 

455 

456 for name in logger_names: 

457 target_logger = logging.getLogger(name) 

458 target_logger.handlers = [handler, *target_logger.handlers] 

459 target_logger.propagate = propagate