detectkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- detectkit/__init__.py +17 -0
- detectkit/alerting/__init__.py +13 -0
- detectkit/alerting/channels/__init__.py +21 -0
- detectkit/alerting/channels/base.py +191 -0
- detectkit/alerting/channels/email.py +146 -0
- detectkit/alerting/channels/factory.py +193 -0
- detectkit/alerting/channels/mattermost.py +53 -0
- detectkit/alerting/channels/slack.py +55 -0
- detectkit/alerting/channels/telegram.py +110 -0
- detectkit/alerting/channels/webhook.py +139 -0
- detectkit/alerting/orchestrator.py +368 -0
- detectkit/cli/__init__.py +1 -0
- detectkit/cli/commands/__init__.py +1 -0
- detectkit/cli/commands/init.py +282 -0
- detectkit/cli/commands/run.py +427 -0
- detectkit/cli/commands/test_alert.py +184 -0
- detectkit/cli/main.py +186 -0
- detectkit/config/__init__.py +30 -0
- detectkit/config/metric_config.py +467 -0
- detectkit/config/profile.py +285 -0
- detectkit/config/project_config.py +164 -0
- detectkit/core/__init__.py +6 -0
- detectkit/core/interval.py +132 -0
- detectkit/core/models.py +106 -0
- detectkit/database/__init__.py +27 -0
- detectkit/database/clickhouse_manager.py +385 -0
- detectkit/database/internal_tables.py +581 -0
- detectkit/database/manager.py +324 -0
- detectkit/database/tables.py +134 -0
- detectkit/detectors/__init__.py +6 -0
- detectkit/detectors/base.py +222 -0
- detectkit/detectors/factory.py +138 -0
- detectkit/detectors/statistical/__init__.py +8 -0
- detectkit/detectors/statistical/iqr.py +230 -0
- detectkit/detectors/statistical/mad.py +423 -0
- detectkit/detectors/statistical/manual_bounds.py +177 -0
- detectkit/detectors/statistical/zscore.py +225 -0
- detectkit/loaders/__init__.py +6 -0
- detectkit/loaders/metric_loader.py +470 -0
- detectkit/loaders/query_template.py +164 -0
- detectkit/orchestration/__init__.py +9 -0
- detectkit/orchestration/task_manager.py +698 -0
- detectkit/utils/__init__.py +1 -0
- detectkit-0.1.0.dist-info/METADATA +231 -0
- detectkit-0.1.0.dist-info/RECORD +49 -0
- detectkit-0.1.0.dist-info/WHEEL +5 -0
- detectkit-0.1.0.dist-info/entry_points.txt +2 -0
- detectkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- detectkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram alert channel implementation.
|
|
3
|
+
|
|
4
|
+
Sends anomaly alerts via Telegram Bot API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TelegramChannel(BaseAlertChannel):
|
|
15
|
+
"""
|
|
16
|
+
Telegram alert channel using Bot API.
|
|
17
|
+
|
|
18
|
+
Sends formatted messages to Telegram chat using bot token.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
bot_token: Telegram bot token (from @BotFather)
|
|
22
|
+
chat_id: Target chat ID (user, group, or channel)
|
|
23
|
+
parse_mode: Message parse mode ("Markdown", "HTML", or None)
|
|
24
|
+
disable_notification: Send silently without notification
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> channel = TelegramChannel(
|
|
28
|
+
... bot_token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
|
29
|
+
... chat_id="-1001234567890"
|
|
30
|
+
... )
|
|
31
|
+
>>> alert = AlertData(
|
|
32
|
+
... metric_name="cpu_usage",
|
|
33
|
+
... timestamp=np.datetime64("2024-01-01T10:00:00"),
|
|
34
|
+
... value=95.0,
|
|
35
|
+
... is_anomaly=True
|
|
36
|
+
... )
|
|
37
|
+
>>> channel.send(alert)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
bot_token: str,
|
|
43
|
+
chat_id: str,
|
|
44
|
+
parse_mode: str = "Markdown",
|
|
45
|
+
disable_notification: bool = False,
|
|
46
|
+
template: Optional[str] = None,
|
|
47
|
+
**kwargs,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize Telegram channel.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
bot_token: Telegram bot token from @BotFather
|
|
54
|
+
chat_id: Target chat ID (can be user_id, @channel_name, or group ID)
|
|
55
|
+
parse_mode: Message formatting ("Markdown", "HTML", or None)
|
|
56
|
+
disable_notification: Send silently without notification sound
|
|
57
|
+
template: Custom message template (optional)
|
|
58
|
+
**kwargs: Additional parameters (ignored)
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If bot_token or chat_id is missing
|
|
62
|
+
"""
|
|
63
|
+
if not bot_token:
|
|
64
|
+
raise ValueError("bot_token is required for TelegramChannel")
|
|
65
|
+
if not chat_id:
|
|
66
|
+
raise ValueError("chat_id is required for TelegramChannel")
|
|
67
|
+
|
|
68
|
+
self.bot_token = bot_token
|
|
69
|
+
self.chat_id = chat_id
|
|
70
|
+
self.parse_mode = parse_mode
|
|
71
|
+
self.disable_notification = disable_notification
|
|
72
|
+
self.template = template
|
|
73
|
+
|
|
74
|
+
def send(self, alert_data: AlertData) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Send alert to Telegram.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
alert_data: Alert information to send
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
requests.RequestException: If request fails
|
|
83
|
+
requests.HTTPError: If Telegram API returns error
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> channel.send(alert_data)
|
|
87
|
+
"""
|
|
88
|
+
message = self.format_message(alert_data, self.template)
|
|
89
|
+
|
|
90
|
+
# Telegram Bot API URL
|
|
91
|
+
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"chat_id": self.chat_id,
|
|
95
|
+
"text": message,
|
|
96
|
+
"disable_notification": self.disable_notification,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if self.parse_mode:
|
|
100
|
+
payload["parse_mode"] = self.parse_mode
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
response = requests.post(url, json=payload, timeout=10)
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
except requests.RequestException as e:
|
|
106
|
+
raise requests.RequestException(f"Failed to send Telegram alert: {e}")
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
"""String representation."""
|
|
110
|
+
return f"TelegramChannel(chat_id={self.chat_id})"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic webhook alert channel.
|
|
3
|
+
|
|
4
|
+
Sends alerts to any webhook endpoint that accepts JSON payload.
|
|
5
|
+
Compatible with Mattermost, Slack, and other webhook-based systems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookChannel(BaseAlertChannel):
|
|
16
|
+
"""
|
|
17
|
+
Generic webhook alert channel.
|
|
18
|
+
|
|
19
|
+
Sends formatted alert messages to any webhook URL with JSON payload.
|
|
20
|
+
Compatible with:
|
|
21
|
+
- Mattermost incoming webhooks
|
|
22
|
+
- Slack incoming webhooks
|
|
23
|
+
- Custom webhook endpoints
|
|
24
|
+
|
|
25
|
+
The payload format is compatible with Mattermost/Slack:
|
|
26
|
+
{
|
|
27
|
+
"text": "message",
|
|
28
|
+
"username": "bot_name",
|
|
29
|
+
"icon_emoji": ":emoji:",
|
|
30
|
+
"channel": "#channel" (optional)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Parameters:
|
|
34
|
+
webhook_url (str): Webhook URL to send alerts to
|
|
35
|
+
username (str): Bot username to display (default: "detectk")
|
|
36
|
+
icon_emoji (str): Bot emoji icon (default: ":warning:")
|
|
37
|
+
channel (str): Target channel (optional, for Slack/Mattermost)
|
|
38
|
+
timeout (int): Request timeout in seconds (default: 10)
|
|
39
|
+
extra_headers (dict): Additional HTTP headers (optional)
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> # Mattermost
|
|
43
|
+
>>> channel = WebhookChannel(
|
|
44
|
+
... webhook_url="https://mattermost.example.com/hooks/xxx"
|
|
45
|
+
... )
|
|
46
|
+
>>>
|
|
47
|
+
>>> # Slack
|
|
48
|
+
>>> channel = WebhookChannel(
|
|
49
|
+
... webhook_url="https://hooks.slack.com/services/xxx",
|
|
50
|
+
... channel="#alerts"
|
|
51
|
+
... )
|
|
52
|
+
>>>
|
|
53
|
+
>>> # Custom webhook
|
|
54
|
+
>>> channel = WebhookChannel(
|
|
55
|
+
... webhook_url="https://custom.example.com/webhook",
|
|
56
|
+
... extra_headers={"Authorization": "Bearer token"}
|
|
57
|
+
... )
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
webhook_url: str,
|
|
63
|
+
username: str = "detectk",
|
|
64
|
+
icon_emoji: str = ":warning:",
|
|
65
|
+
channel: Optional[str] = None,
|
|
66
|
+
timeout: int = 10,
|
|
67
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
68
|
+
):
|
|
69
|
+
"""Initialize webhook channel."""
|
|
70
|
+
if not webhook_url:
|
|
71
|
+
raise ValueError("webhook_url is required")
|
|
72
|
+
|
|
73
|
+
self.webhook_url = webhook_url
|
|
74
|
+
self.username = username
|
|
75
|
+
self.icon_emoji = icon_emoji
|
|
76
|
+
self.channel = channel
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.extra_headers = extra_headers or {}
|
|
79
|
+
|
|
80
|
+
def send(
|
|
81
|
+
self,
|
|
82
|
+
alert_data: AlertData,
|
|
83
|
+
template: Optional[str] = None,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Send alert to webhook.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
alert_data: Alert data to send
|
|
90
|
+
template: Optional custom message template
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if sent successfully, False otherwise
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
requests.RequestException: If request fails critically
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> channel = WebhookChannel(webhook_url="https://...")
|
|
100
|
+
>>> success = channel.send(alert_data)
|
|
101
|
+
"""
|
|
102
|
+
# Format message
|
|
103
|
+
message = self.format_message(alert_data, template)
|
|
104
|
+
|
|
105
|
+
# Prepare payload (Mattermost/Slack compatible format)
|
|
106
|
+
payload = {
|
|
107
|
+
"text": message,
|
|
108
|
+
"username": self.username,
|
|
109
|
+
"icon_emoji": self.icon_emoji,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Add channel if specified (for Slack)
|
|
113
|
+
if self.channel:
|
|
114
|
+
payload["channel"] = self.channel
|
|
115
|
+
|
|
116
|
+
# Prepare headers
|
|
117
|
+
headers = {"Content-Type": "application/json"}
|
|
118
|
+
headers.update(self.extra_headers)
|
|
119
|
+
|
|
120
|
+
# Send to webhook
|
|
121
|
+
try:
|
|
122
|
+
response = requests.post(
|
|
123
|
+
self.webhook_url,
|
|
124
|
+
json=payload,
|
|
125
|
+
headers=headers,
|
|
126
|
+
timeout=self.timeout,
|
|
127
|
+
)
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
return True
|
|
130
|
+
except requests.RequestException as e:
|
|
131
|
+
# Log error but don't crash
|
|
132
|
+
print(f"Failed to send webhook alert: {e}")
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
"""String representation."""
|
|
137
|
+
url_preview = self.webhook_url[:30] + "..." if len(self.webhook_url) > 30 else self.webhook_url
|
|
138
|
+
channel_info = f", channel='{self.channel}'" if self.channel else ""
|
|
139
|
+
return f"WebhookChannel(url='{url_preview}', username='{self.username}'{channel_info})"
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alert orchestrator for coordinating detection and alerting.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Checking consecutive anomaly logic
|
|
6
|
+
- Direction matching
|
|
7
|
+
- Multiple detector aggregation (min_detectors)
|
|
8
|
+
- Loading recent detection results
|
|
9
|
+
- Coordinating alert sending
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
19
|
+
from detectkit.core.interval import Interval
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AlertConditions:
|
|
24
|
+
"""Alert conditions configuration."""
|
|
25
|
+
|
|
26
|
+
min_detectors: int = 1 # Minimum detectors needed for alert
|
|
27
|
+
direction: str = "any" # "any", "same", "up", "down"
|
|
28
|
+
consecutive_anomalies: int = 1 # Number of consecutive anomalies required
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DetectionRecord:
|
|
33
|
+
"""Record of a detection result from database."""
|
|
34
|
+
|
|
35
|
+
timestamp: np.datetime64
|
|
36
|
+
detector_name: str
|
|
37
|
+
detector_id: str
|
|
38
|
+
value: float
|
|
39
|
+
is_anomaly: bool
|
|
40
|
+
confidence_lower: Optional[float]
|
|
41
|
+
confidence_upper: Optional[float]
|
|
42
|
+
direction: str # "up", "down", "none"
|
|
43
|
+
severity: float
|
|
44
|
+
detection_metadata: Dict
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AlertOrchestrator:
|
|
48
|
+
"""
|
|
49
|
+
Orchestrates the alert decision and sending process.
|
|
50
|
+
|
|
51
|
+
Responsibilities:
|
|
52
|
+
- Load recent detection results from database
|
|
53
|
+
- Check consecutive anomaly conditions
|
|
54
|
+
- Check direction matching
|
|
55
|
+
- Aggregate multiple detectors (min_detectors)
|
|
56
|
+
- Send alerts through configured channels
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> orchestrator = AlertOrchestrator(
|
|
60
|
+
... metric_name="cpu_usage",
|
|
61
|
+
... interval=Interval.parse("10min"),
|
|
62
|
+
... conditions=AlertConditions(consecutive_anomalies=3, direction="same")
|
|
63
|
+
... )
|
|
64
|
+
>>> should_alert, alert_data = orchestrator.should_alert(recent_detections)
|
|
65
|
+
>>> if should_alert:
|
|
66
|
+
... orchestrator.send_alerts(alert_data, channels)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
metric_name: str,
|
|
72
|
+
interval: Interval,
|
|
73
|
+
conditions: Optional[AlertConditions] = None,
|
|
74
|
+
timezone_display: str = "UTC",
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
Initialize alert orchestrator.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
metric_name: Name of the metric
|
|
81
|
+
interval: Metric interval
|
|
82
|
+
conditions: Alert conditions (defaults to AlertConditions())
|
|
83
|
+
timezone_display: Timezone for alert display (default: UTC)
|
|
84
|
+
"""
|
|
85
|
+
self.metric_name = metric_name
|
|
86
|
+
self.interval = interval
|
|
87
|
+
self.conditions = conditions or AlertConditions()
|
|
88
|
+
self.timezone_display = timezone_display
|
|
89
|
+
|
|
90
|
+
def should_alert(
|
|
91
|
+
self,
|
|
92
|
+
recent_detections: List[DetectionRecord],
|
|
93
|
+
) -> tuple[bool, Optional[AlertData]]:
|
|
94
|
+
"""
|
|
95
|
+
Determine if alert should be sent based on recent detections.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
recent_detections: List of recent detection records (sorted by time, newest first)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tuple of (should_alert, alert_data)
|
|
102
|
+
- should_alert: True if alert should be sent
|
|
103
|
+
- alert_data: AlertData if should_alert=True, None otherwise
|
|
104
|
+
|
|
105
|
+
Logic:
|
|
106
|
+
1. Check if enough detectors triggered (min_detectors)
|
|
107
|
+
2. Check consecutive anomalies with direction matching
|
|
108
|
+
3. Return decision and formatted AlertData
|
|
109
|
+
"""
|
|
110
|
+
if not recent_detections:
|
|
111
|
+
return False, None
|
|
112
|
+
|
|
113
|
+
# Group detections by timestamp
|
|
114
|
+
detections_by_time = self._group_by_timestamp(recent_detections)
|
|
115
|
+
|
|
116
|
+
# Check from newest to oldest
|
|
117
|
+
timestamps_sorted = sorted(detections_by_time.keys(), reverse=True)
|
|
118
|
+
|
|
119
|
+
# Check min_detectors for the latest point
|
|
120
|
+
latest_timestamp = timestamps_sorted[0]
|
|
121
|
+
latest_detections = detections_by_time[latest_timestamp]
|
|
122
|
+
|
|
123
|
+
# Filter anomalies
|
|
124
|
+
latest_anomalies = [d for d in latest_detections if d.is_anomaly]
|
|
125
|
+
|
|
126
|
+
if len(latest_anomalies) < self.conditions.min_detectors:
|
|
127
|
+
return False, None
|
|
128
|
+
|
|
129
|
+
# Check consecutive anomalies
|
|
130
|
+
consecutive_count = self._count_consecutive_anomalies(
|
|
131
|
+
detections_by_time, timestamps_sorted
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if consecutive_count < self.conditions.consecutive_anomalies:
|
|
135
|
+
return False, None
|
|
136
|
+
|
|
137
|
+
# Build AlertData from latest anomalies
|
|
138
|
+
# If multiple detectors, aggregate them
|
|
139
|
+
alert_data = self._build_alert_data(
|
|
140
|
+
latest_anomalies, consecutive_count
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return True, alert_data
|
|
144
|
+
|
|
145
|
+
def _group_by_timestamp(
|
|
146
|
+
self, detections: List[DetectionRecord]
|
|
147
|
+
) -> Dict[np.datetime64, List[DetectionRecord]]:
|
|
148
|
+
"""Group detection records by timestamp."""
|
|
149
|
+
grouped = {}
|
|
150
|
+
for detection in detections:
|
|
151
|
+
if detection.timestamp not in grouped:
|
|
152
|
+
grouped[detection.timestamp] = []
|
|
153
|
+
grouped[detection.timestamp].append(detection)
|
|
154
|
+
return grouped
|
|
155
|
+
|
|
156
|
+
def _count_consecutive_anomalies(
|
|
157
|
+
self,
|
|
158
|
+
detections_by_time: Dict[np.datetime64, List[DetectionRecord]],
|
|
159
|
+
timestamps_sorted: List[np.datetime64],
|
|
160
|
+
) -> int:
|
|
161
|
+
"""
|
|
162
|
+
Count consecutive anomalies matching direction condition.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
detections_by_time: Detections grouped by timestamp
|
|
166
|
+
timestamps_sorted: Timestamps in descending order (newest first)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Number of consecutive anomalies
|
|
170
|
+
|
|
171
|
+
Logic:
|
|
172
|
+
- direction="any": Count any anomalies
|
|
173
|
+
- direction="same": Count anomalies in same direction (resets on change)
|
|
174
|
+
- direction="up": Count only "up" anomalies
|
|
175
|
+
- direction="down": Count only "down" anomalies
|
|
176
|
+
"""
|
|
177
|
+
direction_condition = self.conditions.direction
|
|
178
|
+
consecutive = 0
|
|
179
|
+
prev_direction = None
|
|
180
|
+
|
|
181
|
+
for timestamp in timestamps_sorted:
|
|
182
|
+
detections = detections_by_time[timestamp]
|
|
183
|
+
|
|
184
|
+
# Check if enough detectors found anomaly
|
|
185
|
+
anomalies = [d for d in detections if d.is_anomaly]
|
|
186
|
+
if len(anomalies) < self.conditions.min_detectors:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
# Determine dominant direction (use first detector's direction)
|
|
190
|
+
current_direction = anomalies[0].direction
|
|
191
|
+
|
|
192
|
+
# Check direction matching
|
|
193
|
+
if direction_condition == "any":
|
|
194
|
+
consecutive += 1
|
|
195
|
+
elif direction_condition == "same":
|
|
196
|
+
if prev_direction is None:
|
|
197
|
+
consecutive = 1
|
|
198
|
+
prev_direction = current_direction
|
|
199
|
+
elif current_direction == prev_direction:
|
|
200
|
+
consecutive += 1
|
|
201
|
+
else:
|
|
202
|
+
# Direction changed, stop counting
|
|
203
|
+
break
|
|
204
|
+
elif direction_condition == "up":
|
|
205
|
+
if current_direction == "up":
|
|
206
|
+
consecutive += 1
|
|
207
|
+
else:
|
|
208
|
+
break
|
|
209
|
+
elif direction_condition == "down":
|
|
210
|
+
if current_direction == "down":
|
|
211
|
+
consecutive += 1
|
|
212
|
+
else:
|
|
213
|
+
break
|
|
214
|
+
else:
|
|
215
|
+
# Unknown direction condition
|
|
216
|
+
consecutive += 1
|
|
217
|
+
|
|
218
|
+
return consecutive
|
|
219
|
+
|
|
220
|
+
def _build_alert_data(
|
|
221
|
+
self,
|
|
222
|
+
anomalies: List[DetectionRecord],
|
|
223
|
+
consecutive_count: int,
|
|
224
|
+
) -> AlertData:
|
|
225
|
+
"""
|
|
226
|
+
Build AlertData from anomalous detections.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
anomalies: List of anomalous detections for the latest point
|
|
230
|
+
consecutive_count: Number of consecutive anomalies
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
AlertData for sending
|
|
234
|
+
"""
|
|
235
|
+
# Use first detector for primary info (if multiple, we'll note it)
|
|
236
|
+
primary = anomalies[0]
|
|
237
|
+
|
|
238
|
+
# If multiple detectors, aggregate info
|
|
239
|
+
if len(anomalies) > 1:
|
|
240
|
+
# Take the worst severity
|
|
241
|
+
max_severity = max(d.severity for d in anomalies)
|
|
242
|
+
detector_names = [d.detector_name for d in anomalies]
|
|
243
|
+
detector_name = f"{len(anomalies)} detectors"
|
|
244
|
+
detector_params_list = [
|
|
245
|
+
f"{d.detector_name}({d.detector_id[:8]})" for d in anomalies
|
|
246
|
+
]
|
|
247
|
+
detector_params = ", ".join(detector_params_list)
|
|
248
|
+
|
|
249
|
+
# Combine metadata
|
|
250
|
+
combined_metadata = {
|
|
251
|
+
"detectors": detector_names,
|
|
252
|
+
"count": len(anomalies),
|
|
253
|
+
}
|
|
254
|
+
for i, d in enumerate(anomalies):
|
|
255
|
+
combined_metadata[f"detector_{i}_metadata"] = d.detection_metadata
|
|
256
|
+
else:
|
|
257
|
+
max_severity = primary.severity
|
|
258
|
+
detector_name = primary.detector_name
|
|
259
|
+
detector_params = f"{primary.detector_id[:16]}"
|
|
260
|
+
combined_metadata = primary.detection_metadata
|
|
261
|
+
|
|
262
|
+
# Convert numpy timestamp for AlertData
|
|
263
|
+
timestamp = primary.timestamp
|
|
264
|
+
|
|
265
|
+
return AlertData(
|
|
266
|
+
metric_name=self.metric_name,
|
|
267
|
+
timestamp=timestamp,
|
|
268
|
+
timezone=self.timezone_display,
|
|
269
|
+
value=primary.value,
|
|
270
|
+
confidence_lower=primary.confidence_lower,
|
|
271
|
+
confidence_upper=primary.confidence_upper,
|
|
272
|
+
detector_name=detector_name,
|
|
273
|
+
detector_params=detector_params,
|
|
274
|
+
direction=primary.direction,
|
|
275
|
+
severity=max_severity,
|
|
276
|
+
detection_metadata=combined_metadata,
|
|
277
|
+
consecutive_count=consecutive_count,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def send_alerts(
|
|
281
|
+
self,
|
|
282
|
+
alert_data: AlertData,
|
|
283
|
+
channels: List[BaseAlertChannel],
|
|
284
|
+
template: Optional[str] = None,
|
|
285
|
+
) -> Dict[str, bool]:
|
|
286
|
+
"""
|
|
287
|
+
Send alerts through all configured channels.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
alert_data: Alert data to send
|
|
291
|
+
channels: List of alert channels
|
|
292
|
+
template: Optional custom message template
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Dict mapping channel name to success status
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
>>> results = orchestrator.send_alerts(
|
|
299
|
+
... alert_data,
|
|
300
|
+
... channels=[mattermost, slack],
|
|
301
|
+
... template="ALERT: {metric_name} = {value}"
|
|
302
|
+
... )
|
|
303
|
+
>>> print(results)
|
|
304
|
+
{'MattermostChannel': True, 'SlackChannel': True}
|
|
305
|
+
"""
|
|
306
|
+
results = {}
|
|
307
|
+
|
|
308
|
+
for channel in channels:
|
|
309
|
+
try:
|
|
310
|
+
success = channel.send(alert_data, template)
|
|
311
|
+
channel_name = channel.__class__.__name__
|
|
312
|
+
results[channel_name] = success
|
|
313
|
+
except Exception as e:
|
|
314
|
+
channel_name = channel.__class__.__name__
|
|
315
|
+
print(f"Error sending alert via {channel_name}: {e}")
|
|
316
|
+
results[channel_name] = False
|
|
317
|
+
|
|
318
|
+
return results
|
|
319
|
+
|
|
320
|
+
def get_last_complete_point(self, now: Optional[datetime] = None) -> datetime:
|
|
321
|
+
"""
|
|
322
|
+
Determine the last complete time point for the metric.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
now: Current time (default: datetime.now(timezone.utc))
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Last complete timestamp
|
|
329
|
+
|
|
330
|
+
Logic:
|
|
331
|
+
- Floor current time to interval boundary
|
|
332
|
+
- Subtract one interval to get last complete point
|
|
333
|
+
- Example: now=13:23, interval=10min -> 13:10
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
>>> orchestrator = AlertOrchestrator("metric", Interval.parse("10min"))
|
|
337
|
+
>>> now = datetime(2024, 1, 1, 13, 23, 0, tzinfo=timezone.utc)
|
|
338
|
+
>>> last_point = orchestrator.get_last_complete_point(now)
|
|
339
|
+
>>> print(last_point)
|
|
340
|
+
2024-01-01 13:10:00+00:00
|
|
341
|
+
"""
|
|
342
|
+
if now is None:
|
|
343
|
+
now = datetime.now(timezone.utc)
|
|
344
|
+
|
|
345
|
+
# Ensure UTC
|
|
346
|
+
if now.tzinfo is None:
|
|
347
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
348
|
+
|
|
349
|
+
# Floor to interval
|
|
350
|
+
interval_seconds = self.interval.seconds
|
|
351
|
+
timestamp_seconds = int(now.timestamp())
|
|
352
|
+
floored_seconds = (timestamp_seconds // interval_seconds) * interval_seconds
|
|
353
|
+
|
|
354
|
+
# Subtract one interval to get last complete point
|
|
355
|
+
last_complete_seconds = floored_seconds - interval_seconds
|
|
356
|
+
|
|
357
|
+
return datetime.fromtimestamp(last_complete_seconds, tz=timezone.utc)
|
|
358
|
+
|
|
359
|
+
def __repr__(self) -> str:
|
|
360
|
+
"""String representation."""
|
|
361
|
+
return (
|
|
362
|
+
f"AlertOrchestrator("
|
|
363
|
+
f"metric='{self.metric_name}', "
|
|
364
|
+
f"interval={self.interval}, "
|
|
365
|
+
f"min_detectors={self.conditions.min_detectors}, "
|
|
366
|
+
f"direction='{self.conditions.direction}', "
|
|
367
|
+
f"consecutive={self.conditions.consecutive_anomalies})"
|
|
368
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface for detectk."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command implementations."""
|