Coverage for python/lsst/dax/apdb/monitor.py: 34%
104 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 02:54 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 02:54 -0700
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/>.
22from __future__ import annotations
24__all__ = ["MonAgent", "MonService", "LoggingMonHandler"]
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
34from lsst.utils.classes import Singleton
36if TYPE_CHECKING:
37 from contextlib import AbstractContextManager
39_TagsType = Mapping[str, str | int]
42class MonHandler(ABC):
43 """Interface for handlers of the monitoring records.
45 Handlers are responsible for delivering monitoring records to their final
46 destination, for example log file or time-series database.
47 """
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.
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()
71class MonAgent:
72 """Client-side interface for adding monitoring records to the monitoring
73 service.
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 """
82 def __init__(self, name: str = ""):
83 self._name = name
84 self._service = MonService()
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.
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 )
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.
119 Parameters
120 ----------
121 tags : `~collections.abc.Mapping` [`str`, `str` or `int`]
122 Tags associated with the records.
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)
135class MonFilter:
136 """Filter for the names associated with client agents.
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 """
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
157 def is_match_all(self) -> bool:
158 """Return `True` if this rule is a catch-all rule.
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
167 def accept(self, agent_name: str) -> bool | None:
168 """Return filtering decision for specified agent name.
170 Parameters
171 ----------
172 agent_name : `str`
173 Name of the clent agent that produces monitoring record.
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
187class MonService(metaclass=Singleton):
188 """Class implementing monitoring service functionality.
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.
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 """
204 _handlers: list[MonHandler] = []
205 """List of active handlers."""
207 _context_tags: _TagsType | None = None
208 """Current tag context, these tags are added to each new record."""
210 _filters: list[MonFilter] = []
211 """Sequence of filters for agent names."""
213 def set_filters(self, rules: Iterable[str]) -> None:
214 """Define a sequence of rules for filtering of the agent names.
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.
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`.
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)
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)
281 @property
282 def handlers(self) -> Iterable[MonHandler]:
283 """Set of handlers defined currently."""
284 return self._handlers
286 def add_handler(self, handler: MonHandler) -> None:
287 """Add one monitoring handler.
289 Parameters
290 ----------
291 handler : `MonHandler`
292 Handler instance.
293 """
294 if handler not in self._handlers:
295 self._handlers.append(handler)
297 def remove_handler(self, handler: MonHandler) -> None:
298 """Add one monitoring handler.
300 Parameters
301 ----------
302 handler : `MonHandler`
303 Handler instance.
304 """
305 if handler in self._handlers:
306 self._handlers.remove(handler)
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
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.
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
337class LoggingMonHandler(MonHandler):
338 """Implementation of the monitoring handler which dumps records formatted
339 as JSON objects to `logging`.
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`
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 """
355 def __init__(self, logger_name: str, log_level: int = logging.INFO):
356 self._logger = logging.getLogger(logger_name)
357 self._level = log_level
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)