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

97 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-31 18:58 -0500

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

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

3# 

4# WuttaTell -- Telemetry submission for Wutta Framework 

5# Copyright © 2025 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 os 

28import re 

29import subprocess 

30 

31from wuttjamaican.app import GenericHandler 

32from wuttjamaican.conf import WuttaConfigProfile 

33 

34from wuttatell.client import SimpleAPIClient 

35 

36 

37class TelemetryHandler(GenericHandler): 

38 """ 

39 Handler for submission of telemetry data 

40 

41 The primary caller interface involves just two methods: 

42 

43 * :meth:`collect_all_data()` 

44 * :meth:`submit_all_data()` 

45 """ 

46 

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

48 """ """ 

49 if isinstance(profile, TelemetryProfile): 

50 return profile 

51 

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

53 

54 def collect_all_data(self, profile=None): 

55 """ 

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

57 

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

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

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

61 

62 * :meth:`collect_data_os()` 

63 * :meth:`collect_data_python()` 

64 

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

66 top level of the structure. 

67 

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

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

70 

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

72 errors were encountered during collection, the dict will 

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

74 """ 

75 data = {} 

76 profile = self.get_profile(profile) 

77 

78 for key in profile.collect_keys: 

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

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

81 

82 self.normalize_errors(data) 

83 return data 

84 

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

86 """ """ 

87 all_errors = [] 

88 for value in data.values(): 

89 if value: 

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

91 if errors: 

92 all_errors.extend(errors) 

93 if all_errors: 

94 data["errors"] = all_errors 

95 

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

97 """ 

98 Collect basic data about the operating system. 

99 

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

101 ``/etc/timezone`` for the timezone. 

102 

103 If all goes well the result looks like:: 

104 

105 { 

106 "release_id": "debian", 

107 "release_version": "12", 

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

109 "timezone": "America/Chicago", 

110 } 

111 

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

113 the default logic here ignores the profile. 

114 

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

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

117 """ 

118 data = {} 

119 errors = [] 

120 

121 # release 

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

123 try: 

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

125 output = f.read() 

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

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

128 else: 

129 release = {} 

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

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

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

133 key, val = match.groups() 

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

135 val = val.strip('"') 

136 release[key] = val 

137 try: 

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

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

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

141 except KeyError: 

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

143 

144 # timezone 

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

146 try: 

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

148 output = f.read() 

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

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

151 else: 

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

153 

154 if errors: 

155 data["errors"] = errors 

156 return data 

157 

158 def collect_data_python(self, profile): 

159 """ 

160 Collect basic data about the Python environment. 

161 

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

163 environment. Note that the profile will determine which 

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

165 environment. 

166 

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

168 

169 { 

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

171 "release_full": "Python 3.11.2", 

172 "release_version": "3.11.2", 

173 } 

174 

175 If a virtual environment is involved the result will include 

176 its root path:: 

177 

178 { 

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

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

181 "release_full": "Python 3.11.2", 

182 "release_version": "3.11.2", 

183 } 

184 

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

186 

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

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

189 """ 

190 data = {} 

191 errors = [] 

192 

193 # envroot determines python executable 

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

195 if envroot: 

196 data["envroot"] = envroot 

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

198 else: 

199 python = profile.get_str( 

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

201 ) 

202 

203 # python version 

204 data["executable"] = python 

205 try: 

206 output = subprocess.check_output([python, "--version"]) 

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

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

209 errors.append(str(err)) 

210 else: 

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

212 data["release_full"] = output 

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

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

215 else: 

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

217 

218 if errors: 

219 data["errors"] = errors 

220 return data 

221 

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

223 """ 

224 Submit telemetry data to the configured collection service. 

225 

226 Default logic will use 

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

228 collected data to the configured API endpoint. 

229 

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

231 

232 :param data: Data dict as obtained by 

233 :meth:`collect_all_data()`. 

234 """ 

235 profile = self.get_profile(profile) 

236 if data is None: 

237 data = self.collect_all_data(profile) 

238 

239 client = SimpleAPIClient(self.config) 

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

241 

242 

243class TelemetryProfile(WuttaConfigProfile): 

244 """ 

245 Represents a configured profile for telemetry submission. 

246 

247 This is a subclass of 

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

249 similarly works off the 

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

251 identify each configured profile. 

252 

253 Upon construction each profile instance will have the following 

254 attributes, determined by config: 

255 

256 .. attribute:: collect_keys 

257 

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

259 e.g. ``['os', 'python']``. 

260 

261 .. attribute:: submit_url 

262 

263 URL to which collected telemetry data should be submitted. 

264 """ 

265 

266 @property 

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

268 """ """ 

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

270 

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

272 """ """ 

273 keys = self.get_str("collect.keys", default="os,python") 

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

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