diff --git a/MANIFEST.in b/MANIFEST.in index 7b741275f0d1fedbd2b153e542ffe0df369d9a7a..cfabbb68818ec823c5b63874a7c751fc79008540 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE include README.md include requirements.txt +include CHANGELOG.md diff --git a/docs/instrumentation/metrics.md b/docs/instrumentation/metrics.md index d35dcb9374aff165c94e1c6f4781a6b261487d09..2118fccb1ae436c998aaa561793023eb3ebf7534 100644 --- a/docs/instrumentation/metrics.md +++ b/docs/instrumentation/metrics.md @@ -1,12 +1,56 @@ Metrics and instruments are a system to output real-time statistics. -_An instrument_ is a collection of metrics. It has a name (hierarchical, dot-separated), -that does not have to correspond to particular modules or classes. It can be in one of 3 states: +Metrics are defined and meant to be used in a similar way +to Python logging. +It has a name (hierarchical, dot-separated), +that does not have to correspond to particular +modules or classes. It can be in one of 3 states: -* Disabled -* Runtime -* Debug +* DISABLED +* RUNTIME +* DEBUG +* INHERIT -By default, it runs in _runtime_ mode. This means that statistics are collected only from metrics of this -instrument that are set to at least RUNTIME. If a user wants to dig deeper, it can switch the instrument to -DEBUG, which will cause more data to be registered. +By default, it runs in _runtime_ mode. This means that statistics +are collected only from metrics of this +instrument that are set to at least RUNTIME. If a user wants to +dig deeper, it can switch the instrument to +DEBUG, which will cause more data to be registered. If a metric +is in state INHERIT, it will inherit the metric level from it's +parent, traversing the tree if required. + +You can switch the metric anytime by calling it's `switch_level` +method. + +You obtain metrics using `getMetric()` as follows: + +```python +metric = getMetric(__name__+'.StringMetric', 'string', RUNTIME, **kwargs) +``` + +Where the second argument is a metric type. Following metric types +are available: + +* base - for just a container metric +* string - for string values +* int - for int values +* float - for float values +* cps - will count given amount of calls to handle() during last + time period, as specified by user + +Third parameter is optional. If set, all child metrics created +during this metric's instantiation will receive such metric level. +If the metric already exists, it's level will be set to provided +metric level, if passed. + +All child metrics (going from the root metric to 0) will be initialized +with the value that you just passed. In order to keep them in order, +an additional parameter passed to `getMetric()`, `metric_level`, if +specified, will set given level upon returning the even existing +metric. + +If you don't specify it, the metric level for root metric will be +set to RUNTIME. Same if you specify INHERIT. + +If you specify any kwargs, they will be delivered to the last +metric's in chain constructor. diff --git a/satella/__init__.py b/satella/__init__.py index 880e99426d25ac19a7602832e9000e3c641d2c39..1617bb15a5b029f31a46f28daa375faf990a1ef4 100644 --- a/satella/__init__.py +++ b/satella/__init__.py @@ -1,3 +1,3 @@ # coding=UTF-8 -__version__ = '2.1.10a2' +__version__ = '2.1.10a5' diff --git a/satella/instrumentation/metrics/__init__.py b/satella/instrumentation/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9b8fa09e67667d72b903960eae71d64bc88523cc --- /dev/null +++ b/satella/instrumentation/metrics/__init__.py @@ -0,0 +1,55 @@ +import abc +import logging +import typing as tp +import itertools +import threading +logger = logging.getLogger(__name__) +from .metric_types.base import RUNTIME, DISABLED, DEBUG, INHERIT, Metric +from .metric_types import METRIC_NAMES_TO_CLASSES + +__all__ = ['getMetric', 'DISABLED', 'RUNTIME', 'DEBUG', 'INHERIT'] + + +metrics = {} +metrics_lock = threading.Lock() + + +def getMetric(metric_name: str, metric_type: str = 'base', metric_level: tp.Optional[str] = None, **kwargs): + """ + Obtain a metric of given name. + :param metric_name: must be a module name + """ + metric_level_to_set_for_children = metric_level or INHERIT + name = metric_name.split('.') + with metrics_lock: + root_metric = None + for name_index, name_part in itertools.chain(enumerate(name), ((len(name), None), )): + tentative_name = '.'.join(name[:name_index]) + if tentative_name not in metrics: + if tentative_name == '': + # initialize the root metric + if metric_level is None: + metric_level_to_set_for_root = RUNTIME + elif metric_level_to_set_for_children == INHERIT: + metric_level_to_set_for_root = RUNTIME + else: + metric_level_to_set_for_root = metric_level_to_set_for_children + print(metric_level_to_set_for_root) + metric = Metric('', None, metric_level_to_set_for_root) + metric.level = RUNTIME + root_metric = metric + elif metric_name == tentative_name: + metric = METRIC_NAMES_TO_CLASSES[metric_type](tentative_name, root_metric, metric_level, **kwargs) + else: + metric = Metric(tentative_name, root_metric, metric_level_to_set_for_children) + metrics[tentative_name] = metric + if metric != root_metric: # prevent infinite recursion errors + root_metric.append_child(metric) + else: + metric = metrics[tentative_name] + root_metric = metric + + if metric_level is not None: + metric.switch_level(metric_level) + + return metric diff --git a/satella/instrumentation/metrics/metric_types/__init__.py b/satella/instrumentation/metrics/metric_types/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f5510b6ef352904ff6b66bd5244211552c815a3 --- /dev/null +++ b/satella/instrumentation/metrics/metric_types/__init__.py @@ -0,0 +1,16 @@ +import typing as tp +from .base import Metric +from .simple import StringMetric, IntegerMetric, FloatMetric +from .cps import ClicksPerTimeUnitMetric + +ALL_METRICS = [ + Metric, + StringMetric, + IntegerMetric, + FloatMetric, + ClicksPerTimeUnitMetric +] + +METRIC_NAMES_TO_CLASSES = { + metric.CLASS_NAME: metric for metric in ALL_METRICS +} \ No newline at end of file diff --git a/satella/instrumentation/metrics/metric_types/base.py b/satella/instrumentation/metrics/metric_types/base.py new file mode 100644 index 0000000000000000000000000000000000000000..2191f2e94d29f64765dcffa2e48e0c5d18dbc6e6 --- /dev/null +++ b/satella/instrumentation/metrics/metric_types/base.py @@ -0,0 +1,66 @@ +import typing as tp +import abc +from satella.json import JSONAble + +DISABLED = 1 +RUNTIME = 2 +DEBUG = 3 +INHERIT = 4 + + +class Metric(JSONAble): + """ + A base metric class + """ + CLASS_NAME = 'base' + + def reset(self) -> None: + """Delete all child metrics that this metric contains""" + from satella.instrumentation import metrics + if self.name == '': + metrics.metrics = {} + else: + metrics.metrics = {k: v for k, v in metrics.metrics.items() if not k.startswith(self.name+'.')} + del metrics.metrics[self.name] + self.children = [] + + def __init__(self, name, root_metric: 'Metric' = None, metric_level: str = None, **kwargs): + """When reimplementing the method, remember to pass kwargs here!""" + self.name = name + self.root_metric = root_metric + self.level = metric_level or RUNTIME + assert not (self.name == '' and self.level == INHERIT), 'Unable to set INHERIT for root metric!' + self.children = [] + + def __str__(self) -> str: + return self.name + + def append_child(self, metric: 'Metric'): + self.children.append(metric) + + def can_process_this_level(self, target_level: int) -> bool: + metric = self + while metric.level == INHERIT: + # this is bound to terminate, since it is not possible to set metric_level of INHERIT on root + metric = metric.root_metric + return metric.level >= target_level + + def switch_level(self, level: int) -> None: + assert not (self.name == '' and level == INHERIT), 'Unable to set INHERIT for root metric!' + self.level = level + + def to_json(self) -> tp.Union[list, dict, str, int, float, None]: + return { + child.name[len(self.name)+1 if len(self.name) > 0 else 0:]: child.to_json() for child in self.children + } + + def handle(self, level: int, *args, **kwargs) -> None: + """Override me!""" + raise TypeError('A collection of metrics is not meant to get .handle() called!') + + def debug(self, *args, **kwargs): + self.handle(DEBUG, *args, **kwargs) + + def runtime(self, *args, **kwargs): + self.handle(RUNTIME, *args, **kwargs) + diff --git a/satella/instrumentation/metrics/metric_types/cps.py b/satella/instrumentation/metrics/metric_types/cps.py new file mode 100644 index 0000000000000000000000000000000000000000..cbe1827991657f662c804af9049666321e07168b --- /dev/null +++ b/satella/instrumentation/metrics/metric_types/cps.py @@ -0,0 +1,41 @@ +import typing as tp +from .base import Metric +import time +import collections + + +class ClicksPerTimeUnitMetric(Metric): + CLASS_NAME = 'cps' + + def __init__(self, *args, time_unit_vectors: tp.Optional[tp.List[float]] = None, **kwargs): + """ + :param time_unit_vectors: time units (in seconds) to count the clicks in between. + Default - track a single value, amount of calls to .handle() in last second + """ + super().__init__(*args, **kwargs) + time_unit_vectors = time_unit_vectors or [1] + self.last_clicks = collections.deque() + self.cutoff_period = max(time_unit_vectors) + self.time_unit_vectors = time_unit_vectors + + def handle(self, level: int, *args, **kwargs) -> None: + monotime = time.monotonic() + if self.can_process_this_level(level): + self.last_clicks.append(time.monotonic()) + try: + while self.last_clicks[0] <= monotime - self.cutoff_period: + self.last_clicks.popleft() + except IndexError: + pass + + def to_json(self) -> tp.List[int]: + count_map = [0] * len(self.time_unit_vectors) + monotime = time.monotonic() + time_unit_vectors = [monotime-v for v in self.time_unit_vectors] + + for v in self.last_clicks: + for index, cutoff in enumerate(time_unit_vectors): + if v >= cutoff: + count_map[index] += 1 + + return count_map diff --git a/satella/instrumentation/metrics/metric_types/simple.py b/satella/instrumentation/metrics/metric_types/simple.py new file mode 100644 index 0000000000000000000000000000000000000000..5a738cadf9f6fe713a89a84ebf4d03bdaddafffa --- /dev/null +++ b/satella/instrumentation/metrics/metric_types/simple.py @@ -0,0 +1,36 @@ +import typing as tp +from .base import Metric + + +class SimpleMetric(Metric): + + CLASS_NAME = 'string' + CONSTRUCTOR = str + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data = None + + def append_child(self, metric: 'Metric'): + raise TypeError('This metric cannot contain children!') + + def handle(self, level, data): + if self.can_process_this_level(level): + self.data = self.CONSTRUCTOR(data) + + def to_json(self) -> tp.Union[list, dict, str, int, float, None]: + return self.data + + +class StringMetric(SimpleMetric): + pass + + +class IntegerMetric(SimpleMetric): + CLASS_NAME = 'int' + CONSTRUCTOR = int + + +class FloatMetric(SimpleMetric): + CLASS_NAME = 'float' + CONSTRUCTOR = float diff --git a/satella/instrumentation/metrics/record.py b/satella/instrumentation/metrics/record.py new file mode 100644 index 0000000000000000000000000000000000000000..ff28131f49717023e63966f8f454841f4d3d02da --- /dev/null +++ b/satella/instrumentation/metrics/record.py @@ -0,0 +1,17 @@ +import typing as tp +import abc +from .metric_types.base import RUNTIME, INHERIT + + +class BaseRecord(metaclass=abc.ABCMeta): + def __init__(self, level: str = RUNTIME): + self.level = level + + def can_be_handled_by(self, metric: 'Metric'): + run_level = metric.level + while run_level == INHERIT: + metric = metric.root_metric + run_level = metric.level + else: + return run_level >= self.level + raise ValueError('Invalid metric setup - root metric is in inherit mode!') diff --git a/setup.cfg b/setup.cfg index 61afc77149c822262c48bf56cc5cbbd8a516e575..dc321ce6b880d3d1c316e6c65d2d00144602e513 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,16 @@ +# coding: utf-8 [metadata] name = satella -description-file = README.md +description-file = A set of useful routines and libraries for writing Python long-running services +long-description = file: README.md +long-description-content-type = text/markdown; charset=UTF-8 +license_files = LICENSE author = Piotr MaĹlanka author_email = piotrm@smok.co description = Utilities for writing servers in Python url = https://github.com/piotrmaslanka/satella - +project-urls = + Documentation = https://satella.readthedocs.io/ classifier = Programming Language :: Python Programming Language :: Python :: 3.5 diff --git a/tests/test_instrumentation/test_metrics.py b/tests/test_instrumentation/test_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..f62011f5e8a51408c182c1dc642a6c440f379ba4 --- /dev/null +++ b/tests/test_instrumentation/test_metrics.py @@ -0,0 +1,86 @@ +import unittest +import time +import typing as tp +from satella.instrumentation.metrics import getMetric, DEBUG, RUNTIME, INHERIT + + +class TestMetric(unittest.TestCase): + + def tearDown(self): + getMetric('').reset() + + def test_base_metric(self): + metric = getMetric('root.test.StringValue', 'string') + metric.runtime('data') + + metric2 = getMetric('root.test.FloatValue', 'float') + metric2.runtime(2.0) + + metric3 = getMetric('root.test.IntValue', 'int') + metric3.runtime(3) + + root_metric = getMetric('') + + self.assertEquals(root_metric.to_json(), { + 'root': { + 'test': { + 'StringValue': 'data', + 'FloatValue': 2.0, + 'IntValue': 3 + } + } + }) + + def test_base_metric(self): + metric2 = getMetric('root.test.FloatValue', 'float', DEBUG) + metric2.runtime(2.0) + metric2.debug(1.0) + + metric3 = getMetric('root.test.IntValue', 'int') + metric3.runtime(3) + metric3.debug(2) + + root_metric = getMetric('') + + self.assertEquals(root_metric.to_json(), { + 'root': { + 'test': { + 'FloatValue': 1.0, + 'IntValue': 3 + } + } + }) + + def testInheritance(self): + metric = getMetric('root.test.FloatValue', 'float', INHERIT) + metric.runtime(2.0) + metric_parent = getMetric('root.test') + + self.assertEquals(getMetric('').to_json(), { + 'root': { + 'test': { + 'FloatValue': 2.0, + } + } + }) + + metric_parent.switch_level(RUNTIME) + metric.debug(3.0) + + self.assertEquals(getMetric('').to_json(), { + 'root': { + 'test': { + 'FloatValue': 2.0, + } + } + }) + + def test_cps(self): + metric = getMetric('root.CPSValue', 'cps', time_unit_vectors=[1, 2]) + metric.runtime() + self.assertEquals(metric.to_json(), [1, 1]) + metric.runtime() + self.assertEquals(metric.to_json(), [2, 2]) + time.sleep(1.2) + self.assertEquals(metric.to_json(), [0, 2]) +