detectkit 0.2.4__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 +193 -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 +369 -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 +486 -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 +499 -0
- detectkit/config/profile.py +285 -0
- detectkit/config/project_config.py +164 -0
- detectkit/config/validator.py +124 -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 +393 -0
- detectkit/database/internal_tables.py +724 -0
- detectkit/database/manager.py +324 -0
- detectkit/database/tables.py +138 -0
- detectkit/detectors/__init__.py +6 -0
- detectkit/detectors/base.py +441 -0
- detectkit/detectors/factory.py +138 -0
- detectkit/detectors/statistical/__init__.py +8 -0
- detectkit/detectors/statistical/iqr.py +508 -0
- detectkit/detectors/statistical/mad.py +478 -0
- detectkit/detectors/statistical/manual_bounds.py +206 -0
- detectkit/detectors/statistical/zscore.py +491 -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 +746 -0
- detectkit/utils/__init__.py +17 -0
- detectkit/utils/stats.py +196 -0
- detectkit-0.2.4.dist-info/METADATA +237 -0
- detectkit-0.2.4.dist-info/RECORD +51 -0
- detectkit-0.2.4.dist-info/WHEEL +5 -0
- detectkit-0.2.4.dist-info/entry_points.txt +2 -0
- detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
- detectkit-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of 'dtk run' command.
|
|
3
|
+
|
|
4
|
+
Executes metric processing pipeline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from detectkit.config.metric_config import MetricConfig
|
|
14
|
+
from detectkit.config.profile import ProfilesConfig
|
|
15
|
+
from detectkit.config.validator import validate_metric_uniqueness
|
|
16
|
+
from detectkit.database.internal_tables import InternalTablesManager
|
|
17
|
+
from detectkit.orchestration.task_manager import PipelineStep, TaskManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_command(
|
|
21
|
+
select: str,
|
|
22
|
+
exclude: Optional[str],
|
|
23
|
+
steps: str,
|
|
24
|
+
from_date: Optional[str],
|
|
25
|
+
to_date: Optional[str],
|
|
26
|
+
full_refresh: bool,
|
|
27
|
+
force: bool,
|
|
28
|
+
profile: Optional[str],
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Execute metric processing pipeline.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
select: Metric selector (name, path, or tag)
|
|
35
|
+
exclude: Metrics to exclude (name, path, or tag)
|
|
36
|
+
steps: Comma-separated pipeline steps
|
|
37
|
+
from_date: Start date string
|
|
38
|
+
to_date: End date string
|
|
39
|
+
full_refresh: Delete and reload all data
|
|
40
|
+
force: Ignore task locks
|
|
41
|
+
profile: Profile name to use
|
|
42
|
+
"""
|
|
43
|
+
# Parse steps
|
|
44
|
+
step_list = parse_steps(steps)
|
|
45
|
+
|
|
46
|
+
# Parse dates
|
|
47
|
+
from_dt = parse_date(from_date) if from_date else None
|
|
48
|
+
to_dt = parse_date(to_date) if to_date else None
|
|
49
|
+
|
|
50
|
+
# Find project root and load config
|
|
51
|
+
project_root = find_project_root()
|
|
52
|
+
if not project_root:
|
|
53
|
+
click.echo(
|
|
54
|
+
click.style(
|
|
55
|
+
"Error: Not in a detectkit project directory!",
|
|
56
|
+
fg="red",
|
|
57
|
+
bold=True,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
click.echo("Run 'dtk init <project_name>' to create a new project.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
click.echo(f"Project root: {project_root}")
|
|
64
|
+
|
|
65
|
+
# Load project config
|
|
66
|
+
# project_config = load_project_config(project_root)
|
|
67
|
+
|
|
68
|
+
# Select metrics based on selector
|
|
69
|
+
# Returns list of (path, config) tuples with uniqueness validation
|
|
70
|
+
try:
|
|
71
|
+
metrics = select_metrics(select, project_root)
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
click.echo(
|
|
74
|
+
click.style(
|
|
75
|
+
f"Error: {e}",
|
|
76
|
+
fg="red",
|
|
77
|
+
bold=True,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Exclude metrics if specified
|
|
83
|
+
if exclude:
|
|
84
|
+
try:
|
|
85
|
+
excluded_metrics = select_metrics(exclude, project_root)
|
|
86
|
+
excluded_names = {config.name for _, config in excluded_metrics}
|
|
87
|
+
metrics = [(path, config) for path, config in metrics if config.name not in excluded_names]
|
|
88
|
+
|
|
89
|
+
if excluded_metrics:
|
|
90
|
+
click.echo(f"Excluded {len(excluded_metrics)} metric(s) matching: {exclude}")
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
click.echo(
|
|
93
|
+
click.style(
|
|
94
|
+
f"Error in exclusion selector: {e}",
|
|
95
|
+
fg="red",
|
|
96
|
+
bold=True,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if not metrics:
|
|
102
|
+
click.echo(
|
|
103
|
+
click.style(
|
|
104
|
+
f"No metrics found matching selector: {select}",
|
|
105
|
+
fg="yellow",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
click.echo(f"Found {len(metrics)} metric(s) to process")
|
|
111
|
+
click.echo()
|
|
112
|
+
|
|
113
|
+
# Load profiles.yml
|
|
114
|
+
profiles_path = project_root / "profiles.yml"
|
|
115
|
+
if not profiles_path.exists():
|
|
116
|
+
click.echo(
|
|
117
|
+
click.style(
|
|
118
|
+
"Error: profiles.yml not found!",
|
|
119
|
+
fg="red",
|
|
120
|
+
bold=True,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
click.echo(f"Expected at: {profiles_path}")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
profiles_config = ProfilesConfig.from_yaml(profiles_path)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
click.echo(
|
|
130
|
+
click.style(
|
|
131
|
+
f"Error loading profiles.yml: {e}",
|
|
132
|
+
fg="red",
|
|
133
|
+
bold=True,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Create database manager
|
|
139
|
+
try:
|
|
140
|
+
db_manager = profiles_config.create_manager(profile)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
click.echo(
|
|
143
|
+
click.style(
|
|
144
|
+
f"Error creating database manager: {e}",
|
|
145
|
+
fg="red",
|
|
146
|
+
bold=True,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Create internal tables manager
|
|
152
|
+
internal_manager = InternalTablesManager(db_manager)
|
|
153
|
+
|
|
154
|
+
# Initialize internal tables if needed
|
|
155
|
+
try:
|
|
156
|
+
internal_manager.ensure_tables()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
click.echo(
|
|
159
|
+
click.style(
|
|
160
|
+
f"Error initializing internal tables: {e}",
|
|
161
|
+
fg="red",
|
|
162
|
+
bold=True,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Create task manager
|
|
168
|
+
task_manager = TaskManager(
|
|
169
|
+
internal_manager=internal_manager,
|
|
170
|
+
db_manager=db_manager,
|
|
171
|
+
profiles_config=profiles_config,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Process each metric
|
|
175
|
+
for metric_path, config in metrics:
|
|
176
|
+
process_metric(
|
|
177
|
+
metric_path=metric_path,
|
|
178
|
+
config=config,
|
|
179
|
+
project_root=project_root,
|
|
180
|
+
task_manager=task_manager,
|
|
181
|
+
steps=step_list,
|
|
182
|
+
from_date=from_dt,
|
|
183
|
+
to_date=to_dt,
|
|
184
|
+
full_refresh=full_refresh,
|
|
185
|
+
force=force,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_steps(steps_str: str) -> List[PipelineStep]:
|
|
190
|
+
"""
|
|
191
|
+
Parse comma-separated steps string.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
steps_str: Comma-separated steps (e.g., "load,detect,alert")
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of PipelineStep enums
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
>>> parse_steps("load,detect")
|
|
201
|
+
[PipelineStep.LOAD, PipelineStep.DETECT]
|
|
202
|
+
"""
|
|
203
|
+
step_map = {
|
|
204
|
+
"load": PipelineStep.LOAD,
|
|
205
|
+
"detect": PipelineStep.DETECT,
|
|
206
|
+
"alert": PipelineStep.ALERT,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
steps = []
|
|
210
|
+
for step_str in steps_str.split(","):
|
|
211
|
+
step_str = step_str.strip().lower()
|
|
212
|
+
if step_str not in step_map:
|
|
213
|
+
raise click.BadParameter(
|
|
214
|
+
f"Invalid step: {step_str}. Valid steps: load, detect, alert"
|
|
215
|
+
)
|
|
216
|
+
steps.append(step_map[step_str])
|
|
217
|
+
|
|
218
|
+
return steps
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_date(date_str: str) -> datetime:
|
|
222
|
+
"""
|
|
223
|
+
Parse date string to datetime.
|
|
224
|
+
|
|
225
|
+
Supports formats:
|
|
226
|
+
- YYYY-MM-DD
|
|
227
|
+
- YYYY-MM-DD HH:MM:SS
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
date_str: Date string
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
datetime object
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
click.BadParameter: If date format is invalid
|
|
237
|
+
"""
|
|
238
|
+
formats = [
|
|
239
|
+
"%Y-%m-%d",
|
|
240
|
+
"%Y-%m-%d %H:%M:%S",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
for fmt in formats:
|
|
244
|
+
try:
|
|
245
|
+
return datetime.strptime(date_str, fmt)
|
|
246
|
+
except ValueError:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
raise click.BadParameter(
|
|
250
|
+
f"Invalid date format: {date_str}. "
|
|
251
|
+
f"Use YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def find_project_root() -> Optional[Path]:
|
|
256
|
+
"""
|
|
257
|
+
Find detectkit project root by looking for detectkit_project.yml.
|
|
258
|
+
|
|
259
|
+
Searches current directory and parent directories.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Path to project root or None if not found
|
|
263
|
+
"""
|
|
264
|
+
current = Path.cwd()
|
|
265
|
+
|
|
266
|
+
# Search up to 10 levels up
|
|
267
|
+
for _ in range(10):
|
|
268
|
+
if (current / "detectkit_project.yml").exists():
|
|
269
|
+
return current
|
|
270
|
+
|
|
271
|
+
if current.parent == current:
|
|
272
|
+
# Reached filesystem root
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
current = current.parent
|
|
276
|
+
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, MetricConfig]]:
|
|
281
|
+
"""
|
|
282
|
+
Select metrics based on selector and validate uniqueness.
|
|
283
|
+
|
|
284
|
+
Selector types:
|
|
285
|
+
- Metric name: "cpu_usage" (searches by 'name' field recursively in subdirectories)
|
|
286
|
+
- Path pattern: "metrics/critical/*.yml" or "league/cpu_usage"
|
|
287
|
+
- Tag: "tag:critical"
|
|
288
|
+
|
|
289
|
+
For name selector:
|
|
290
|
+
1. First tries filename-based search in root metrics/ directory
|
|
291
|
+
2. If not found, searches recursively by 'name' field in all subdirectories
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
selector: Selector string
|
|
295
|
+
project_root: Project root path
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
List of (path, config) tuples for selected metrics
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If duplicate metric names found or configs invalid
|
|
302
|
+
"""
|
|
303
|
+
metrics_dir = project_root / "metrics"
|
|
304
|
+
|
|
305
|
+
if not metrics_dir.exists():
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
# Collect metric paths based on selector
|
|
309
|
+
metric_paths: List[Path] = []
|
|
310
|
+
|
|
311
|
+
# Tag selector
|
|
312
|
+
if selector.startswith("tag:"):
|
|
313
|
+
tag = selector[4:]
|
|
314
|
+
metric_paths = find_metrics_by_tag(metrics_dir, tag)
|
|
315
|
+
# Path pattern selector
|
|
316
|
+
elif "*" in selector or "/" in selector:
|
|
317
|
+
pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
|
|
318
|
+
metric_paths = list(project_root.glob(pattern))
|
|
319
|
+
# Metric name selector
|
|
320
|
+
else:
|
|
321
|
+
# First try filename-based search in root (backward compatibility)
|
|
322
|
+
metric_file = metrics_dir / f"{selector}.yml"
|
|
323
|
+
if metric_file.exists():
|
|
324
|
+
metric_paths = [metric_file]
|
|
325
|
+
else:
|
|
326
|
+
# Try with .yaml extension
|
|
327
|
+
metric_file = metrics_dir / f"{selector}.yaml"
|
|
328
|
+
if metric_file.exists():
|
|
329
|
+
metric_paths = [metric_file]
|
|
330
|
+
else:
|
|
331
|
+
# Fall back to recursive search by 'name' field
|
|
332
|
+
found_metric = find_metric_by_name(metrics_dir, selector)
|
|
333
|
+
if found_metric:
|
|
334
|
+
metric_paths = [found_metric]
|
|
335
|
+
|
|
336
|
+
if not metric_paths:
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
# Validate uniqueness and load configs
|
|
340
|
+
# This will raise ValueError if duplicate metric names found
|
|
341
|
+
return validate_metric_uniqueness(metric_paths)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def find_metrics_by_tag(metrics_dir: Path, tag: str) -> List[Path]:
|
|
345
|
+
"""
|
|
346
|
+
Find all metrics with specific tag.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
metrics_dir: Metrics directory path
|
|
350
|
+
tag: Tag to search for
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
List of metric paths with this tag
|
|
354
|
+
"""
|
|
355
|
+
import yaml
|
|
356
|
+
|
|
357
|
+
matching_metrics = []
|
|
358
|
+
|
|
359
|
+
for metric_file in metrics_dir.glob("**/*.yml"):
|
|
360
|
+
try:
|
|
361
|
+
with open(metric_file) as f:
|
|
362
|
+
config = yaml.safe_load(f)
|
|
363
|
+
|
|
364
|
+
if config and "tags" in config:
|
|
365
|
+
if tag in config["tags"]:
|
|
366
|
+
matching_metrics.append(metric_file)
|
|
367
|
+
except Exception:
|
|
368
|
+
# Skip files that can't be parsed
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
return matching_metrics
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def find_metric_by_name(metrics_dir: Path, name: str) -> Optional[Path]:
|
|
375
|
+
"""
|
|
376
|
+
Find metric by name field (searches recursively in subdirectories).
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
metrics_dir: Metrics directory path
|
|
380
|
+
name: Metric name to search for (from 'name' field in YAML)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Path to metric file if found, None otherwise
|
|
384
|
+
"""
|
|
385
|
+
import yaml
|
|
386
|
+
|
|
387
|
+
# Search both .yml and .yaml extensions
|
|
388
|
+
for pattern in ["**/*.yml", "**/*.yaml"]:
|
|
389
|
+
for metric_file in metrics_dir.glob(pattern):
|
|
390
|
+
try:
|
|
391
|
+
with open(metric_file) as f:
|
|
392
|
+
config = yaml.safe_load(f)
|
|
393
|
+
|
|
394
|
+
if config and config.get("name") == name:
|
|
395
|
+
return metric_file
|
|
396
|
+
except Exception:
|
|
397
|
+
# Skip files that can't be parsed
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def process_metric(
|
|
404
|
+
metric_path: Path,
|
|
405
|
+
config: MetricConfig,
|
|
406
|
+
project_root: Path,
|
|
407
|
+
task_manager: TaskManager,
|
|
408
|
+
steps: List[PipelineStep],
|
|
409
|
+
from_date: Optional[datetime],
|
|
410
|
+
to_date: Optional[datetime],
|
|
411
|
+
full_refresh: bool,
|
|
412
|
+
force: bool,
|
|
413
|
+
):
|
|
414
|
+
"""
|
|
415
|
+
Process a single metric.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
metric_path: Path to metric YAML file
|
|
419
|
+
config: Loaded and validated metric configuration
|
|
420
|
+
project_root: Project root directory
|
|
421
|
+
task_manager: Task manager instance
|
|
422
|
+
steps: Pipeline steps to execute
|
|
423
|
+
from_date: Start date
|
|
424
|
+
to_date: End date
|
|
425
|
+
full_refresh: Full refresh flag
|
|
426
|
+
force: Force flag
|
|
427
|
+
"""
|
|
428
|
+
# Use config.name (not metric_path.stem) for consistency
|
|
429
|
+
metric_name = config.name
|
|
430
|
+
|
|
431
|
+
click.echo(click.style(f"Processing metric: {metric_name}", fg="cyan", bold=True))
|
|
432
|
+
click.echo(f" Config file: {metric_path.relative_to(project_root)}")
|
|
433
|
+
click.echo(f" Steps: {', '.join(s.value for s in steps)}")
|
|
434
|
+
|
|
435
|
+
if from_date:
|
|
436
|
+
click.echo(f" From: {from_date}")
|
|
437
|
+
if to_date:
|
|
438
|
+
click.echo(f" To: {to_date}")
|
|
439
|
+
if full_refresh:
|
|
440
|
+
click.echo(click.style(" Full refresh: YES", fg="yellow"))
|
|
441
|
+
if force:
|
|
442
|
+
click.echo(click.style(" Force: YES (ignoring locks)", fg="yellow"))
|
|
443
|
+
|
|
444
|
+
click.echo()
|
|
445
|
+
|
|
446
|
+
# Run pipeline
|
|
447
|
+
try:
|
|
448
|
+
# Log step headers
|
|
449
|
+
if PipelineStep.LOAD in steps:
|
|
450
|
+
click.echo()
|
|
451
|
+
click.echo(click.style(" ┌─ LOAD", fg="cyan", bold=True))
|
|
452
|
+
|
|
453
|
+
result = task_manager.run_metric(
|
|
454
|
+
config=config,
|
|
455
|
+
steps=steps,
|
|
456
|
+
from_date=from_date,
|
|
457
|
+
to_date=to_date,
|
|
458
|
+
full_refresh=full_refresh,
|
|
459
|
+
force=force,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Display results - task_manager already printed details
|
|
463
|
+
click.echo()
|
|
464
|
+
if result["status"] == "success":
|
|
465
|
+
click.echo(click.style("✓ Pipeline completed successfully", fg="green", bold=True))
|
|
466
|
+
else:
|
|
467
|
+
click.echo(
|
|
468
|
+
click.style(
|
|
469
|
+
f" ✗ Failed: {result['error']}",
|
|
470
|
+
fg="red",
|
|
471
|
+
bold=True,
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
click.echo(
|
|
477
|
+
click.style(
|
|
478
|
+
f" ✗ Pipeline error: {e}",
|
|
479
|
+
fg="red",
|
|
480
|
+
bold=True,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
import traceback
|
|
484
|
+
click.echo(traceback.format_exc())
|
|
485
|
+
|
|
486
|
+
click.echo()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test alert command - send test alert to configured channels.
|
|
3
|
+
|
|
4
|
+
Allows testing alert rendering and channel delivery without real anomalies.
|
|
5
|
+
Useful for:
|
|
6
|
+
- Verifying Mattermost/Slack message formatting
|
|
7
|
+
- Testing webhook connectivity
|
|
8
|
+
- Previewing alert templates
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from detectkit.alerting.channels.base import AlertData
|
|
18
|
+
from detectkit.alerting.channels.factory import AlertChannelFactory
|
|
19
|
+
from detectkit.config.metric_config import MetricConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_mock_alert_data(
|
|
23
|
+
metric_config: MetricConfig,
|
|
24
|
+
timezone_display: str = "UTC",
|
|
25
|
+
) -> AlertData:
|
|
26
|
+
"""
|
|
27
|
+
Create realistic mock AlertData for testing.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
metric_config: Metric configuration
|
|
31
|
+
timezone_display: Timezone for display
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
AlertData with mock anomaly data
|
|
35
|
+
"""
|
|
36
|
+
# Use current time
|
|
37
|
+
now = datetime.now(timezone.utc)
|
|
38
|
+
|
|
39
|
+
# Create realistic mock data
|
|
40
|
+
return AlertData(
|
|
41
|
+
metric_name=metric_config.name,
|
|
42
|
+
timestamp=np.datetime64(now, "ms"),
|
|
43
|
+
timezone=timezone_display,
|
|
44
|
+
value=0.8532, # Mock anomalous value
|
|
45
|
+
confidence_lower=0.4521,
|
|
46
|
+
confidence_upper=0.6234,
|
|
47
|
+
detector_name="MADDetector:threshold=3.0",
|
|
48
|
+
detector_params='{"threshold": 3.0, "window_size": 8640}',
|
|
49
|
+
direction="above",
|
|
50
|
+
severity=4.52,
|
|
51
|
+
detection_metadata={
|
|
52
|
+
"global_median": 0.5123,
|
|
53
|
+
"adjusted_median": 0.5234,
|
|
54
|
+
"seasonality_groups": [
|
|
55
|
+
{
|
|
56
|
+
"group": ["offset_10minutes", "league_day"],
|
|
57
|
+
"median_multiplier": 1.023,
|
|
58
|
+
"mad_multiplier": 0.876,
|
|
59
|
+
"group_size": 23,
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
consecutive_count=3,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_test_alert(metric_name: str, profile: Optional[str] = None):
|
|
68
|
+
"""
|
|
69
|
+
Send test alert for specified metric.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
metric_name: Name of metric to test alert for
|
|
73
|
+
profile: Optional profile override
|
|
74
|
+
"""
|
|
75
|
+
# Load project config
|
|
76
|
+
project_root = Path.cwd()
|
|
77
|
+
project_config_path = project_root / "detectkit_project.yml"
|
|
78
|
+
|
|
79
|
+
if not project_config_path.exists():
|
|
80
|
+
print("Error: No detectkit_project.yml found in current directory")
|
|
81
|
+
print("Run this command from your detectkit project root")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Load project config manually (avoid validation issues)
|
|
85
|
+
import yaml
|
|
86
|
+
|
|
87
|
+
with open(project_config_path) as f:
|
|
88
|
+
project_data = yaml.safe_load(f)
|
|
89
|
+
|
|
90
|
+
metrics_dir_name = project_data.get("metrics_path", "metrics")
|
|
91
|
+
|
|
92
|
+
# Find metric config
|
|
93
|
+
metrics_dir = project_root / metrics_dir_name
|
|
94
|
+
metric_files = list(metrics_dir.glob("**/*.yml")) + list(
|
|
95
|
+
metrics_dir.glob("**/*.yaml")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
metric_config = None
|
|
99
|
+
for metric_file in metric_files:
|
|
100
|
+
try:
|
|
101
|
+
config = MetricConfig.from_yaml_file(metric_file)
|
|
102
|
+
if config.name == metric_name:
|
|
103
|
+
metric_config = config
|
|
104
|
+
break
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if not metric_config:
|
|
109
|
+
print(f"Error: Metric '{metric_name}' not found")
|
|
110
|
+
print(f"Searched in: {metrics_dir}")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Check if alerting is configured
|
|
114
|
+
if not metric_config.alerting or not metric_config.alerting.enabled:
|
|
115
|
+
print(f"Error: Alerting not enabled for metric '{metric_name}'")
|
|
116
|
+
print("Enable alerting in metric config (alerting.enabled: true)")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if not metric_config.alerting.channels:
|
|
120
|
+
print(f"Error: No alert channels configured for metric '{metric_name}'")
|
|
121
|
+
print("Add channels in metric config (alerting.channels: [...])")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Load profiles
|
|
125
|
+
profiles_path = project_root / "profiles.yml"
|
|
126
|
+
if not profiles_path.exists():
|
|
127
|
+
print("Error: profiles.yml not found")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
import yaml
|
|
131
|
+
|
|
132
|
+
with open(profiles_path) as f:
|
|
133
|
+
profiles_data = yaml.safe_load(f)
|
|
134
|
+
|
|
135
|
+
alert_channels_config = profiles_data.get("alert_channels", {})
|
|
136
|
+
|
|
137
|
+
# Get timezone for display
|
|
138
|
+
timezone_display = metric_config.alerting.timezone or "UTC"
|
|
139
|
+
|
|
140
|
+
# Create mock alert data
|
|
141
|
+
print(f"\n📨 Sending test alert for metric: {metric_name}")
|
|
142
|
+
print(f" Timezone: {timezone_display}")
|
|
143
|
+
print(f" Channels: {', '.join(metric_config.alerting.channels)}\n")
|
|
144
|
+
|
|
145
|
+
alert_data = create_mock_alert_data(metric_config, timezone_display)
|
|
146
|
+
|
|
147
|
+
# Send to each configured channel
|
|
148
|
+
success_count = 0
|
|
149
|
+
for channel_name in metric_config.alerting.channels:
|
|
150
|
+
if channel_name not in alert_channels_config:
|
|
151
|
+
print(f"⚠️ Channel '{channel_name}' not found in profiles.yml - skipping")
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
channel_config = alert_channels_config[channel_name]
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Create channel instance
|
|
158
|
+
# channel_config должен содержать 'type' + остальные параметры
|
|
159
|
+
channel = AlertChannelFactory.create_from_config(channel_config)
|
|
160
|
+
|
|
161
|
+
# Get custom template if configured
|
|
162
|
+
template = None
|
|
163
|
+
if metric_config.alerting.template_consecutive:
|
|
164
|
+
template = metric_config.alerting.template_consecutive
|
|
165
|
+
|
|
166
|
+
# Send alert
|
|
167
|
+
print(f" → Sending to {channel_name}...", end=" ")
|
|
168
|
+
success = channel.send(alert_data, template=template)
|
|
169
|
+
|
|
170
|
+
if success:
|
|
171
|
+
print("✓ SUCCESS")
|
|
172
|
+
success_count += 1
|
|
173
|
+
else:
|
|
174
|
+
print("✗ FAILED")
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print(f"✗ ERROR: {e}")
|
|
178
|
+
|
|
179
|
+
# Summary
|
|
180
|
+
print(f"\n{'✓' if success_count > 0 else '✗'} Sent test alert to {success_count}/{len(metric_config.alerting.channels)} channels")
|
|
181
|
+
|
|
182
|
+
if success_count > 0:
|
|
183
|
+
print("\n💡 Check your configured channels to verify message formatting")
|
|
184
|
+
print(f" Mock data used: value=0.8532, confidence=[0.4521, 0.6234], severity=4.52")
|