Coverage for python/lsst/dax/apdb/monitor.py: 34%

104 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 10:45 +0000

1# This file is part of dax_apdb. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

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

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

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

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

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

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ["MonAgent", "MonService", "LoggingMonHandler"] 

25 

26import contextlib 

27import json 

28import logging 

29import time 

30from abc import ABC, abstractmethod 

31from collections.abc import Iterable, Iterator, Mapping 

32from typing import TYPE_CHECKING, Any 

33 

34from lsst.utils.classes import Singleton 

35 

36if TYPE_CHECKING: 

37 from contextlib import AbstractContextManager 

38 

39_TagsType = Mapping[str, str | int] 

40 

41 

42class MonHandler(ABC): 

43 """Interface for handlers of the monitoring records. 

44 

45 Handlers are responsible for delivering monitoring records to their final 

46 destination, for example log file or time-series database. 

47 """ 

48 

49 @abstractmethod 

50 def handle( 

51 self, name: str, timestamp: float, tags: _TagsType, values: Mapping[str, Any], agent_name: str 

52 ) -> None: 

53 """Handle one monitoring record. 

54 

55 Parameters 

56 ---------- 

57 name : `str` 

58 Record name, arbitrary string. 

59 timestamp : `str` 

60 Time in seconds since UNIX epoch when record originated. 

61 tags : `~collections.abc.Mapping` [`str`, `str` or `int`] 

62 Tags associated with the record, may be empty. 

63 values : `~collections.abc.Mapping` [`str`, `Any`] 

64 Values associated with the record, usually never empty. 

65 agent_name `str` 

66 Name of a client agent that produced this record. 

67 """ 

68 raise NotImplementedError() 

69 

70 

71class MonAgent: 

72 """Client-side interface for adding monitoring records to the monitoring 

73 service. 

74 

75 Parameters 

76 ---------- 

77 name : `str` 

78 Client agent name, this is used for filtering of the records by the 

79 service and ia also passed to monitoring handler as ``agent_name``. 

80 """ 

81 

82 def __init__(self, name: str = ""): 

83 self._name = name 

84 self._service = MonService() 

85 

86 def add_record( 

87 self, 

88 name: str, 

89 *, 

90 values: Mapping[str, Any], 

91 tags: Mapping[str, str | int] | None = None, 

92 timestamp: float | None = None, 

93 ) -> None: 

94 """Send one record to monitoring service. 

95 

96 Parameters 

97 ---------- 

98 name : `str` 

99 Record name, arbitrary string. 

100 values : `~collections.abc.Mapping` [`str`, `Any`] 

101 Values associated with the record, usually never empty. 

102 tags : `~collections.abc.Mapping` [`str`, `str` or `int`] 

103 Tags associated with the record, may be empty. 

104 timestamp : `str` 

105 Time in seconds since UNIX epoch when record originated. 

106 """ 

107 self._service._add_record( 

108 agent_name=self._name, 

109 record_name=name, 

110 tags=tags, 

111 values=values, 

112 timestamp=timestamp, 

113 ) 

114 

115 def context_tags(self, tags: _TagsType) -> AbstractContextManager[None]: 

116 """Context manager that adds a set of tags to all records created 

117 inside the context. 

118 

119 Parameters 

120 ---------- 

121 tags : `~collections.abc.Mapping` [`str`, `str` or `int`] 

122 Tags associated with the records. 

123 

124 Notes 

125 ----- 

126 All calls to `add_record` that happen inside the corresponding context 

127 will add tags specified in this call. Tags specified in `add_record` 

128 will override matching tag names that are passed to this method. On 

129 exit from context a previous tag context is restored (which may be 

130 empty). 

131 """ 

132 return self._service.context_tags(tags) 

133 

134 

135class MonFilter: 

136 """Filter for the names associated with client agents. 

137 

138 Parameters 

139 ---------- 

140 rule : `str` 

141 String specifying filtering rule for a single name, or catch-all rule. 

142 The rule consist of the agent name prefixed by minus or optional plus 

143 sign. Catch-all rule uses name "any". If the rule starts with minus 

144 sign then matching agent will be rejected. Otherwise matching agent 

145 is accepted. 

146 """ 

147 

148 def __init__(self, rule: str): 

149 self._accept = True 

150 if rule.startswith("-"): 

151 self._accept = False 

152 rule = rule[1:] 

153 elif rule.startswith("+"): 

154 rule = rule[1:] 

155 self.agent_name = "" if rule == "any" else rule 

156 

157 def is_match_all(self) -> bool: 

158 """Return `True` if this rule is a catch-all rule. 

159 

160 Returns 

161 ------- 

162 is_match_all : `bool` 

163 `True` if rule name is `-any`, `+any`, or `any`. 

164 """ 

165 return not self.agent_name 

166 

167 def accept(self, agent_name: str) -> bool | None: 

168 """Return filtering decision for specified agent name. 

169 

170 Parameters 

171 ---------- 

172 agent_name : `str` 

173 Name of the clent agent that produces monitoring record. 

174 

175 Returns 

176 ------- 

177 decision : `bool` or `None` 

178 `True` if the agent is accepted, `False` if agent is rejected. 

179 `None` is returned if this rule does not match agent name and 

180 decision should be made by the next rule. 

181 """ 

182 if not self.agent_name or agent_name == self.agent_name: 

183 return self._accept 

184 return None 

185 

186 

187class MonService(metaclass=Singleton): 

188 """Class implementing monitoring service functionality. 

189 

190 Notes 

191 ----- 

192 This is a singleton class which serves all client agents in an application. 

193 It accepts records from agents, filters it based on a set of configured 

194 rules and forwards them to one or more configured handlers. By default 

195 there are no handlers defined which means that all records are discarded. 

196 Default set of filtering rules is empty which accepts all agent names. 

197 

198 To produce a useful output from this service one has to add at least one 

199 handler using `add_handler` method (e.g. `LoggingMonHandler` instance). 

200 The `set_filters` methods can be used to specify the set of filtering 

201 rules. 

202 """ 

203 

204 _handlers: list[MonHandler] = [] 

205 """List of active handlers.""" 

206 

207 _context_tags: _TagsType | None = None 

208 """Current tag context, these tags are added to each new record.""" 

209 

210 _filters: list[MonFilter] = [] 

211 """Sequence of filters for agent names.""" 

212 

213 def set_filters(self, rules: Iterable[str]) -> None: 

214 """Define a sequence of rules for filtering of the agent names. 

215 

216 Parameters 

217 ---------- 

218 rules : `~collections.abc.Iterable` [`str`] 

219 Ordered collection of rules. Each string specifies filtering rule 

220 for a single name, or catch-all rule. The rule consist of the 

221 agent name prefixed by minus or optional plus sign. Catch-all rule 

222 uses name "any". If the rule starts with minus sign then matching 

223 agent will be rejected. Otherwise matching agent is accepted. 

224 

225 Notes 

226 ----- 

227 The catch-all rule (`-any`, `+any`, or `any`) can be specified in any 

228 location in the sequence but it is always applied last. E.g. 

229 `["-any", "+agent1"]` behaves the same as `["+agent1", "-any"]`. 

230 If the set of rues does not include catch-all rule, filtering behaves 

231 as if it is added implicitly as `+any`. 

232 

233 Filtering code evaluates each rule in order. First rule that matches 

234 the agent name wins. Agent names are matched literally, wildcards are 

235 not supported and there are no parent/child relations between agent 

236 names (e.g `lsst.dax.apdb` and `lsst.dax.apdb.sql` are treated as 

237 independent names). 

238 """ 

239 match_all: MonFilter | None = None 

240 self._filters = [] 

241 for rule in rules: 

242 mon_filter = MonFilter(rule) 

243 if mon_filter.is_match_all(): 

244 match_all = mon_filter 

245 else: 

246 self._filters.append(mon_filter) 

247 if match_all: 

248 self._filters.append(match_all) 

249 

250 def _add_record( 

251 self, 

252 *, 

253 agent_name: str, 

254 record_name: str, 

255 values: Mapping[str, Any], 

256 tags: Mapping[str, str | int] | None = None, 

257 timestamp: float | None = None, 

258 ) -> None: 

259 """Add one monitoring record, this method is for use by agents only.""" 

260 if self._handlers: 

261 accept: bool | None = None 

262 # Check every filter, accept if none makes any decision. 

263 for filter in self._filters: 

264 accept = filter.accept(agent_name) 

265 if accept is False: 

266 return 

267 if accept is True: 

268 break 

269 if timestamp is None: 

270 timestamp = time.time() 

271 if tags is None: 

272 tags = self._context_tags or {} 

273 else: 

274 if self._context_tags: 

275 all_tags = dict(self._context_tags) 

276 all_tags.update(tags) 

277 tags = all_tags 

278 for handler in self._handlers: 

279 handler.handle(record_name, timestamp, tags, values, agent_name) 

280 

281 @property 

282 def handlers(self) -> Iterable[MonHandler]: 

283 """Set of handlers defined currently.""" 

284 return self._handlers 

285 

286 def add_handler(self, handler: MonHandler) -> None: 

287 """Add one monitoring handler. 

288 

289 Parameters 

290 ---------- 

291 handler : `MonHandler` 

292 Handler instance. 

293 """ 

294 if handler not in self._handlers: 

295 self._handlers.append(handler) 

296 

297 def remove_handler(self, handler: MonHandler) -> None: 

298 """Add one monitoring handler. 

299 

300 Parameters 

301 ---------- 

302 handler : `MonHandler` 

303 Handler instance. 

304 """ 

305 if handler in self._handlers: 

306 self._handlers.remove(handler) 

307 

308 def _add_context_tags(self, tags: _TagsType) -> _TagsType | None: 

309 """Extend the tag context with new tags, overriding any tags that may 

310 already exist in a current context. 

311 """ 

312 old_tags = self._context_tags 

313 if not self._context_tags: 

314 self._context_tags = tags 

315 else: 

316 all_tags = dict(self._context_tags) 

317 all_tags.update(tags) 

318 self._context_tags = all_tags 

319 return old_tags 

320 

321 @contextlib.contextmanager 

322 def context_tags(self, tags: _TagsType) -> Iterator[None]: 

323 """Context manager that adds a set of tags to all records created 

324 inside the context. 

325 

326 Typically clients will be using `MonAgent.context_tags`, which forwards 

327 to this method. 

328 """ 

329 old_context = self._add_context_tags(tags) 

330 try: 

331 yield 

332 finally: 

333 # Restore old context. 

334 self._context_tags = old_context 

335 

336 

337class LoggingMonHandler(MonHandler): 

338 """Implementation of the monitoring handler which dumps records formatted 

339 as JSON objects to `logging`. 

340 

341 Parameters 

342 ---------- 

343 logger_name : `str` 

344 Name of the `logging` logger to use for output. 

345 log_level : `int`, optional 

346 Logging level to use for output, default is `INFO` 

347 

348 Notes 

349 ----- 

350 The attributes of the formatted JSON object correspond to the parameters 

351 of `handle` method, except for `agent_name` which is mapped to `source`. 

352 The `tags` and `values` become JSON sub-objects with corresponding keys. 

353 """ 

354 

355 def __init__(self, logger_name: str, log_level: int = logging.INFO): 

356 self._logger = logging.getLogger(logger_name) 

357 self._level = log_level 

358 

359 def handle( 

360 self, name: str, timestamp: float, tags: _TagsType, values: Mapping[str, Any], agent_name: str 

361 ) -> None: 

362 # Docstring is inherited from base class. 

363 record = { 

364 "name": name, 

365 "timestamp": timestamp, 

366 "tags": tags, 

367 "values": values, 

368 "source": agent_name, 

369 } 

370 msg = json.dumps(record) 

371 self._logger.log(self._level, msg)