lib/metric-config-parser/metric_config_parser/alert.py (138 lines of code) (raw):

import enum from typing import TYPE_CHECKING, Any, Dict, List, Optional import attr if TYPE_CHECKING: from metric_config_parser.config import ConfigCollection from metric_config_parser.monitoring import MonitoringSpec from metric_config_parser.definition import DefinitionSpecSub from metric_config_parser.project import ProjectConfiguration from metric_config_parser.metric import MetricReference, Summary from metric_config_parser.util import converter # todo: probably should just be a string class AlertType(enum.Enum): """Different types of alerts.""" # alert when confidence intervals of different branches don't overlap CI_OVERLAP = "ci_overlap" # alert if defined thresholds are exceeded/too low THRESHOLD = "threshold" # alert if average of most recent measurement window is below/above average of previous window AVG_DIFF = "avg_diff" @attr.s(auto_attribs=True) class Alert: """Represents an alert.""" name: str type: AlertType metrics: List[Summary] friendly_name: Optional[str] = None description: Optional[str] = None parameters: Optional[List[Any]] = [] min: Optional[List[int]] = None max: Optional[List[int]] = None window_size: Optional[int] = None max_relative_change: Optional[float] = None statistics: Optional[List[str]] = None @attr.s(auto_attribs=True) class AlertReference: """Represents a reference to an alert.""" name: str def resolve( self, spec: "DefinitionSpecSub", conf: "ProjectConfiguration", configs: "ConfigCollection", ) -> Alert: """Return the `Alert` that this is referencing.""" if isinstance(spec, MonitoringSpec): if self.name not in spec.alerts.definitions: raise ValueError(f"Alert {self.name} has not been defined.") return spec.alerts.definitions[self.name].resolve(spec, conf, configs) else: raise ValueError(f"Alerts cannot be defined as part of {spec}") converter.register_structure_hook(AlertReference, lambda obj, _type: AlertReference(name=obj)) @attr.s(auto_attribs=True) class AlertDefinition: """Describes the interface for defining an alert in configuration.""" name: str # implicit in configuration type: AlertType metrics: List[MetricReference] friendly_name: Optional[str] = None description: Optional[str] = None parameters: Optional[List[Any]] = None min: Optional[List[int]] = None max: Optional[List[int]] = None window_size: Optional[int] = None max_relative_change: Optional[float] = None statistics: Optional[List[str]] = None def __attrs_post_init__(self): """Validate that the right parameters have been set depending on the alert type.""" if self.type == AlertType.CI_OVERLAP: none_fields = ["min", "max", "window_size", "max_relative_change"] elif self.type == AlertType.THRESHOLD: none_fields = ["window_size", "max_relative_change"] if self.min is None and self.max is None: raise ValueError( "Either 'max' or 'min' needs to be set when defining a threshold alert" ) if self.min and self.parameters and len(self.min) != len(self.parameters): raise ValueError( "Number of 'min' thresholds not matching number of parameters to monitor. " + "A 'min' threshold needs to be specified for each percentile." ) if self.max and self.parameters and len(self.max) != len(self.parameters): raise ValueError( "Number of 'max' thresholds not matching number of parameters to monitor. " + "A 'max' threshold needs to be specified for each percentile." ) elif self.type == AlertType.AVG_DIFF: none_fields = ["min", "max"] if self.window_size is None: raise ValueError("'window_size' needs to be specified when using avg_diff alert") if self.max_relative_change is None: raise ValueError("'max_relative_change' to be specified when using avg_diff alert") for field in none_fields: if getattr(self, field) is not None: raise ValueError( f"For alert of type {str(self.type)}, the parameter {field} must not be set" ) def resolve( self, spec: "DefinitionSpecSub", conf: "ProjectConfiguration", configs: "ConfigCollection", ) -> Alert: """Create and return a `Alert` from the definition.""" # filter to only have metrics that actually need to be monitored metrics = [] for metric_ref in {p.name for p in self.metrics}: if metric_ref in spec.metrics.definitions: metrics += spec.metrics.definitions[metric_ref].resolve(spec, conf, configs) else: raise ValueError(f"No definition for metric {metric_ref}.") return Alert( name=self.name, type=self.type, metrics=metrics, friendly_name=self.friendly_name, description=self.description, parameters=self.parameters, min=self.min, max=self.max, window_size=self.window_size, max_relative_change=self.max_relative_change, statistics=self.statistics, ) @attr.s(auto_attribs=True) class AlertsSpec: """Describes the interface for defining custom alerts.""" definitions: Dict[str, AlertDefinition] = attr.Factory(dict) @classmethod def from_dict(cls, d: dict) -> "AlertsSpec": """Create a `AlertsSpec` from a dictionary.""" d = dict((k.lower(), v) for k, v in d.items()) definitions = { k: converter.structure({"name": k, **v}, AlertDefinition) for k, v in d.items() } return cls(definitions=definitions) def merge(self, other: "AlertsSpec"): """ Merge another alert spec into the current one. The `other` AlertsSpec overwrites existing keys. """ for alert_name, alert_definition in other.definitions.items(): if alert_name in self.definitions: for key in attr.fields_dict(type(self.definitions[alert_name])): if key == "metrics": self.definitions[alert_name].metrics += alert_definition.metrics else: setattr( self.definitions[alert_name], key, getattr(alert_definition, key) or getattr(self.definitions[alert_name], key), ) else: self.definitions[alert_name] = alert_definition self.definitions.update(other.definitions) converter.register_structure_hook(AlertsSpec, lambda obj, _type: AlertsSpec.from_dict(obj))