Coverage for .tox/coverage/lib/python3.13/site-packages/wuttatell/telemetry.py: 100%

163 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-21 19:19 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttaTell -- Telemetry submission for Wutta Framework 

5# Copyright © 2025-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Telemetry submission handler 

25""" 

26 

27import logging 

28import os 

29import re 

30import shutil 

31import subprocess 

32 

33from wuttjamaican.app import GenericHandler 

34from wuttjamaican.conf import WuttaConfigProfile 

35 

36from wuttatell.client import SimpleAPIClient 

37 

38log = logging.getLogger(__name__) 

39 

40 

41class TelemetryHandler(GenericHandler): 

42 """ 

43 Handler for submission of telemetry data 

44 

45 The primary caller interface involves just two methods: 

46 

47 * :meth:`collect_all_data()` 

48 * :meth:`submit_all_data()` 

49 """ 

50 

51 def get_all_profile_keys(self): 

52 """ 

53 Retrieve list of all configured profile keys. 

54 

55 This will iterate over defined config values **from file(s) 

56 only** (will not read DB settings), and parse each option name 

57 to determine the set of unique profile names. 

58 

59 :returns: Sorted list of profile keys. If ``default`` is 

60 included, it will always be first in the list. 

61 """ 

62 keys = set() 

63 

64 defined = self.config.get_dict(f"{self.config.appname}.telemetry") 

65 for key in defined: 

66 if key.startswith("_"): 

67 continue 

68 if "." not in key: 

69 continue 

70 key = key.split(".")[0] 

71 keys.add(key) 

72 

73 keys = sorted(keys) 

74 if "default" in keys and keys[0] != "default": 

75 keys.remove("default") 

76 keys.insert(0, "default") 

77 

78 return keys 

79 

80 def get_profile(self, profile): # pylint: disable=empty-docstring 

81 """ """ 

82 if isinstance(profile, TelemetryProfile): 

83 return profile 

84 

85 return TelemetryProfile(self.config, profile or "default") 

86 

87 def collect_all_data(self, profile=None): 

88 """ 

89 Collect and return all data pertaining to the given profile. 

90 

91 The profile will determine which types of data to collect, 

92 e.g. ``('os', 'python')``. Corresponding handler methods 

93 are then called to collect each type; for instance: 

94 

95 * :meth:`collect_data_os()` 

96 * :meth:`collect_data_python()` 

97 

98 Once all data has been collected, errors are grouped to the 

99 top level of the structure. 

100 

101 :param profile: :class:`TelemetryProfile` instance, or key 

102 thereof. If not specified, ``'default'`` is assumed. 

103 

104 :returns: A dict of data, keyed by collection type. If any 

105 errors were encountered during collection, the dict will 

106 also have an ``'errors'`` key. 

107 """ 

108 data = {} 

109 profile = self.get_profile(profile) 

110 

111 log.debug( 

112 "collecting data for '%s' profile: %s", profile.key, profile.collect_keys 

113 ) 

114 for key in profile.collect_keys: 

115 collector = getattr(self, f"collect_data_{key}") 

116 data[key] = collector(profile=profile) 

117 

118 self.normalize_errors(data) 

119 log.debug(data) 

120 return data 

121 

122 def normalize_errors(self, data): # pylint: disable=empty-docstring 

123 """ """ 

124 all_errors = [] 

125 for value in data.values(): 

126 if value: 

127 errors = value.pop("errors", None) 

128 if errors: 

129 all_errors.extend(errors) 

130 if all_errors: 

131 data["errors"] = all_errors 

132 

133 def collect_data_os(self, profile, **kwargs): # pylint: disable=unused-argument 

134 """ 

135 Collect basic data about the operating system. 

136 

137 This parses ``/etc/os-release`` for basic OS info, and 

138 ``/etc/timezone`` for the timezone. 

139 

140 If all goes well the result looks like:: 

141 

142 { 

143 "release_id": "debian", 

144 "release_version": "12", 

145 "release_full": "Debian GNU/Linux 12 (bookworm)", 

146 "timezone": "America/Chicago", 

147 } 

148 

149 :param profile: :class:`TelemetryProfile` instance. Note that 

150 the default logic here ignores the profile. 

151 

152 :returns: Data dict similar to the above. May have an 

153 ``'errors'`` key if anything goes wrong. 

154 """ 

155 data = {} 

156 errors = [] 

157 

158 # release 

159 release_path = kwargs.get("release_path", "/etc/os-release") 

160 try: 

161 with open(release_path, "rt", encoding="utf_8") as f: 

162 output = f.read() 

163 except Exception: # pylint: disable=broad-exception-caught 

164 errors.append(f"Failed to read {release_path}") 

165 else: 

166 release = {} 

167 pattern = re.compile(r"^([^=]+)=(.*)$") 

168 for line in output.strip().split("\n"): 

169 if match := pattern.match(line): 

170 key, val = match.groups() 

171 if val.startswith('"') and val.endswith('"'): 

172 val = val.strip('"') 

173 release[key] = val 

174 try: 

175 data["release_id"] = release["ID"] 

176 data["release_version"] = release["VERSION_ID"] 

177 data["release_full"] = release["PRETTY_NAME"] 

178 except KeyError: 

179 errors.append(f"Failed to parse {release_path}") 

180 

181 # timezone 

182 timezone_path = kwargs.get("timezone_path", "/etc/timezone") 

183 try: 

184 with open(timezone_path, "rt", encoding="utf_8") as f: 

185 output = f.read() 

186 except Exception: # pylint: disable=broad-exception-caught 

187 errors.append(f"Failed to read {timezone_path}") 

188 else: 

189 data["timezone"] = output.strip() 

190 

191 if errors: 

192 data["errors"] = errors 

193 return data 

194 

195 def collect_data_python(self, profile): 

196 """ 

197 Collect basic data about the Python environment. 

198 

199 This primarily runs ``python --version`` for the desired 

200 environment. Note that the profile will determine which 

201 environment to inspect, e.g. system-wide or a specific virtual 

202 environment. 

203 

204 If all goes well the system-wide result looks like:: 

205 

206 { 

207 "executable": "/usr/bin/python3", 

208 "release_full": "Python 3.11.2", 

209 "release_version": "3.11.2", 

210 } 

211 

212 If a virtual environment is involved the result will include 

213 its root path:: 

214 

215 { 

216 "envroot": "/srv/envs/poser", 

217 "executable": "/srv/envs/poser/bin/python", 

218 "release_full": "Python 3.11.2", 

219 "release_version": "3.11.2", 

220 } 

221 

222 :param profile: :class:`TelemetryProfile` instance. 

223 

224 :returns: Data dict similar to the above. May have an 

225 ``'errors'`` key if anything goes wrong. 

226 """ 

227 data = {} 

228 errors = [] 

229 

230 # envroot determines python executable 

231 envroot = profile.get_str("collect.python.envroot") 

232 if envroot: 

233 data["envroot"] = envroot 

234 python = os.path.join(envroot, "bin/python") 

235 else: 

236 python = profile.get_str( 

237 "collect.python.executable", default="/usr/bin/python3" 

238 ) 

239 

240 # python version 

241 data["executable"] = python 

242 try: 

243 # nb. must capture stderr also, for sake of python 2.7 

244 output = subprocess.check_output( 

245 [python, "--version"], stderr=subprocess.STDOUT 

246 ) 

247 except (subprocess.CalledProcessError, FileNotFoundError) as err: 

248 errors.append("Failed to execute `python --version`") 

249 errors.append(str(err)) 

250 else: 

251 output = output.decode("utf_8").strip() 

252 data["release_full"] = output 

253 if match := re.match(r"^Python (\d+\.\d+\.\d+)", output): 

254 data["release_version"] = match.group(1) 

255 else: 

256 errors.append("Failed to parse Python version") 

257 

258 if errors: 

259 data["errors"] = errors 

260 return data 

261 

262 def collect_data_black(self, profile): 

263 """ 

264 Collect basic data about the `black`_ command, if present. 

265 

266 .. _black: https://black.readthedocs.io/en/stable/ 

267 

268 The ``black`` executable is assumed to be found in ``PATH``; if 

269 so then ``black --version`` is called to get the result:: 

270 

271 { 

272 "executable": "/usr/local/bin/black", 

273 "release_full": ("black, 25.1.0 (compiled: yes)\\n" 

274 "Python (CPython) 3.13.5"), 

275 "release_version": "25.1.0", 

276 } 

277 

278 :param profile: :class:`TelemetryProfile` instance. 

279 

280 :returns: Data dict similar to the above. May have an 

281 ``'errors'`` key if anything goes wrong. 

282 """ 

283 data = {} 

284 errors = [] 

285 

286 black_path = profile.get_str("collect.black.executable") 

287 if not black_path: 

288 black_path = shutil.which("black") 

289 if not black_path: 

290 errors.append("Failed to locate black executable") 

291 

292 data["executable"] = black_path 

293 if black_path: 

294 try: 

295 output = subprocess.check_output([black_path, "--version"]) 

296 except (subprocess.CalledProcessError, FileNotFoundError) as err: 

297 errors.append("Failed to execute `black --version`") 

298 errors.append(str(err)) 

299 else: 

300 output = output.decode("utf_8").strip() 

301 lines = output.split("\n") 

302 if match := re.match(r"^black\, (\d+\.\d+\.\d+) ", lines[0]): 

303 data["release_full"] = output 

304 data["release_version"] = match.group(1) 

305 else: 

306 errors.append(f"Failed to parse Black version: {output}") 

307 

308 if errors: 

309 data["errors"] = errors 

310 return data 

311 

312 def collect_data_borg(self, profile): 

313 """ 

314 Collect basic data about the Borg backup command, if present. 

315 

316 The ``borg`` executable is assumed to be found in ``PATH``; if 

317 so then ``borg --version`` is called to get the result:: 

318 

319 { 

320 "executable": "/usr/local/bin/borg", 

321 "release_version": "1.4.5", 

322 } 

323 

324 :param profile: :class:`TelemetryProfile` instance. 

325 

326 :returns: Data dict similar to the above. May have an 

327 ``'errors'`` key if anything goes wrong. 

328 """ 

329 data = {} 

330 errors = [] 

331 

332 borg_path = profile.get_str("collect.borg.executable") 

333 if not borg_path: 

334 borg_path = shutil.which("borg") 

335 if not borg_path: 

336 errors.append("Failed to locate borg executable") 

337 

338 data["executable"] = borg_path 

339 if borg_path: 

340 try: 

341 output = subprocess.check_output([borg_path, "--version"]) 

342 except (subprocess.CalledProcessError, FileNotFoundError) as err: 

343 errors.append("Failed to execute `borg --version`") 

344 errors.append(str(err)) 

345 else: 

346 output = output.decode("utf_8").strip() 

347 if match := re.match(r"^borg (\d+\.\d+\.\d+)", output): 

348 data["release_version"] = match.group(1) 

349 else: 

350 errors.append(f"Failed to parse Borg version: {output}") 

351 

352 if errors: 

353 data["errors"] = errors 

354 return data 

355 

356 def submit_all_data(self, profile=None, data=None): 

357 """ 

358 Submit telemetry data to the configured collection service. 

359 

360 Default logic will use 

361 :class:`~wuttatell.client.SimpleAPIClient` and submit all 

362 collected data to the configured API endpoint. 

363 

364 :param profile: :class:`TelemetryProfile` instance. 

365 

366 :param data: Data dict as obtained by 

367 :meth:`collect_all_data()`. 

368 """ 

369 profile = self.get_profile(profile) 

370 if data is None: 

371 data = self.collect_all_data(profile) 

372 

373 client = SimpleAPIClient(self.config) 

374 client.post(profile.submit_url, data=data) 

375 

376 

377class TelemetryProfile(WuttaConfigProfile): 

378 """ 

379 Represents a configured profile for telemetry submission. 

380 

381 This is a subclass of 

382 :class:`~wuttjamaican:wuttjamaican.conf.WuttaConfigProfile`, and 

383 similarly works off the 

384 :attr:`~wuttjamaican:wuttjamaican.conf.WuttaConfigProfile.key` to 

385 identify each configured profile. 

386 

387 Upon construction each profile instance will have the following 

388 attributes, determined by config: 

389 

390 .. attribute:: collect_keys 

391 

392 List of keys identifying the types of data to collect, 

393 e.g. ``["os", "python", "borg"]``. 

394 

395 .. attribute:: submit_url 

396 

397 URL to which collected telemetry data should be submitted. 

398 """ 

399 

400 @property 

401 def section(self): # pylint: disable=empty-docstring 

402 """ """ 

403 return f"{self.config.appname}.telemetry" 

404 

405 def load(self): # pylint: disable=empty-docstring 

406 """ """ 

407 keys = self.get_str("collect.keys", default="os,python,black,borg") 

408 self.collect_keys = self.config.parse_list(keys) 

409 self.submit_url = self.get_str("submit.url")