detectkit 0.3.8__tar.gz → 0.3.10__tar.gz

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.
Files changed (62) hide show
  1. detectkit-0.3.10/PKG-INFO +181 -0
  2. detectkit-0.3.10/README.md +119 -0
  3. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/__init__.py +1 -1
  4. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/orchestrator.py +14 -8
  5. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/commands/test_alert.py +44 -43
  6. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/config/metric_config.py +12 -2
  7. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/database/internal_tables.py +8 -7
  8. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/orchestration/task_manager.py +85 -86
  9. detectkit-0.3.10/detectkit.egg-info/PKG-INFO +181 -0
  10. {detectkit-0.3.8 → detectkit-0.3.10}/pyproject.toml +1 -1
  11. detectkit-0.3.8/PKG-INFO +0 -252
  12. detectkit-0.3.8/README.md +0 -190
  13. detectkit-0.3.8/detectkit.egg-info/PKG-INFO +0 -252
  14. {detectkit-0.3.8 → detectkit-0.3.10}/LICENSE +0 -0
  15. {detectkit-0.3.8 → detectkit-0.3.10}/MANIFEST.in +0 -0
  16. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/__init__.py +0 -0
  17. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/__init__.py +0 -0
  18. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/base.py +0 -0
  19. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/email.py +0 -0
  20. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/factory.py +0 -0
  21. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/mattermost.py +0 -0
  22. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/slack.py +0 -0
  23. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/telegram.py +0 -0
  24. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/alerting/channels/webhook.py +0 -0
  25. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/__init__.py +0 -0
  26. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/commands/__init__.py +0 -0
  27. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/commands/init.py +0 -0
  28. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/commands/run.py +0 -0
  29. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/cli/main.py +0 -0
  30. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/config/__init__.py +0 -0
  31. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/config/profile.py +0 -0
  32. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/config/project_config.py +0 -0
  33. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/config/validator.py +0 -0
  34. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/core/__init__.py +0 -0
  35. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/core/interval.py +0 -0
  36. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/core/models.py +0 -0
  37. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/database/__init__.py +0 -0
  38. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/database/clickhouse_manager.py +0 -0
  39. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/database/manager.py +0 -0
  40. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/database/tables.py +0 -0
  41. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/__init__.py +0 -0
  42. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/base.py +0 -0
  43. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/factory.py +0 -0
  44. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/statistical/__init__.py +0 -0
  45. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/statistical/iqr.py +0 -0
  46. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/statistical/mad.py +0 -0
  47. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  48. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/detectors/statistical/zscore.py +0 -0
  49. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/loaders/__init__.py +0 -0
  50. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/loaders/metric_loader.py +0 -0
  51. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/loaders/query_template.py +0 -0
  52. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/orchestration/__init__.py +0 -0
  53. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/utils/__init__.py +0 -0
  54. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit/utils/stats.py +0 -0
  55. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit.egg-info/SOURCES.txt +0 -0
  56. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit.egg-info/dependency_links.txt +0 -0
  57. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit.egg-info/entry_points.txt +0 -0
  58. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit.egg-info/requires.txt +0 -0
  59. {detectkit-0.3.8 → detectkit-0.3.10}/detectkit.egg-info/top_level.txt +0 -0
  60. {detectkit-0.3.8 → detectkit-0.3.10}/requirements.txt +0 -0
  61. {detectkit-0.3.8 → detectkit-0.3.10}/setup.cfg +0 -0
  62. {detectkit-0.3.8 → detectkit-0.3.10}/setup.py +0 -0
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: detectkit
3
+ Version: 0.3.10
4
+ Summary: Metric monitoring with automatic anomaly detection
5
+ Author: detectkit team
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alexeiveselov92/detectkit
8
+ Project-URL: Documentation, https://github.com/alexeiveselov92/detectkit
9
+ Project-URL: Repository, https://github.com/alexeiveselov92/detectkit
10
+ Project-URL: Issues, https://github.com/alexeiveselov92/detectkit/issues
11
+ Keywords: monitoring,anomaly-detection,metrics,timeseries,alerting
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24.0
26
+ Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: click>=8.0
29
+ Requires-Dist: jinja2>=3.0
30
+ Requires-Dist: orjson>=3.0
31
+ Requires-Dist: requests>=2.25.0
32
+ Provides-Extra: clickhouse
33
+ Requires-Dist: clickhouse-driver>=0.2.0; extra == "clickhouse"
34
+ Provides-Extra: postgres
35
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
36
+ Provides-Extra: mysql
37
+ Requires-Dist: pymysql>=1.0.0; extra == "mysql"
38
+ Provides-Extra: all-db
39
+ Requires-Dist: clickhouse-driver>=0.2.0; extra == "all-db"
40
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == "all-db"
41
+ Requires-Dist: pymysql>=1.0.0; extra == "all-db"
42
+ Provides-Extra: prophet
43
+ Requires-Dist: prophet>=1.1.0; extra == "prophet"
44
+ Provides-Extra: timesfm
45
+ Requires-Dist: timesfm>=0.1.0; extra == "timesfm"
46
+ Provides-Extra: advanced-detectors
47
+ Requires-Dist: prophet>=1.1.0; extra == "advanced-detectors"
48
+ Requires-Dist: timesfm>=0.1.0; extra == "advanced-detectors"
49
+ Provides-Extra: all
50
+ Requires-Dist: clickhouse-driver>=0.2.0; extra == "all"
51
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
52
+ Requires-Dist: pymysql>=1.0.0; extra == "all"
53
+ Requires-Dist: prophet>=1.1.0; extra == "all"
54
+ Requires-Dist: timesfm>=0.1.0; extra == "all"
55
+ Provides-Extra: dev
56
+ Requires-Dist: pytest>=7.0; extra == "dev"
57
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
58
+ Requires-Dist: black>=23.0; extra == "dev"
59
+ Requires-Dist: mypy>=1.0; extra == "dev"
60
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
61
+ Dynamic: license-file
62
+
63
+ # detectkit
64
+
65
+ [![PyPI version](https://img.shields.io/pypi/v/detectkit.svg)](https://pypi.org/project/detectkit/)
66
+ [![Python](https://img.shields.io/pypi/pyversions/detectkit.svg)](https://pypi.org/project/detectkit/)
67
+
68
+ **Metric monitoring with automatic anomaly detection.**
69
+
70
+ `detectkit` is a Python library for data analysts and engineers to monitor time-series metrics with automatic anomaly detection and alerting. dbt-like project structure and CLI.
71
+
72
+ ## Features
73
+
74
+ - **Pure numpy arrays** — no pandas dependency in core logic
75
+ - **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds
76
+ - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
77
+ - **@mentions** — tag users/groups in alerts, each channel formats natively
78
+ - **Alert lifecycle** — consecutive anomalies, cooldown, recovery notifications
79
+ - **Database agnostic** — ClickHouse, PostgreSQL, MySQL
80
+ - **Idempotent** — resume from interruptions, no duplicate processing
81
+ - **CLI** — `dtk init`, `dtk run --select`, tag-based selectors
82
+
83
+ ## Installation
84
+
85
+ ```bash
86
+ pip install detectkit
87
+ ```
88
+
89
+ With database drivers:
90
+
91
+ ```bash
92
+ pip install detectkit[clickhouse] # ClickHouse
93
+ pip install detectkit[all-db] # All databases
94
+ ```
95
+
96
+ ## Quick Start
97
+
98
+ ### CLI (Recommended)
99
+
100
+ ```bash
101
+ # Create project
102
+ dtk init my_monitoring
103
+ cd my_monitoring
104
+
105
+ # Configure database in profiles.yml, then:
106
+ dtk run --select cpu_usage
107
+ dtk run --select tag:critical
108
+ dtk run --select cpu_usage --steps load,detect
109
+ dtk run --select cpu_usage --from 2024-01-01
110
+ ```
111
+
112
+ ### Metric Configuration
113
+
114
+ ```yaml
115
+ # metrics/api_errors.yml
116
+ name: api_error_rate
117
+ interval: "5min"
118
+
119
+ query: |
120
+ SELECT
121
+ toStartOfInterval(timestamp, INTERVAL 5 MINUTE) AS timestamp,
122
+ countIf(status_code >= 500) / count() * 100 AS value
123
+ FROM http_requests
124
+ WHERE timestamp >= %(from_date)s AND timestamp < %(to_date)s
125
+ GROUP BY timestamp ORDER BY timestamp
126
+
127
+ detectors:
128
+ - type: mad
129
+ params:
130
+ threshold: 3.0
131
+ window_size: 2016 # 7 days
132
+
133
+ alerting:
134
+ enabled: true
135
+ channels: [mattermost_ops]
136
+ consecutive_anomalies: 3
137
+ direction: "up"
138
+ mentions: [oncall_engineer, here]
139
+ alert_cooldown: "30min"
140
+ notify_on_recovery: true
141
+ ```
142
+
143
+ ### Python API
144
+
145
+ ```python
146
+ import numpy as np
147
+ from detectkit.detectors.statistical import ZScoreDetector
148
+
149
+ detector = ZScoreDetector(threshold=3.0, window_size=100)
150
+ results = detector.detect({
151
+ 'timestamp': np.array([...], dtype='datetime64[ms]'),
152
+ 'value': np.array([1.0, 2.0, 1.5, 10.0, 1.8]),
153
+ })
154
+
155
+ for r in results:
156
+ if r.is_anomaly:
157
+ print(f"Anomaly at {r.timestamp}: {r.value}")
158
+ ```
159
+
160
+ ## Documentation
161
+
162
+ - [Getting Started](docs/getting-started/quickstart.md) — 5-minute quickstart
163
+ - [Configuration Guide](docs/guides/configuration.md) — all config options
164
+ - [Detectors Guide](docs/guides/detectors.md) — choosing the right detector
165
+ - [Alerting Guide](docs/guides/alerting.md) — channels, mentions, cooldown, recovery
166
+ - [CLI Reference](docs/reference/cli.md) — command-line documentation
167
+ - [Examples](docs/examples/) — real-world monitoring scenarios
168
+ - [Changelog](CHANGELOG.md) — version history
169
+
170
+ ## Requirements
171
+
172
+ - Python 3.10+
173
+ - numpy >= 1.24.0
174
+ - pydantic >= 2.0.0
175
+ - click >= 8.0
176
+ - PyYAML >= 6.0
177
+ - Jinja2 >= 3.0
178
+
179
+ ## License
180
+
181
+ MIT License — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,119 @@
1
+ # detectkit
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/detectkit.svg)](https://pypi.org/project/detectkit/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/detectkit.svg)](https://pypi.org/project/detectkit/)
5
+
6
+ **Metric monitoring with automatic anomaly detection.**
7
+
8
+ `detectkit` is a Python library for data analysts and engineers to monitor time-series metrics with automatic anomaly detection and alerting. dbt-like project structure and CLI.
9
+
10
+ ## Features
11
+
12
+ - **Pure numpy arrays** — no pandas dependency in core logic
13
+ - **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds
14
+ - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
15
+ - **@mentions** — tag users/groups in alerts, each channel formats natively
16
+ - **Alert lifecycle** — consecutive anomalies, cooldown, recovery notifications
17
+ - **Database agnostic** — ClickHouse, PostgreSQL, MySQL
18
+ - **Idempotent** — resume from interruptions, no duplicate processing
19
+ - **CLI** — `dtk init`, `dtk run --select`, tag-based selectors
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install detectkit
25
+ ```
26
+
27
+ With database drivers:
28
+
29
+ ```bash
30
+ pip install detectkit[clickhouse] # ClickHouse
31
+ pip install detectkit[all-db] # All databases
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ### CLI (Recommended)
37
+
38
+ ```bash
39
+ # Create project
40
+ dtk init my_monitoring
41
+ cd my_monitoring
42
+
43
+ # Configure database in profiles.yml, then:
44
+ dtk run --select cpu_usage
45
+ dtk run --select tag:critical
46
+ dtk run --select cpu_usage --steps load,detect
47
+ dtk run --select cpu_usage --from 2024-01-01
48
+ ```
49
+
50
+ ### Metric Configuration
51
+
52
+ ```yaml
53
+ # metrics/api_errors.yml
54
+ name: api_error_rate
55
+ interval: "5min"
56
+
57
+ query: |
58
+ SELECT
59
+ toStartOfInterval(timestamp, INTERVAL 5 MINUTE) AS timestamp,
60
+ countIf(status_code >= 500) / count() * 100 AS value
61
+ FROM http_requests
62
+ WHERE timestamp >= %(from_date)s AND timestamp < %(to_date)s
63
+ GROUP BY timestamp ORDER BY timestamp
64
+
65
+ detectors:
66
+ - type: mad
67
+ params:
68
+ threshold: 3.0
69
+ window_size: 2016 # 7 days
70
+
71
+ alerting:
72
+ enabled: true
73
+ channels: [mattermost_ops]
74
+ consecutive_anomalies: 3
75
+ direction: "up"
76
+ mentions: [oncall_engineer, here]
77
+ alert_cooldown: "30min"
78
+ notify_on_recovery: true
79
+ ```
80
+
81
+ ### Python API
82
+
83
+ ```python
84
+ import numpy as np
85
+ from detectkit.detectors.statistical import ZScoreDetector
86
+
87
+ detector = ZScoreDetector(threshold=3.0, window_size=100)
88
+ results = detector.detect({
89
+ 'timestamp': np.array([...], dtype='datetime64[ms]'),
90
+ 'value': np.array([1.0, 2.0, 1.5, 10.0, 1.8]),
91
+ })
92
+
93
+ for r in results:
94
+ if r.is_anomaly:
95
+ print(f"Anomaly at {r.timestamp}: {r.value}")
96
+ ```
97
+
98
+ ## Documentation
99
+
100
+ - [Getting Started](docs/getting-started/quickstart.md) — 5-minute quickstart
101
+ - [Configuration Guide](docs/guides/configuration.md) — all config options
102
+ - [Detectors Guide](docs/guides/detectors.md) — choosing the right detector
103
+ - [Alerting Guide](docs/guides/alerting.md) — channels, mentions, cooldown, recovery
104
+ - [CLI Reference](docs/reference/cli.md) — command-line documentation
105
+ - [Examples](docs/examples/) — real-world monitoring scenarios
106
+ - [Changelog](CHANGELOG.md) — version history
107
+
108
+ ## Requirements
109
+
110
+ - Python 3.10+
111
+ - numpy >= 1.24.0
112
+ - pydantic >= 2.0.0
113
+ - click >= 8.0
114
+ - PyYAML >= 6.0
115
+ - Jinja2 >= 3.0
116
+
117
+ ## License
118
+
119
+ MIT License — see [LICENSE](LICENSE) for details.
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
4
4
  A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
5
5
  """
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.3.10"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -519,15 +519,21 @@ class AlertOrchestrator:
519
519
  )
520
520
  detection_records.append(record)
521
521
 
522
- # Count consecutive anomalies (same logic as should_alert)
523
- consecutive = self._count_consecutive_anomalies(
524
- detections=detection_records,
525
- min_detectors=self.conditions.min_detectors,
526
- direction=self.conditions.direction
527
- )
522
+ # Group by timestamp and sort (same format as should_alert uses)
523
+ detections_by_time = self._group_by_timestamp(detection_records)
524
+ timestamps_sorted = sorted(detections_by_time.keys(), reverse=True)
525
+
526
+ # Check that latest post-alert point is NOT anomalous
527
+ # (prevents false recovery when there are fewer post-alert points
528
+ # than consecutive_anomalies threshold)
529
+ latest_ts = timestamps_sorted[0]
530
+ latest_detections = detections_by_time[latest_ts]
531
+ latest_anomalies = [d for d in latest_detections if d.is_anomaly]
532
+ if len(latest_anomalies) >= self.conditions.min_detectors:
533
+ # Latest point is still anomalous — no recovery
534
+ return False
528
535
 
529
- # Recovery = consecutive dropped below threshold
530
- return consecutive < self.conditions.consecutive_anomalies
536
+ return True
531
537
 
532
538
  def should_send_recovery(
533
539
  self,
@@ -115,14 +115,14 @@ def run_test_alert(metric_name: str, profile: Optional[str] = None):
115
115
  return
116
116
 
117
117
  # Check if alerting is configured
118
- if not metric_config.alerting or not metric_config.alerting.enabled:
118
+ if not metric_config.alerting:
119
119
  print(f"Error: Alerting not enabled for metric '{metric_name}'")
120
120
  print("Enable alerting in metric config (alerting.enabled: true)")
121
121
  return
122
122
 
123
- if not metric_config.alerting.channels:
124
- print(f"Error: No alert channels configured for metric '{metric_name}'")
125
- print("Add channels in metric config (alerting.channels: [...])")
123
+ active_configs = [c for c in metric_config.alerting if c.enabled and c.channels]
124
+ if not active_configs:
125
+ print(f"Error: No active alert configs for metric '{metric_name}'")
126
126
  return
127
127
 
128
128
  # Load profiles
@@ -138,50 +138,51 @@ def run_test_alert(metric_name: str, profile: Optional[str] = None):
138
138
 
139
139
  alert_channels_config = profiles_data.get("alert_channels", {})
140
140
 
141
- # Get timezone for display
142
- timezone_display = metric_config.alerting.timezone or "UTC"
143
-
144
- # Create mock alert data
145
141
  print(f"\n📨 Sending test alert for metric: {metric_name}")
146
- print(f" Timezone: {timezone_display}")
147
- print(f" Channels: {', '.join(metric_config.alerting.channels)}\n")
148
142
 
149
- alert_data = create_mock_alert_data(metric_config, timezone_display)
143
+ total_success = 0
144
+ total_channels = 0
150
145
 
151
- # Send to each configured channel
152
- success_count = 0
153
- for channel_name in metric_config.alerting.channels:
154
- if channel_name not in alert_channels_config:
155
- print(f"⚠️ Channel '{channel_name}' not found in profiles.yml - skipping")
156
- continue
146
+ for i, alerting_config in enumerate(active_configs):
147
+ timezone_display = alerting_config.timezone or "UTC"
148
+ if len(active_configs) > 1:
149
+ print(f"\n [config {i + 1}/{len(active_configs)}]")
150
+ print(f" Timezone: {timezone_display}")
151
+ print(f" Channels: {', '.join(alerting_config.channels)}\n")
157
152
 
158
- channel_config = alert_channels_config[channel_name]
153
+ alert_data = create_mock_alert_data(metric_config, timezone_display)
159
154
 
160
- try:
161
- # Create channel instance
162
- # channel_config должен содержать 'type' + остальные параметры
163
- channel = AlertChannelFactory.create_from_config(channel_config)
164
-
165
- # Get custom template if configured
166
- template = None
167
- if metric_config.alerting.template_consecutive:
168
- template = metric_config.alerting.template_consecutive
169
-
170
- # Send alert
171
- print(f" → Sending to {channel_name}...", end=" ")
172
- success = channel.send(alert_data, template=template)
173
-
174
- if success:
175
- print("✓ SUCCESS")
176
- success_count += 1
177
- else:
178
- print(" FAILED")
179
-
180
- except Exception as e:
181
- print(f"✗ ERROR: {e}")
182
-
183
- # Summary
184
- print(f"\n{'✓' if success_count > 0 else '✗'} Sent test alert to {success_count}/{len(metric_config.alerting.channels)} channels")
155
+ success_count = 0
156
+ for channel_name in alerting_config.channels:
157
+ total_channels += 1
158
+ if channel_name not in alert_channels_config:
159
+ print(f"⚠️ Channel '{channel_name}' not found in profiles.yml - skipping")
160
+ continue
161
+
162
+ channel_config = alert_channels_config[channel_name]
163
+
164
+ try:
165
+ channel = AlertChannelFactory.create_from_config(channel_config)
166
+
167
+ template = alerting_config.template_consecutive or None
168
+
169
+ print(f" → Sending to {channel_name}...", end=" ")
170
+ success = channel.send(alert_data, template=template)
171
+
172
+ if success:
173
+ print(" SUCCESS")
174
+ success_count += 1
175
+ total_success += 1
176
+ else:
177
+ print("✗ FAILED")
178
+
179
+ except Exception as e:
180
+ print(f"✗ ERROR: {e}")
181
+
182
+ print(f"\n{'✓' if success_count > 0 else '✗'} Sent test alert to {success_count}/{len(alerting_config.channels)} channels")
183
+
184
+ if len(active_configs) > 1:
185
+ print(f"\nTotal: {total_success}/{total_channels} channels across {len(active_configs)} alert configs")
185
186
 
186
187
  if success_count > 0:
187
188
  print("\n💡 Check your configured channels to verify message formatting")
@@ -341,9 +341,19 @@ class MetricConfig(BaseModel):
341
341
  detectors: List[DetectorConfig] = Field(
342
342
  default_factory=list, description="Detector configurations"
343
343
  )
344
- alerting: Optional[AlertConfig] = Field(
345
- default=None, description="Alert configuration"
344
+ alerting: Optional[List[AlertConfig]] = Field(
345
+ default=None, description="Alert configuration(s) — single dict or list of dicts"
346
346
  )
347
+
348
+ @field_validator("alerting", mode="before")
349
+ @classmethod
350
+ def normalize_alerting(cls, v):
351
+ """Normalize alerting to list. Accepts single dict/AlertConfig (backward compat) or list."""
352
+ if v is None:
353
+ return None
354
+ if isinstance(v, (dict, AlertConfig)):
355
+ return [v]
356
+ return v
347
357
  tables: Optional[TablesConfig] = Field(
348
358
  default=None, description="Custom table names (overrides defaults)"
349
359
  )
@@ -814,13 +814,14 @@ class InternalTablesManager:
814
814
  no_data_alert = 0
815
815
  min_detectors = 1
816
816
 
817
- if metric_config.alerting:
818
- is_alert_enabled = 1 if metric_config.alerting.enabled else 0
819
- timezone_str = metric_config.alerting.timezone
820
- direction = metric_config.alerting.direction
821
- consecutive_anomalies = metric_config.alerting.consecutive_anomalies
822
- no_data_alert = 1 if metric_config.alerting.no_data_alert else 0
823
- min_detectors = metric_config.alerting.min_detectors
817
+ first_alerting = metric_config.alerting[0] if metric_config.alerting else None
818
+ if first_alerting:
819
+ is_alert_enabled = 1 if first_alerting.enabled else 0
820
+ timezone_str = first_alerting.timezone
821
+ direction = first_alerting.direction
822
+ consecutive_anomalies = first_alerting.consecutive_anomalies
823
+ no_data_alert = 1 if first_alerting.no_data_alert else 0
824
+ min_detectors = first_alerting.min_detectors
824
825
 
825
826
  # Prepare data for INSERT
826
827
  data = {