feat(jobspec): Unify soak jobspecs
[csit.git] / resources / tools / telemetry / metrics.py
1 # Copyright (c) 2021 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Metric library."""
15
16 from collections import namedtuple
17 from threading import Lock
18 from time import monotonic
19 import re
20
21
22 class Value:
23     """
24     A value storage protected by a mutex.
25     """
26     def __init__(self):
27         """
28         Initialize value to default and create a lock.
29         """
30         self._value = 0.0
31         self._lock = Lock()
32         self._timestamp = None
33
34     def inc(self, amount):
35         """
36         Increment value by amount under mutex.
37         Add a timestamp of capturing value.
38
39         :param amount: Amount of increment.
40         :type amount: int or float
41         """
42         with self._lock:
43             self._value += amount
44             self._timestamp = monotonic()
45
46     def set(self, value):
47         """
48         Set to a specific value under mutex.
49         Add a timestamp of capturing value.
50
51         :param value: Amount of increment.
52         :type value: int or float
53         """
54         with self._lock:
55             self._value = value
56             self._timestamp = monotonic()
57
58     def get(self):
59         """
60         Get a value under mutex.
61
62         :returns: Stored value.
63         :rtype: int or float
64         """
65         with self._lock:
66             return self._value
67
68     def get_timestamp(self):
69         """
70         Get a timestamp under mutex.
71
72         :returns: Stored timestamp.
73         :rtype: str
74         """
75         with self._lock:
76             return self._timestamp
77
78
79 class Metric:
80     """
81     A single metric parent and its samples.
82     """
83     def __init__(self, name, documentation, typ):
84         """
85         Initialize class and do basic sanitize.
86
87         :param name: Full metric name.
88         :param documentation: Metric HELP string.
89         :param typ: Metric type [counter|gauge|info].
90         :type name: str
91         :type documentation: str
92         :type typ: str
93         """
94         self.metric_types = (
95             u"counter", u"gauge", u"info"
96         )
97         self.metric_sample = namedtuple(
98             u"Sample", [u"name", u"labels", u"value", u"timestamp"]
99         )
100
101         if not re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$").match(name):
102             raise ValueError(f"Invalid metric name: {name}!")
103         if typ not in self.metric_types:
104             raise ValueError(f"Invalid metric type: {typ}!")
105
106         self.name = name
107         self.documentation = documentation
108         self.type = typ
109         self.samples = []
110
111     def add_sample(self, name, labels, value, timestamp):
112         """
113         Add a sample (entry) to the metric.
114
115         :param name: Full metric name.
116         :param labels: Metric labels.
117         :param value: Metric value.
118         :param timestamp: Timestamp. Default to be when accessed.
119         :type name: str
120         :type lables: tuple
121         :type value: int or float
122         :type timestamp: float
123         """
124         self.samples.append(
125             self.metric_sample(name, labels, value, timestamp)
126         )
127
128     def __eq__(self, other):
129         """
130         Check equality of added metric.
131
132         :param other: Metric to compare.
133         :type other: Metric
134         """
135         return (isinstance(other, Metric)
136                 and self.name == other.name
137                 and self.documentation == other.documentation
138                 and self.type == other.type
139                 and self.samples == other.samples)
140
141     def __repr__(self):
142         """
143         Represantation as a string for a debug print.
144         """
145         return (
146             f"Metric({self.name}, "
147             f"{self.documentation}, "
148             f"{self.type}, "
149             f"{self.samples})"
150         )
151
152
153 class MetricBase:
154     """
155     Abstract class for Metric implementation.
156     """
157     _type = None
158
159     def __init__(
160             self, name, documentation, labelnames=(), namespace="",
161             subsystem="", labelvalues=None,
162         ):
163         """
164         Metric initialization.
165
166         :param name: Metric name.
167         :param documentation: Metric HELP string.
168         :param labelnames: Metric label list.
169         :param namespace: Metric namespace (will be added as prefix).
170         :param subsystem: Metric susbsystem (will be added as prefix).
171         :param labelvalues: Metric label values.
172         :type name: str
173         :type documentation: str
174         :type labelnames: list
175         :type namespace: str
176         :type subsystem: str
177         :type labelvalues: list
178         """
179         self._name = self.validate_name(name, namespace, subsystem)
180         self._labelnames = self.validate_labelnames(labelnames)
181         self._labelvalues = tuple(labelvalues or ())
182         self._documentation = documentation
183
184         if self._is_parent():
185             self._lock = Lock()
186             self._metrics = {}
187
188         if self._is_observable():
189             self._metric_init()
190
191     @staticmethod
192     def validate_name(name, namespace, subsystem):
193         """
194         Construct metric full name and validate naming convention.
195
196         :param name: Metric name.
197         :param namespace: Metric namespace (will be added as prefix).
198         :param subsystem: Metric susbsystem (will be added as prefix).
199         :type name: str
200         :type namespace: str
201         :type subsystem: str
202         :returns: Metric full name.
203         :rtype: str
204         :rasies ValueError: If name does not conform with naming conventions.
205         """
206         full_name = u""
207         full_name += f"{namespace}_" if namespace else u""
208         full_name += f"{subsystem}_" if subsystem else u""
209         full_name += name
210
211         if not re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$").match(full_name):
212             raise ValueError(
213                 f"Invalid metric name: {full_name}!"
214             )
215         return full_name
216
217     @staticmethod
218     def validate_labelnames(labelnames):
219         """
220         Create label tuple and validate naming convention.
221
222         :param labelnames: Metric label list.
223         :type labelnames: list
224         :returns: Label names.
225         :rtype: tuple
226         :rasies ValueError: If name does not conform with naming conventions.
227         """
228         labelnames = tuple(labelnames)
229         for label in labelnames:
230             if not re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$").match(label):
231                 raise ValueError(f"Invalid label metric name: {label}!")
232             if re.compile(r"^__.*$").match(label):
233                 raise ValueError(f"Reserved label metric name: {label}!")
234         return labelnames
235
236     def _is_observable(self):
237         """
238         Check whether this metric is observable, i.e.
239         * a metric without label names and values, or
240         * the child of a labelled metric.
241
242         :return: Observable
243         :rtype: bool
244         """
245         return not self._labelnames or (self._labelnames and self._labelvalues)
246
247     def _is_parent(self):
248         """
249         Check whether metric is parent, i.e.
250         * a metric with label names but not its values.
251
252         :return: Parent
253         :rtype: bool
254         """
255         return self._labelnames and not self._labelvalues
256
257     def _get_metric(self):
258         """
259         Returns metric that will handle samples.
260
261         :returns: Metric object.
262         :rtype: Metric
263         """
264         return Metric(self._name, self._documentation, self._type)
265
266     def describe(self):
267         """
268         Returns metric that will handle samples.
269
270         :returns: List of metric objects.
271         :rtype: list
272         """
273         return [self._get_metric()]
274
275     def collect(self):
276         """
277         Returns metric with samples.
278
279         :returns: List with metric object.
280         :rtype: list
281         """
282         metric = self._get_metric()
283         for suffix, labels, value, timestamp in self.samples():
284             metric.add_sample(self._name + suffix, labels, value, timestamp)
285         return [metric]
286
287     def labels(self, *labelvalues, **labelkwargs):
288         """
289         Return the child for the given labelset.
290
291         :param labelvalues: Label values.
292         :param labelkwargs: Dictionary with label names and values.
293         :type labelvalues: list
294         :type labelkwargs: dict
295         :returns: Metric with labels and values.
296         :rtype: Metric
297         :raises ValueError: If labels were not initialized.
298         :raises ValueError: If labels are already set (chaining).
299         :raises ValueError: If both parameters are passed.
300         :raises ValueError: If label values are not matching label names.
301         """
302         if not self._labelnames:
303             raise ValueError(
304                 f"No label names were set when constructing {self}!"
305             )
306
307         if self._labelvalues:
308             raise ValueError(
309                 f"{self} already has labels set; can not chain .labels() calls!"
310             )
311
312         if labelvalues and labelkwargs:
313             raise ValueError(
314                 u"Can't pass both *args and **kwargs!"
315             )
316
317         if labelkwargs:
318             if sorted(labelkwargs) != sorted(self._labelnames):
319                 raise ValueError(u"Incorrect label names!")
320             labelvalues = tuple(labelkwargs[l] for l in self._labelnames)
321         else:
322             if len(labelvalues) != len(self._labelnames):
323                 raise ValueError(u"Incorrect label count!")
324             labelvalues = tuple(l for l in labelvalues)
325         with self._lock:
326             if labelvalues not in self._metrics:
327                 self._metrics[labelvalues] = self.__class__(
328                     self._name,
329                     documentation=self._documentation,
330                     labelnames=self._labelnames,
331                     labelvalues=labelvalues
332                 )
333             return self._metrics[labelvalues]
334
335     def samples(self):
336         """
337         Returns samples wheter an object is parent or child.
338
339         :returns: List of Metric objects with values.
340         :rtype: list
341         """
342         if self._is_parent():
343             return self._multi_samples()
344         return self._child_samples()
345
346     def _multi_samples(self):
347         """
348         Returns parent and its childs with its values.
349
350         :returns: List of Metric objects with values.
351         :rtype: list
352         """
353         with self._lock:
354             metrics = self._metrics.copy()
355         for labels, metric in metrics.items():
356             series_labels = list(zip(self._labelnames, labels))
357             for suffix, sample_labels, value, timestamp in metric.samples():
358                 yield (
359                     suffix, dict(series_labels + list(sample_labels.items())),
360                     value, timestamp
361                 )
362
363     def _child_samples(self):
364         """
365         Returns child with its values. Should be implemented by child class.
366
367         :raises NotImplementedError: If implementation in not in subclass.
368         """
369         raise NotImplementedError(
370             f"_child_samples() must be implemented by {self}!"
371         )
372
373     def _metric_init(self):
374         """
375         Initialize the metric object as a child.
376
377         :raises NotImplementedError: If implementation in not in subclass.
378         """
379         raise NotImplementedError(
380             f"_metric_init() must be implemented by {self}!"
381         )
382
383     def __str__(self):
384         """
385         String for a debug print.
386         """
387         return f"{self._type}:{self._name}"
388
389     def __repr__(self):
390         """
391         Represantation as a string for a debug print.
392         """
393         metric_type = type(self)
394         return f"{metric_type.__module__}.{metric_type.__name__}({self._name})"
395
396
397 class Counter(MetricBase):
398     """
399     A Counter tracks counts of events or running totals.
400     """
401     _type = u"counter"
402
403     def __init__(self,
404                  name,
405                  documentation,
406                  labelnames=(),
407                  namespace=u"",
408                  subsystem=u"",
409                  labelvalues=None
410                  ):
411         """
412         Initialize the Counter metric object.
413
414         :param name: Metric name.
415         :param documentation: Metric HELP string.
416         :param labelnames: Metric label list.
417         :param namespace: Metric namespace (will be added as prefix).
418         :param subsystem: Metric susbsystem (will be added as prefix).
419         :param labelvalues: Metric label values.
420         :type name: str
421         :type documentation: str
422         :type labelnames: list
423         :type namespace: str
424         :type subsystem: str
425         :type labelvalues: list
426         """
427         super(Counter, self).__init__(
428             name=name,
429             documentation=documentation,
430             labelnames=labelnames,
431             namespace=namespace,
432             subsystem=subsystem,
433             labelvalues=labelvalues,
434         )
435
436     def _metric_init(self):
437         """
438         Initialize counter value.
439         """
440         self._value = Value()
441
442     def inc(self, amount=1):
443         """
444         Increment counter by the given amount.
445
446         :param amount: Amount to increment.
447         :type amount: int or float
448         :raises ValueError: If amout is not positive.
449         """
450         if amount < 0:
451             raise ValueError(
452                 u"Counters can only be incremented by non-negative amounts."
453             )
454         self._value.inc(amount)
455
456     def _child_samples(self):
457         """
458         Returns list of child samples.
459
460         :returns: List of child samples.
461         :rtype: tuple
462         """
463         return ((u"", {}, self._value.get(), self._value.get_timestamp()),)
464
465
466 class Gauge(MetricBase):
467     """
468     Gauge metric, to report instantaneous values.
469     """
470     _type = u"gauge"
471
472     def __init__(self,
473                  name,
474                  documentation,
475                  labelnames=(),
476                  namespace=u"",
477                  subsystem=u"",
478                  labelvalues=None
479                  ):
480         """
481         Initialize the Gauge metric object.
482
483         :param name: Metric name.
484         :param documentation: Metric HELP string.
485         :param labelnames: Metric label list.
486         :param namespace: Metric namespace (will be added as prefix).
487         :param subsystem: Metric susbsystem (will be added as prefix).
488         :param labelvalues: Metric label values.
489         :type name: str
490         :type documentation: str
491         :type labelnames: list
492         :type namespace: str
493         :type subsystem: str
494         :type labelvalues: list
495         """
496         super(Gauge, self).__init__(
497             name=name,
498             documentation=documentation,
499             labelnames=labelnames,
500             namespace=namespace,
501             subsystem=subsystem,
502             labelvalues=labelvalues,
503         )
504
505     def _metric_init(self):
506         """
507         Initialize gauge value.
508         """
509         self._value = Value()
510
511     def inc(self, amount=1):
512         """
513         Increment gauge by the given amount.
514
515         :param amount: Amount to increment.
516         :type amount: int or float
517         """
518         self._value.inc(amount)
519
520     def dec(self, amount=1):
521         """
522         Decrement gauge by the given amount.
523
524         :param amount: Amount to decrement.
525         :type amount: int or float
526         """
527         self._value.inc(-amount)
528
529     def set(self, value):
530         """
531         Set gauge to the given value.
532
533         :param amount: Value to set.
534         :type amount: int or float
535         """
536         self._value.set(float(value))
537
538     def _child_samples(self):
539         """
540         Returns list of child samples.
541
542         :returns: List of child samples.
543         :rtype: tuple
544         """
545         return ((u"", {}, self._value.get(), self._value.get_timestamp()),)
546
547
548 class Info(MetricBase):
549     """
550     Info metric, key-value pairs.
551     """
552     _type = u"info"
553
554     def __init__(self,
555                  name,
556                  documentation,
557                  labelnames=(),
558                  namespace=u"",
559                  subsystem=u"",
560                  labelvalues=None
561                  ):
562         """
563         Initialize the Info metric object.
564
565         :param name: Metric name.
566         :param documentation: Metric HELP string.
567         :param labelnames: Metric label list.
568         :param namespace: Metric namespace (will be added as prefix).
569         :param subsystem: Metric susbsystem (will be added as prefix).
570         :param labelvalues: Metric label values.
571         :type name: str
572         :type documentation: str
573         :type labelnames: list
574         :type namespace: str
575         :type subsystem: str
576         :type labelvalues: list
577         """
578         super(Info, self).__init__(
579             name=name,
580             documentation=documentation,
581             labelnames=labelnames,
582             namespace=namespace,
583             subsystem=subsystem,
584             labelvalues=labelvalues,
585         )
586
587     def _metric_init(self):
588         """
589         Initialize gauge value and time it was created.
590         """
591         self._labelname_set = set(self._labelnames)
592         self._lock = Lock()
593         self._value = {}
594
595     def info(self, value):
596         """
597         Set info to the given value.
598
599         :param amount: Value to set.
600         :type amount: int or float
601         :raises ValueError: If lables are overlapping.
602         """
603         if self._labelname_set.intersection(value.keys()):
604             raise ValueError(
605                 u"Overlapping labels for Info metric, "
606                 f"metric: {self._labelnames} child: {value}!"
607             )
608         with self._lock:
609             self._value = dict(value)
610
611     def _child_samples(self):
612         """
613         Returns list of child samples.
614
615         :returns: List of child samples.
616         :rtype: tuple
617         """
618         with self._lock:
619             return ((u"_info", self._value, 1.0, monotonic()),)