detectkit 0.1.0__tar.gz → 0.1.2__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.
- {detectkit-0.1.0/detectkit.egg-info → detectkit-0.1.2}/PKG-INFO +14 -9
- {detectkit-0.1.0 → detectkit-0.1.2}/README.md +13 -8
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/commands/run.py +63 -40
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/config/metric_config.py +32 -0
- detectkit-0.1.2/detectkit/config/validator.py +124 -0
- detectkit-0.1.2/detectkit/detectors/statistical/iqr.py +418 -0
- detectkit-0.1.2/detectkit/detectors/statistical/zscore.py +404 -0
- {detectkit-0.1.0 → detectkit-0.1.2/detectkit.egg-info}/PKG-INFO +14 -9
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit.egg-info/SOURCES.txt +1 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/pyproject.toml +1 -1
- detectkit-0.1.0/detectkit/detectors/statistical/iqr.py +0 -230
- detectkit-0.1.0/detectkit/detectors/statistical/zscore.py +0 -225
- {detectkit-0.1.0 → detectkit-0.1.2}/LICENSE +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/MANIFEST.in +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/alerting/orchestrator.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/cli/main.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/config/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/config/profile.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/config/project_config.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/core/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/core/interval.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/core/models.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/database/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/database/internal_tables.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/database/manager.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/database/tables.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/base.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/orchestration/task_manager.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/requirements.txt +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/setup.cfg +0 -0
- {detectkit-0.1.0 → detectkit-0.1.2}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Metric monitoring with automatic anomaly detection
|
|
5
5
|
Author: detectkit team
|
|
6
6
|
License: MIT
|
|
@@ -67,9 +67,11 @@ Dynamic: license-file
|
|
|
67
67
|
|
|
68
68
|
## Status
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
✅ **Production Ready** - Version 0.1.2
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
Published to PyPI: https://pypi.org/project/detectkit/
|
|
73
|
+
|
|
74
|
+
Complete rewrite with modern architecture and full documentation (2025).
|
|
73
75
|
|
|
74
76
|
## Features
|
|
75
77
|
|
|
@@ -191,14 +193,17 @@ pytest tests/ --cov=detectkit --cov-report=html
|
|
|
191
193
|
- ⚠️ Advanced detectors (Prophet, TimesFM) - optional extras
|
|
192
194
|
- ⚠️ Additional alert channels (Telegram, Email) - optional
|
|
193
195
|
|
|
194
|
-
See [TODO.md](TODO.md) for detailed development roadmap.
|
|
195
|
-
|
|
196
196
|
## Documentation
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
- [
|
|
201
|
-
- [
|
|
198
|
+
📚 **Complete documentation available at: https://github.com/alexeiveselov92/detectkit/tree/main/docs**
|
|
199
|
+
|
|
200
|
+
- [Getting Started](https://github.com/alexeiveselov92/detectkit/blob/main/docs/getting-started/quickstart.md) - 5-minute quickstart
|
|
201
|
+
- [Configuration Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/configuration.md) - All configuration options
|
|
202
|
+
- [Detectors Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/detectors.md) - Choosing the right detector
|
|
203
|
+
- [Alerting Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/alerting.md) - Setting up alerts
|
|
204
|
+
- [CLI Reference](https://github.com/alexeiveselov92/detectkit/blob/main/docs/reference/cli.md) - Command-line documentation
|
|
205
|
+
- [Examples](https://github.com/alexeiveselov92/detectkit/tree/main/docs/examples) - Real-world monitoring scenarios
|
|
206
|
+
|
|
202
207
|
|
|
203
208
|
## Requirements
|
|
204
209
|
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
✅ **Production Ready** - Version 0.1.2
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Published to PyPI: https://pypi.org/project/detectkit/
|
|
12
|
+
|
|
13
|
+
Complete rewrite with modern architecture and full documentation (2025).
|
|
12
14
|
|
|
13
15
|
## Features
|
|
14
16
|
|
|
@@ -130,14 +132,17 @@ pytest tests/ --cov=detectkit --cov-report=html
|
|
|
130
132
|
- ⚠️ Advanced detectors (Prophet, TimesFM) - optional extras
|
|
131
133
|
- ⚠️ Additional alert channels (Telegram, Email) - optional
|
|
132
134
|
|
|
133
|
-
See [TODO.md](TODO.md) for detailed development roadmap.
|
|
134
|
-
|
|
135
135
|
## Documentation
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- [
|
|
140
|
-
- [
|
|
137
|
+
📚 **Complete documentation available at: https://github.com/alexeiveselov92/detectkit/tree/main/docs**
|
|
138
|
+
|
|
139
|
+
- [Getting Started](https://github.com/alexeiveselov92/detectkit/blob/main/docs/getting-started/quickstart.md) - 5-minute quickstart
|
|
140
|
+
- [Configuration Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/configuration.md) - All configuration options
|
|
141
|
+
- [Detectors Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/detectors.md) - Choosing the right detector
|
|
142
|
+
- [Alerting Guide](https://github.com/alexeiveselov92/detectkit/blob/main/docs/guides/alerting.md) - Setting up alerts
|
|
143
|
+
- [CLI Reference](https://github.com/alexeiveselov92/detectkit/blob/main/docs/reference/cli.md) - Command-line documentation
|
|
144
|
+
- [Examples](https://github.com/alexeiveselov92/detectkit/tree/main/docs/examples) - Real-world monitoring scenarios
|
|
145
|
+
|
|
141
146
|
|
|
142
147
|
## Requirements
|
|
143
148
|
|
|
@@ -12,6 +12,7 @@ import click
|
|
|
12
12
|
|
|
13
13
|
from detectkit.config.metric_config import MetricConfig
|
|
14
14
|
from detectkit.config.profile import ProfilesConfig
|
|
15
|
+
from detectkit.config.validator import validate_metric_uniqueness
|
|
15
16
|
from detectkit.database.internal_tables import InternalTablesManager
|
|
16
17
|
from detectkit.orchestration.task_manager import PipelineStep, TaskManager
|
|
17
18
|
|
|
@@ -65,16 +66,37 @@ def run_command(
|
|
|
65
66
|
# project_config = load_project_config(project_root)
|
|
66
67
|
|
|
67
68
|
# Select metrics based on selector
|
|
68
|
-
|
|
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
|
|
69
81
|
|
|
70
82
|
# Exclude metrics if specified
|
|
71
83
|
if exclude:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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]
|
|
75
88
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
100
|
|
|
79
101
|
if not metrics:
|
|
80
102
|
click.echo(
|
|
@@ -150,9 +172,10 @@ def run_command(
|
|
|
150
172
|
)
|
|
151
173
|
|
|
152
174
|
# Process each metric
|
|
153
|
-
for metric_path in metrics:
|
|
175
|
+
for metric_path, config in metrics:
|
|
154
176
|
process_metric(
|
|
155
177
|
metric_path=metric_path,
|
|
178
|
+
config=config,
|
|
156
179
|
project_root=project_root,
|
|
157
180
|
task_manager=task_manager,
|
|
158
181
|
steps=step_list,
|
|
@@ -254,9 +277,9 @@ def find_project_root() -> Optional[Path]:
|
|
|
254
277
|
return None
|
|
255
278
|
|
|
256
279
|
|
|
257
|
-
def select_metrics(selector: str, project_root: Path) -> List[Path]:
|
|
280
|
+
def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, MetricConfig]]:
|
|
258
281
|
"""
|
|
259
|
-
Select metrics based on selector.
|
|
282
|
+
Select metrics based on selector and validate uniqueness.
|
|
260
283
|
|
|
261
284
|
Selector types:
|
|
262
285
|
- Metric name: "cpu_usage"
|
|
@@ -268,34 +291,44 @@ def select_metrics(selector: str, project_root: Path) -> List[Path]:
|
|
|
268
291
|
project_root: Project root path
|
|
269
292
|
|
|
270
293
|
Returns:
|
|
271
|
-
List of
|
|
294
|
+
List of (path, config) tuples for selected metrics
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValueError: If duplicate metric names found or configs invalid
|
|
272
298
|
"""
|
|
273
299
|
metrics_dir = project_root / "metrics"
|
|
274
300
|
|
|
275
301
|
if not metrics_dir.exists():
|
|
276
302
|
return []
|
|
277
303
|
|
|
304
|
+
# Collect metric paths based on selector
|
|
305
|
+
metric_paths: List[Path] = []
|
|
306
|
+
|
|
278
307
|
# Tag selector
|
|
279
308
|
if selector.startswith("tag:"):
|
|
280
309
|
tag = selector[4:]
|
|
281
|
-
|
|
282
|
-
|
|
310
|
+
metric_paths = find_metrics_by_tag(metrics_dir, tag)
|
|
283
311
|
# Path pattern selector
|
|
284
|
-
|
|
312
|
+
elif "*" in selector or "/" in selector:
|
|
285
313
|
pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
314
|
+
metric_paths = list(project_root.glob(pattern))
|
|
315
|
+
# Metric name selector (only searches root metrics/ directory)
|
|
316
|
+
else:
|
|
317
|
+
metric_file = metrics_dir / f"{selector}.yml"
|
|
318
|
+
if metric_file.exists():
|
|
319
|
+
metric_paths = [metric_file]
|
|
320
|
+
else:
|
|
321
|
+
# Try with .yaml extension
|
|
322
|
+
metric_file = metrics_dir / f"{selector}.yaml"
|
|
323
|
+
if metric_file.exists():
|
|
324
|
+
metric_paths = [metric_file]
|
|
292
325
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if metric_file.exists():
|
|
296
|
-
return [metric_file]
|
|
326
|
+
if not metric_paths:
|
|
327
|
+
return []
|
|
297
328
|
|
|
298
|
-
|
|
329
|
+
# Validate uniqueness and load configs
|
|
330
|
+
# This will raise ValueError if duplicate metric names found
|
|
331
|
+
return validate_metric_uniqueness(metric_paths)
|
|
299
332
|
|
|
300
333
|
|
|
301
334
|
def find_metrics_by_tag(metrics_dir: Path, tag: str) -> List[Path]:
|
|
@@ -330,6 +363,7 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> List[Path]:
|
|
|
330
363
|
|
|
331
364
|
def process_metric(
|
|
332
365
|
metric_path: Path,
|
|
366
|
+
config: MetricConfig,
|
|
333
367
|
project_root: Path,
|
|
334
368
|
task_manager: TaskManager,
|
|
335
369
|
steps: List[PipelineStep],
|
|
@@ -343,6 +377,7 @@ def process_metric(
|
|
|
343
377
|
|
|
344
378
|
Args:
|
|
345
379
|
metric_path: Path to metric YAML file
|
|
380
|
+
config: Loaded and validated metric configuration
|
|
346
381
|
project_root: Project root directory
|
|
347
382
|
task_manager: Task manager instance
|
|
348
383
|
steps: Pipeline steps to execute
|
|
@@ -351,10 +386,11 @@ def process_metric(
|
|
|
351
386
|
full_refresh: Full refresh flag
|
|
352
387
|
force: Force flag
|
|
353
388
|
"""
|
|
354
|
-
|
|
389
|
+
# Use config.name (not metric_path.stem) for consistency
|
|
390
|
+
metric_name = config.name
|
|
355
391
|
|
|
356
|
-
click.echo(click.style(f"Processing: {metric_name}", fg="cyan", bold=True))
|
|
357
|
-
click.echo(f"
|
|
392
|
+
click.echo(click.style(f"Processing metric: {metric_name}", fg="cyan", bold=True))
|
|
393
|
+
click.echo(f" Config file: {metric_path.relative_to(project_root)}")
|
|
358
394
|
click.echo(f" Steps: {', '.join(s.value for s in steps)}")
|
|
359
395
|
|
|
360
396
|
if from_date:
|
|
@@ -368,19 +404,6 @@ def process_metric(
|
|
|
368
404
|
|
|
369
405
|
click.echo()
|
|
370
406
|
|
|
371
|
-
# Load metric configuration
|
|
372
|
-
try:
|
|
373
|
-
config = MetricConfig.from_yaml_file(metric_path)
|
|
374
|
-
except Exception as e:
|
|
375
|
-
click.echo(
|
|
376
|
-
click.style(
|
|
377
|
-
f" ✗ Error loading metric config: {e}",
|
|
378
|
-
fg="red",
|
|
379
|
-
)
|
|
380
|
-
)
|
|
381
|
-
click.echo()
|
|
382
|
-
return
|
|
383
|
-
|
|
384
407
|
# Run pipeline
|
|
385
408
|
try:
|
|
386
409
|
result = task_manager.run_metric(
|
|
@@ -231,6 +231,7 @@ class MetricConfig(BaseModel):
|
|
|
231
231
|
|
|
232
232
|
Attributes:
|
|
233
233
|
name: Metric name (unique identifier)
|
|
234
|
+
tags: Optional list of tags for metric selection (e.g., ["critical", "api"])
|
|
234
235
|
profile: Profile name to use (overrides default_profile from project config)
|
|
235
236
|
query: Inline SQL query (mutually exclusive with query_file)
|
|
236
237
|
query_file: Path to SQL file (mutually exclusive with query)
|
|
@@ -246,6 +247,7 @@ class MetricConfig(BaseModel):
|
|
|
246
247
|
Example YAML:
|
|
247
248
|
```yaml
|
|
248
249
|
name: cpu_usage
|
|
250
|
+
tags: ["critical", "infrastructure", "10min"]
|
|
249
251
|
profile: clickhouse_prod
|
|
250
252
|
query_file: sql/cpu_usage.sql
|
|
251
253
|
query_columns:
|
|
@@ -275,6 +277,10 @@ class MetricConfig(BaseModel):
|
|
|
275
277
|
"""
|
|
276
278
|
|
|
277
279
|
name: str = Field(..., description="Metric name")
|
|
280
|
+
tags: Optional[List[str]] = Field(
|
|
281
|
+
default=None,
|
|
282
|
+
description="Optional tags for metric selection (e.g., ['critical', 'api', '10min'])",
|
|
283
|
+
)
|
|
278
284
|
profile: Optional[str] = Field(
|
|
279
285
|
default=None, description="Profile name to use (overrides default_profile)"
|
|
280
286
|
)
|
|
@@ -335,6 +341,32 @@ class MetricConfig(BaseModel):
|
|
|
335
341
|
)
|
|
336
342
|
return v
|
|
337
343
|
|
|
344
|
+
@field_validator("tags")
|
|
345
|
+
@classmethod
|
|
346
|
+
def validate_tags(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
|
347
|
+
"""Validate tags field."""
|
|
348
|
+
if v is None:
|
|
349
|
+
return v
|
|
350
|
+
|
|
351
|
+
if not v:
|
|
352
|
+
raise ValueError("tags list cannot be empty (use null instead)")
|
|
353
|
+
|
|
354
|
+
# Check for duplicate tags
|
|
355
|
+
if len(v) != len(set(v)):
|
|
356
|
+
raise ValueError("Duplicate tags not allowed")
|
|
357
|
+
|
|
358
|
+
# Validate each tag format (alphanumeric + underscore + dash)
|
|
359
|
+
for tag in v:
|
|
360
|
+
if not tag:
|
|
361
|
+
raise ValueError("Empty tag not allowed")
|
|
362
|
+
if not all(c.isalnum() or c in ("_", "-") for c in tag):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"Invalid tag '{tag}': only alphanumeric characters, "
|
|
365
|
+
f"underscores, and dashes allowed"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
return v
|
|
369
|
+
|
|
338
370
|
@field_validator("loading_batch_size")
|
|
339
371
|
@classmethod
|
|
340
372
|
def validate_batch_size(cls, v: int) -> int:
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metric configuration validation.
|
|
3
|
+
|
|
4
|
+
This module provides validation functions for metric configurations,
|
|
5
|
+
ensuring data integrity and preventing configuration errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
from detectkit.config.metric_config import MetricConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_metric_uniqueness(metric_paths: List[Path]) -> List[Tuple[Path, MetricConfig]]:
|
|
15
|
+
"""
|
|
16
|
+
Load all metrics and validate that metric names are unique.
|
|
17
|
+
|
|
18
|
+
This validation is CRITICAL for data integrity because duplicate metric names
|
|
19
|
+
would cause:
|
|
20
|
+
- Data corruption (mixed data in _dtk_datapoints table)
|
|
21
|
+
- Task blocking (lock conflicts in _dtk_tasks table)
|
|
22
|
+
- Wrong anomaly detection (detectors receive mixed data from different sources)
|
|
23
|
+
- Data loss (ReplacingMergeTree ignores duplicate inserts)
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
metric_paths: List of paths to metric YAML files
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of (path, config) tuples for all valid metrics
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If duplicate metric names are found, with clear error message
|
|
33
|
+
showing which files have conflicting names
|
|
34
|
+
ValidationError: If any metric config fails to parse
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> paths = [Path("metrics/api/cpu.yml"), Path("metrics/system/cpu.yml")]
|
|
38
|
+
>>> validate_metric_uniqueness(paths)
|
|
39
|
+
ValueError: Duplicate metric name 'cpu_usage' found:
|
|
40
|
+
- metrics/api/cpu.yml
|
|
41
|
+
- metrics/system/cpu.yml
|
|
42
|
+
|
|
43
|
+
Metric names must be unique across the project.
|
|
44
|
+
Please rename one of the metrics.
|
|
45
|
+
"""
|
|
46
|
+
configs: List[Tuple[Path, MetricConfig]] = []
|
|
47
|
+
seen_names: dict[str, Path] = {}
|
|
48
|
+
|
|
49
|
+
for metric_path in metric_paths:
|
|
50
|
+
# Load and parse config
|
|
51
|
+
try:
|
|
52
|
+
config = MetricConfig.from_yaml_file(metric_path)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Failed to parse metric config at {metric_path}:\n{e}"
|
|
56
|
+
) from e
|
|
57
|
+
|
|
58
|
+
# Check for duplicate metric names
|
|
59
|
+
if config.name in seen_names:
|
|
60
|
+
conflicting_path = seen_names[config.name]
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Duplicate metric name '{config.name}' found:\n"
|
|
63
|
+
f" - {conflicting_path}\n"
|
|
64
|
+
f" - {metric_path}\n\n"
|
|
65
|
+
f"Metric names must be unique across the project.\n"
|
|
66
|
+
f"Please rename one of the metrics to avoid data corruption."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
seen_names[config.name] = metric_path
|
|
70
|
+
configs.append((metric_path, config))
|
|
71
|
+
|
|
72
|
+
return configs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_project_metrics(project_root: Path) -> List[Tuple[Path, MetricConfig]]:
|
|
76
|
+
"""
|
|
77
|
+
Load and validate all metrics in the project.
|
|
78
|
+
|
|
79
|
+
This is a convenience function that:
|
|
80
|
+
1. Finds all *.yml and *.yaml files in the metrics/ directory (recursively)
|
|
81
|
+
2. Validates uniqueness of metric names
|
|
82
|
+
3. Returns validated list of (path, config) tuples
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
project_root: Path to project root directory (contains metrics/ folder)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of (path, config) tuples for all valid metrics
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If duplicate metric names found or configs fail validation
|
|
92
|
+
FileNotFoundError: If metrics/ directory doesn't exist
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> from pathlib import Path
|
|
96
|
+
>>> project_root = Path("/path/to/project")
|
|
97
|
+
>>> metrics = validate_project_metrics(project_root)
|
|
98
|
+
>>> for path, config in metrics:
|
|
99
|
+
... print(f"{config.name}: {path}")
|
|
100
|
+
"""
|
|
101
|
+
metrics_dir = project_root / "metrics"
|
|
102
|
+
|
|
103
|
+
if not metrics_dir.exists():
|
|
104
|
+
raise FileNotFoundError(
|
|
105
|
+
f"Metrics directory not found: {metrics_dir}\n"
|
|
106
|
+
f"Expected structure:\n"
|
|
107
|
+
f" {project_root}/\n"
|
|
108
|
+
f" metrics/\n"
|
|
109
|
+
f" your_metric.yml\n"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Find all metric files recursively
|
|
113
|
+
metric_paths = []
|
|
114
|
+
for pattern in ["**/*.yml", "**/*.yaml"]:
|
|
115
|
+
metric_paths.extend(metrics_dir.glob(pattern))
|
|
116
|
+
|
|
117
|
+
if not metric_paths:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"No metric files found in {metrics_dir}\n"
|
|
120
|
+
f"Expected at least one *.yml or *.yaml file."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Validate uniqueness
|
|
124
|
+
return validate_metric_uniqueness(metric_paths)
|