PK!~JO epimetheus/__init__.pyfrom .registry import * # noqa PK!*Mepimetheus/metrics.pyfrom collections import deque from math import ceil, floor from typing import Tuple import attr from .sample import SampleKey, SampleValue, clock # Metrics should be optimized for fast receiving of data # And relatively rare reporting __all__ = ('Counter', 'Gauge', 'Histogram', 'Summary', 'Exposer') @attr.s class Counter: TYPE = 'counter' _count = attr.ib(init=False, default=0) _ts = attr.ib(init=False, factory=clock) def inc(self, delta: float = 1): assert delta >= 0 self._count += delta self._ts = clock() def sample_group(self, skey: SampleKey): yield skey def sample_values(self): yield SampleValue(self._count, self._ts) @attr.s class Gauge: TYPE = 'gauge' _value = attr.ib(init=False, default=0) _ts = attr.ib(init=False, factory=clock) def inc(self, delta: float = 1): self._value += delta self._ts = clock() def dec(self, delta: float = 1): self._value -= delta self._ts = clock() def set(self, value: float): self._value = value self._ts = clock() # TODO: set_to_current_time def sample_group(self, skey: SampleKey): yield skey def sample_values(self): yield SampleValue(self._value, self._ts) def to_sorted_tuple(x): return tuple(sorted(x)) @attr.s class Histogram: TYPE = 'histogram' RESERVED_LABELS = frozenset(['le']) _buckets: Tuple[float] = attr.ib(converter=to_sorted_tuple) _bcounts = attr.ib(init=False) _inf_bcount = attr.ib(init=False, default=0) _sum = attr.ib(init=False, default=0) _count = attr.ib(init=False, default=0) def __attrs_post_init__(self): self._bcounts = [0 for _ in self._buckets] def observe(self, value: float): # TODO: optimize, use bisect for index, upper in enumerate(self._buckets): if value <= upper: self._bcounts[index] += 1 break else: self._inf_bcount += 1 self._sum += value self._count += 1 def sample_group(self, skey: SampleKey): bkey = skey.with_suffix('_bucket') for b in self._buckets: yield bkey.with_labels(le=b) yield bkey.with_labels(le='+Inf') yield skey.with_suffix('_sum') yield skey.with_suffix('_count') def sample_values(self): for v in self._bcounts: yield SampleValue(v) yield SampleValue(self._inf_bcount) yield SampleValue(self._sum) yield SampleValue(self._count) @attr.s class Summary: TYPE = 'summary' RESERVED_LABELS = frozenset(['quantile']) # TODO: validate range 0..1 _buckets: Tuple[float] = attr.ib(converter=to_sorted_tuple) _time_window: float = attr.ib(default=3600) _samples = attr.ib(init=False, factory=deque) @_buckets.validator def _validate_buckets(self, attribute, value): for x in value: if not (0 <= x <= 1): raise ValueError('Quantiles must be in range 0..1') def _clean_old_samples(self): before = clock() - int(self._time_window * 1000) while self._samples and self._samples[0].timestamp <= before: self._samples.popleft() def observe(self, value: float): self._samples.append(SampleValue.create(value, clock())) self._clean_old_samples() def sample_group(self, skey: SampleKey): for b in self._buckets: yield skey.with_labels(quantile=b) yield skey.with_suffix('_sum') yield skey.with_suffix('_count') def sample_values(self): self._clean_old_samples() if not self._samples: return quantiles = [] ss = list(sorted(self._samples, key=lambda s: s.value)) n = len(ss) ss.append(ss[-1]) for qp in self._buckets: k = (n - 1) * qp f, c = floor(k), ceil(k) if f == c: qval = ss[f].value else: qval = ( ss[f].value * (c - k) + ss[c].value * (k - f) ) quantiles.append(qval) for v in quantiles: yield SampleValue(v) yield SampleValue(sum(s.value for s in ss[:n])) yield SampleValue(n) @attr.s class Exposer: _metric = attr.ib() _key: SampleKey = attr.ib() _help: str = attr.ib(default=None) def __attrs_post_init__(self): self._keys = tuple( k.full_key for k in self._metric.sample_group(self._key)) def expose_header(self): if self._help is not None: yield f'# HELP {self._help}' yield f'# TYPE {self._key.name} {self._metric.TYPE}' def expose(self): he = False for k, v in zip( self._keys, (v.expose() for v in self._metric.sample_values()), ): # expose header only if we have samples if not he: yield from self.expose_header() he = True yield f'{k} {v}' PK!epimetheus/registry.pyfrom functools import wraps from typing import Dict, Tuple import attr from sortedcontainers import SortedDict from . import metrics from .sample import SampleKey __all__ = ('counter', 'gauge', 'histogram', 'summary') @attr.s class Registry: _metrics = attr.ib(init=False, factory=dict) _exposers = attr.ib(init=False, factory=SortedDict) def get(self, key: SampleKey): return self._metrics.get(key) def register(self, key, instance, help=None): assert key not in self._metrics self._metrics[key] = instance self._exposers[key] = metrics.Exposer(instance, key, help) def unregister(self, key): del self._metrics[key] del self._exposers[key] def expose(self): for exp in self._exposers.values(): yield from exp.expose() registry = Registry() def _create_builder(fn): @wraps(fn) def builder( *, name: str, labels: Dict[str, str] = attr.NOTHING, help: str = None, **kwargs, ): key = SampleKey(name, labels) instance = registry.get(key) # we do not check there equality of instances # TODO: may be we should? if instance is None: instance = fn(**kwargs) registry.register(key, instance, help=help) return instance return builder @_create_builder def counter() -> metrics.Counter: return metrics.Counter() @_create_builder def gauge() -> metrics.Gauge: return metrics.Gauge() @_create_builder def histogram(*, buckets: Tuple[float]) -> metrics.Histogram: return metrics.Histogram(buckets) @_create_builder def summary( *, buckets: Tuple[float], time_window: float = attr.NOTHING, ) -> metrics.Summary: return metrics.Summary(buckets, time_window) PK!l^d d epimetheus/sample.pyimport math import re import time from typing import Dict import attr __all__ = ('SampleKey', 'SampleValue') METRIC_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') LABEL_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') def convert_labels(value): return {k: str(v) for k, v in value.items()} @attr.s(cmp=True, frozen=True) class SampleKey: _name: str = attr.ib(cmp=False, repr=False) _labels: Dict[str, str] = attr.ib( factory=dict, converter=convert_labels, cmp=False, repr=False) _line: str = attr.ib(init=False, cmp=True, repr=True) @_name.validator def _validate_name(self, attribute, value): if METRIC_NAME_RE.fullmatch(value) is None: raise ValueError('Invalid sample name') @_labels.validator def _validate_labels(self, attribute, value): for k, v in value.items(): if k.startswith('__'): raise ValueError('Label names starting with __ are reserved by Prometheus') if LABEL_NAME_RE.fullmatch(k) is None: raise ValueError('Invalid label name') def with_suffix(self, suffix: str) -> 'SampleKey': return type(self)( name=self._name + suffix, labels=self._labels, ) def with_labels(self, **new_labels: Dict[str, str]): return type(self)( name=self._name, labels={**self._labels, **new_labels}, ) @staticmethod def expose_label_value(v) -> str: return str(v).replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') @classmethod def expose_label_set(cls, labels: Dict[str, str]) -> str: if not labels: return '' x = ','.join(f'{k}="{cls.expose_label_value(v)}"' for k, v in labels.items()) return '{' + x + '}' def __attrs_post_init__(self): line = f'{self._name}{self.expose_label_set(self._labels)}' object.__setattr__(self, '_line', line) @property def name(self): return self._name @property def full_key(self): return self._line @attr.s class SampleValue: value: float = attr.ib(default=0) timestamp: int = attr.ib(default=None) @classmethod def create(cls, value: float, use_ts=False): if use_ts: return cls(value, clock()) return cls(value) @staticmethod def expose_value(value): if value == math.inf: return 'Inf' elif value == -math.inf: return '-Inf' elif math.isnan(value): return 'Nan' return str(value) @staticmethod def expose_timestamp(ts): return str(ts) def expose(self): if self.timestamp is not None: return f'{self.expose_value(self.value)} {self.expose_timestamp(self.timestamp)}' return f'{self.expose_value(self.value)}' def clock(): return int(time.time() * 1000) PK!HnHTU epimetheus-0.1.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!Hn"#epimetheus-0.1.0.dist-info/METADATAN0EYXM *XiE *ڴLN3% Ui4ݞI>uzJ=JM3|PK!HcZ !epimetheus-0.1.0.dist-info/RECORD}λr0@>"b bb/1 l& oRݙ3s5iG802Q5WCBZ Sj碒gIuFJKHie1L';p瞣#1ӷ=}2)Ƽ<'KõpڸEHtxp}"wˡtĠ$H5 ;/q3K08ODn dj