Coverage for qbittorrent_exporter/exporter.py: 68%

132 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-21 09:03 +0000

1import faulthandler 

2import logging 

3import os 

4import signal 

5import sys 

6import time 

7from dataclasses import dataclass, field 

8from enum import StrEnum, auto 

9from typing import Any, Iterable 

10 

11from prometheus_client import start_http_server 

12from prometheus_client.core import REGISTRY, CounterMetricFamily, GaugeMetricFamily 

13from pythonjsonlogger import jsonlogger 

14from qbittorrentapi import Client, TorrentStates 

15 

16# Enable dumps on stderr in case of segfault 

17faulthandler.enable() 

18logger = logging.getLogger() 

19 

20 

21class MetricType(StrEnum): 

22 """ 

23 Represents possible metric types (used in this project). 

24 """ 

25 

26 GAUGE = auto() 

27 COUNTER = auto() 

28 

29 

30@dataclass 

31class Metric: 

32 """ 

33 Contains data and metadata about a single counter or gauge. 

34 """ 

35 

36 name: str 

37 value: Any 

38 labels: dict[str, str] = field(default_factory=lambda: {}) # Default to empty dict 

39 help_text: str = "" 

40 metric_type: MetricType = MetricType.GAUGE 

41 

42 

43class QbittorrentMetricsCollector: 

44 def __init__(self, config: dict) -> None: 

45 self.config = config 

46 self.client = Client( 

47 host=config["host"], 

48 port=config["port"], 

49 username=config["username"], 

50 password=config["password"], 

51 VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"], 

52 ) 

53 

54 def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]: 

55 """ 

56 Yields Prometheus gauges and counters from metrics collected from qbittorrent. 

57 """ 

58 metrics: list[Metric] = self.get_qbittorrent_metrics() 

59 

60 for metric in metrics: 

61 if metric.metric_type == MetricType.COUNTER: 

62 prom_metric = CounterMetricFamily( 

63 metric.name, metric.help_text, labels=list(metric.labels.keys()) 

64 ) 

65 else: 

66 prom_metric = GaugeMetricFamily( 

67 metric.name, metric.help_text, labels=list(metric.labels.keys()) 

68 ) 

69 prom_metric.add_metric( 

70 value=metric.value, labels=list(metric.labels.values()) 

71 ) 

72 yield prom_metric 

73 

74 def get_qbittorrent_metrics(self) -> list[Metric]: 

75 """ 

76 Calls and combines qbittorrent state metrics with torrent metrics. 

77 """ 

78 metrics: list[Metric] = [] 

79 metrics.extend(self._get_qbittorrent_status_metrics()) 

80 metrics.extend(self._get_qbittorrent_torrent_tags_metrics()) 

81 

82 return metrics 

83 

84 def _get_qbittorrent_status_metrics(self) -> list[Metric]: 

85 """ 

86 Returns metrics about the state of the qbittorrent server. 

87 """ 

88 response: dict[str, Any] = {} 

89 version: str = "" 

90 

91 # Fetch data from API 

92 try: 

93 response = self.client.transfer.info 

94 version = self.client.app.version 

95 except Exception as e: 

96 logger.error(f"Couldn't get server info: {e}") 

97 

98 return [ 

99 Metric( 

100 name=f"{self.config['metrics_prefix']}_up", 

101 value=bool(response), 

102 labels={"version": version}, 

103 help_text=( 

104 "Whether the qBittorrent server is answering requests from this" 

105 " exporter. A `version` label with the server version is added." 

106 ), 

107 ), 

108 Metric( 

109 name=f"{self.config['metrics_prefix']}_connected", 

110 value=response.get("connection_status", "") == "connected", 

111 labels={}, # no labels in the example 

112 help_text=( 

113 "Whether the qBittorrent server is connected to the Bittorrent" 

114 " network." 

115 ), 

116 ), 

117 Metric( 

118 name=f"{self.config['metrics_prefix']}_firewalled", 

119 value=response.get("connection_status", "") == "firewalled", 

120 labels={}, # no labels in the example 

121 help_text=( 

122 "Whether the qBittorrent server is connected to the Bittorrent" 

123 " network but is behind a firewall." 

124 ), 

125 ), 

126 Metric( 

127 name=f"{self.config['metrics_prefix']}_dht_nodes", 

128 value=response.get("dht_nodes", 0), 

129 labels={}, # no labels in the example 

130 help_text="Number of DHT nodes connected to.", 

131 ), 

132 Metric( 

133 name=f"{self.config['metrics_prefix']}_dl_info_data", 

134 value=response.get("dl_info_data", 0), 

135 labels={}, # no labels in the example 

136 help_text="Data downloaded since the server started, in bytes.", 

137 metric_type=MetricType.COUNTER, 

138 ), 

139 Metric( 

140 name=f"{self.config['metrics_prefix']}_up_info_data", 

141 value=response.get("up_info_data", 0), 

142 labels={}, # no labels in the example 

143 help_text="Data uploaded since the server started, in bytes.", 

144 metric_type=MetricType.COUNTER, 

145 ), 

146 ] 

147 

148 def _fetch_categories(self) -> dict: 

149 """Fetches all categories in use from qbittorrent.""" 

150 try: 

151 categories = dict(self.client.torrent_categories.categories) 

152 for key, value in categories.items(): 

153 categories[key] = dict(value) # type: ignore 

154 return categories 

155 except Exception as e: 

156 logger.error(f"Couldn't fetch categories: {e}") 

157 return {} 

158 

159 def _fetch_torrents(self) -> list[dict]: 

160 """Fetches torrents from qbittorrent""" 

161 try: 

162 return [dict(_attr_dict) for _attr_dict in self.client.torrents.info()] 

163 except Exception as e: 

164 logger.error(f"Couldn't fetch torrents: {e}") 

165 return [] 

166 

167 def _filter_torrents_by_category( 

168 self, category: str, torrents: list[dict] 

169 ) -> list[dict]: 

170 """Filters torrents by the given category.""" 

171 return [ 

172 torrent 

173 for torrent in torrents 

174 if torrent["category"] == category 

175 or (category == "Uncategorized" and torrent["category"] == "") 

176 ] 

177 

178 def _filter_torrents_by_state( 

179 self, state: TorrentStates, torrents: list[dict] 

180 ) -> list[dict]: 

181 """Filters torrents by the given state.""" 

182 return [torrent for torrent in torrents if torrent["state"] == state.value] 

183 

184 def _construct_metric(self, state: str, category: str, count: int) -> Metric: 

185 """Constructs and returns a metric object with a torrent count and appropriate 

186 labels.""" 

187 return Metric( 

188 name=f"{self.config['metrics_prefix']}_torrents_count", 

189 value=count, 

190 labels={ 

191 "status": state, 

192 "category": category, 

193 }, 

194 help_text=f"Number of torrents in status {state} under category {category}", 

195 ) 

196 

197 def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]: 

198 categories = self._fetch_categories() 

199 torrents = self._fetch_torrents() 

200 

201 metrics: list[Metric] = [] 

202 categories["Uncategorized"] = {"name": "Uncategorized", "savePath": ""} 

203 

204 for category in categories: 

205 category_torrents = self._filter_torrents_by_category(category, torrents) 

206 for state in TorrentStates: 

207 state_torrents = self._filter_torrents_by_state( 

208 state, category_torrents 

209 ) 

210 metric = self._construct_metric( 

211 state.value, category, len(state_torrents) 

212 ) 

213 metrics.append(metric) 

214 

215 return metrics 

216 

217 

218class ShutdownSignalHandler: 

219 def __init__(self): 

220 self.shutdown_count: int = 0 

221 

222 # Register signal handler 

223 signal.signal(signal.SIGINT, self._on_signal_received) 

224 signal.signal(signal.SIGTERM, self._on_signal_received) 

225 

226 def is_shutting_down(self): 

227 return self.shutdown_count > 0 

228 

229 def _on_signal_received(self, signal, frame): 

230 if self.shutdown_count > 1: 

231 logger.warn("Forcibly killing exporter") 

232 sys.exit(1) 

233 logger.info("Exporter is shutting down") 

234 self.shutdown_count += 1 

235 

236 

237def _get_config_value(key: str, default: str = "") -> str: 

238 input_path = os.environ.get("FILE__" + key, None) 

239 if input_path is not None: 

240 try: 

241 with open(input_path, "r") as input_file: 

242 return input_file.read().strip() 

243 except IOError as e: 

244 logger.error(f"Unable to read value for {key} from {input_path}: {str(e)}") 

245 

246 return os.environ.get(key, default) 

247 

248 

249def get_config() -> dict: 

250 """Loads all config values.""" 

251 return { 

252 "host": _get_config_value("QBITTORRENT_HOST", ""), 

253 "port": _get_config_value("QBITTORRENT_PORT", ""), 

254 "username": _get_config_value("QBITTORRENT_USER", ""), 

255 "password": _get_config_value("QBITTORRENT_PASS", ""), 

256 "exporter_port": int(_get_config_value("EXPORTER_PORT", "8000")), 

257 "log_level": _get_config_value("EXPORTER_LOG_LEVEL", "INFO"), 

258 "metrics_prefix": _get_config_value("METRICS_PREFIX", "qbittorrent"), 

259 "verify_webui_certificate": ( 

260 _get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True" 

261 ), 

262 } 

263 

264 

265def main(): 

266 # Init logger so it can be used 

267 logHandler = logging.StreamHandler() 

268 formatter = jsonlogger.JsonFormatter( 

269 "%(asctime) %(levelname) %(message)", datefmt="%Y-%m-%d %H:%M:%S" 

270 ) 

271 logHandler.setFormatter(formatter) 

272 logger.addHandler(logHandler) 

273 logger.setLevel("INFO") # default until config is loaded 

274 

275 config = get_config() 

276 

277 # set level once config has been loaded 

278 logger.setLevel(config["log_level"]) 

279 

280 # Register signal handler 

281 signal_handler = ShutdownSignalHandler() 

282 

283 if not config["host"]: 

284 logger.error( 

285 "No host specified, please set QBITTORRENT_HOST environment variable" 

286 ) 

287 sys.exit(1) 

288 if not config["port"]: 

289 logger.error( 

290 "No port specified, please set QBITTORRENT_PORT environment variable" 

291 ) 

292 sys.exit(1) 

293 

294 # Register our custom collector 

295 logger.info("Exporter is starting up") 

296 REGISTRY.register(QbittorrentMetricsCollector(config)) # type: ignore 

297 

298 # Start server 

299 start_http_server(config["exporter_port"]) 

300 logger.info(f"Exporter listening on port {config['exporter_port']}") 

301 

302 while not signal_handler.is_shutting_down(): 

303 time.sleep(1) 

304 

305 logger.info("Exporter has shutdown") 

306 

307 

308if __name__ == "__main__": 

309 main()