detectkit 0.46.0__tar.gz → 0.48.0__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.46.0/detectkit.egg-info → detectkit-0.48.0}/PKG-INFO +5 -1
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/__init__.py +1 -1
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/base.py +17 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_base.py +5 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_decision.py +2 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/alerting.md +1 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/cli.md +53 -3
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/metrics.md +16 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +11 -3
- detectkit-0.48.0/detectkit/cli/commands/osi.py +245 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/run.py +48 -47
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/tune.py +6 -1
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/main.py +8 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/metric_config.py +76 -1
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/validator.py +40 -4
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_alert_step.py +1 -0
- detectkit-0.48.0/detectkit/semantic/__init__.py +41 -0
- detectkit-0.48.0/detectkit/semantic/errors.py +31 -0
- detectkit-0.48.0/detectkit/semantic/exporter.py +102 -0
- detectkit-0.48.0/detectkit/semantic/importer.py +281 -0
- detectkit-0.48.0/detectkit/semantic/osi_model.py +221 -0
- detectkit-0.48.0/detectkit/semantic/query_gen.py +229 -0
- detectkit-0.48.0/detectkit/tuning/assets/tune.js +187 -0
- detectkit-0.48.0/detectkit/tuning/config_writer.py +253 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/payload.py +101 -7
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/server.py +47 -5
- {detectkit-0.46.0 → detectkit-0.48.0/detectkit.egg-info}/PKG-INFO +5 -1
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/SOURCES.txt +7 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/requires.txt +5 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/pyproject.toml +7 -0
- detectkit-0.46.0/detectkit/tuning/assets/tune.js +0 -187
- detectkit-0.46.0/detectkit/tuning/config_writer.py +0 -138
- {detectkit-0.46.0 → detectkit-0.48.0}/LICENSE +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/MANIFEST.in +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/README.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/core/models.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/requirements.txt +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/setup.cfg +0 -0
- {detectkit-0.46.0 → detectkit-0.48.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.48.0
|
|
4
4
|
Summary: Metric monitoring with automatic anomaly detection
|
|
5
5
|
Author: detectkit team
|
|
6
6
|
License: MIT
|
|
@@ -47,12 +47,15 @@ Requires-Dist: timesfm>=0.1.0; extra == "timesfm"
|
|
|
47
47
|
Provides-Extra: advanced-detectors
|
|
48
48
|
Requires-Dist: prophet>=1.1.0; extra == "advanced-detectors"
|
|
49
49
|
Requires-Dist: timesfm>=0.1.0; extra == "advanced-detectors"
|
|
50
|
+
Provides-Extra: osi
|
|
51
|
+
Requires-Dist: sqlglot>=20.0; extra == "osi"
|
|
50
52
|
Provides-Extra: all
|
|
51
53
|
Requires-Dist: clickhouse-driver>=0.2.0; extra == "all"
|
|
52
54
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
|
|
53
55
|
Requires-Dist: pymysql>=1.0.0; extra == "all"
|
|
54
56
|
Requires-Dist: prophet>=1.1.0; extra == "all"
|
|
55
57
|
Requires-Dist: timesfm>=0.1.0; extra == "all"
|
|
58
|
+
Requires-Dist: sqlglot>=20.0; extra == "all"
|
|
56
59
|
Provides-Extra: dev
|
|
57
60
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
58
61
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -61,6 +64,7 @@ Requires-Dist: requests-mock>=1.12; extra == "dev"
|
|
|
61
64
|
Requires-Dist: black>=23.0; extra == "dev"
|
|
62
65
|
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
63
66
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
67
|
+
Requires-Dist: sqlglot>=20.0; extra == "dev"
|
|
64
68
|
Provides-Extra: integration
|
|
65
69
|
Requires-Dist: testcontainers[clickhouse,mysql,postgres]>=4.0; extra == "integration"
|
|
66
70
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "integration"
|
|
@@ -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.
|
|
7
|
+
__version__ = "0.48.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -72,6 +72,13 @@ class AlertData:
|
|
|
72
72
|
error_message: str | None = None
|
|
73
73
|
description: str | None = None
|
|
74
74
|
mentions: list[str] = field(default_factory=list)
|
|
75
|
+
# OSI-compatible AI grounding: the metric's alternative names. Exposed to
|
|
76
|
+
# message templates as the OPT-IN ``{synonyms}`` / ``{synonyms_line}``
|
|
77
|
+
# variables (via :meth:`build_context`) so a custom template can surface an
|
|
78
|
+
# "Also known as: …" line — but the DEFAULT rendering deliberately does NOT
|
|
79
|
+
# show it, so existing alerts are byte-identical. Stamped by the orchestrator
|
|
80
|
+
# from ``MetricConfig.ai_context.synonyms``; defaults to empty.
|
|
81
|
+
ai_synonyms: list[str] = field(default_factory=list)
|
|
75
82
|
project_name: str | None = None
|
|
76
83
|
# Optional actionable links surfaced in the message. ``dashboard_url`` is the
|
|
77
84
|
# headline link (rendered natively as a clickable title/link on
|
|
@@ -416,6 +423,14 @@ class BaseAlertChannel(ABC):
|
|
|
416
423
|
# Format description line (empty string if no description)
|
|
417
424
|
description_line = f"{alert_data.description}\n" if alert_data.description else ""
|
|
418
425
|
|
|
426
|
+
# OSI ai_context synonyms — the metric's alternative names. Provided as
|
|
427
|
+
# OPT-IN template variables only (``{synonyms}`` / ``{synonyms_line}``);
|
|
428
|
+
# the default templates do NOT reference them, so a metric without
|
|
429
|
+
# ai_context — and every existing alert — renders unchanged. A custom
|
|
430
|
+
# template can drop ``{synonyms_line}`` in to show "Also known as: …".
|
|
431
|
+
synonyms = ", ".join(alert_data.ai_synonyms) if alert_data.ai_synonyms else ""
|
|
432
|
+
synonyms_line = f"Also known as: {synonyms}\n" if synonyms else ""
|
|
433
|
+
|
|
419
434
|
# Format mentions
|
|
420
435
|
mentions_str = self.format_mentions(alert_data.mentions)
|
|
421
436
|
mentions_line = f"\n{mentions_str}" if mentions_str else ""
|
|
@@ -482,6 +497,8 @@ class BaseAlertChannel(ABC):
|
|
|
482
497
|
"error_message": alert_data.error_message or "",
|
|
483
498
|
"description": alert_data.description or "",
|
|
484
499
|
"description_line": description_line,
|
|
500
|
+
"synonyms": synonyms,
|
|
501
|
+
"synonyms_line": synonyms_line,
|
|
485
502
|
"dashboard_url": dashboard_url,
|
|
486
503
|
"dashboard_line": dashboard_line,
|
|
487
504
|
"help_url": help_url,
|
|
@@ -36,6 +36,7 @@ class _OrchestratorBase:
|
|
|
36
36
|
links: dict[str, str] | None = None,
|
|
37
37
|
project_name: str | None = None,
|
|
38
38
|
help_url: str | None = None,
|
|
39
|
+
ai_synonyms: list[str] | None = None,
|
|
39
40
|
):
|
|
40
41
|
self.metric_name = metric_name
|
|
41
42
|
self.interval = interval
|
|
@@ -57,6 +58,10 @@ class _OrchestratorBase:
|
|
|
57
58
|
# defaulting to the official docs; None when opted out). Stamped onto every
|
|
58
59
|
# AlertData so channels render a guide link for non-operator stakeholders.
|
|
59
60
|
self.help_url = help_url
|
|
61
|
+
# OSI ai_context synonyms (the metric's alternative names). Stamped onto
|
|
62
|
+
# every AlertData so channels can render an "Also known as" identity line.
|
|
63
|
+
# Empty list when the metric has no ai_context — renders unchanged.
|
|
64
|
+
self.ai_synonyms = ai_synonyms or []
|
|
60
65
|
|
|
61
66
|
@staticmethod
|
|
62
67
|
def _group_by_timestamp(
|
|
@@ -280,6 +280,7 @@ class _DecisionMixin(_OrchestratorBase):
|
|
|
280
280
|
consecutive_count=consecutive_count,
|
|
281
281
|
description=self.description,
|
|
282
282
|
mentions=self.mentions,
|
|
283
|
+
ai_synonyms=self.ai_synonyms,
|
|
283
284
|
dashboard_url=self.dashboard_url,
|
|
284
285
|
links=self.links,
|
|
285
286
|
project_name=self.project_name,
|
|
@@ -345,6 +346,7 @@ class _DecisionMixin(_OrchestratorBase):
|
|
|
345
346
|
is_no_data=True,
|
|
346
347
|
description=self.description,
|
|
347
348
|
mentions=self.mentions,
|
|
349
|
+
ai_synonyms=self.ai_synonyms,
|
|
348
350
|
dashboard_url=self.dashboard_url,
|
|
349
351
|
links=self.links,
|
|
350
352
|
project_name=self.project_name,
|
|
@@ -188,6 +188,7 @@ class _RecoveryMixin(_OrchestratorBase):
|
|
|
188
188
|
is_recovery=True,
|
|
189
189
|
description=self.description,
|
|
190
190
|
mentions=self.mentions,
|
|
191
|
+
ai_synonyms=self.ai_synonyms,
|
|
191
192
|
dashboard_url=self.dashboard_url,
|
|
192
193
|
links=self.links,
|
|
193
194
|
project_name=self.project_name,
|
|
@@ -40,6 +40,7 @@ version — **read the relevant one on demand** instead of guessing:
|
|
|
40
40
|
| Alert rules (quorum/direction/consecutive), cooldown, recovery, templates | `.claude/rules/detectkit/alerting.md` |
|
|
41
41
|
| Auto-tuning a detector (`dtk autotune`), labels file, the `autotune:` block, `_dtk_autotune_runs` | `.claude/rules/detectkit/autotune.md` |
|
|
42
42
|
| Manually/interactively tuning a detector in the browser and writing it back in place (`dtk tune`) | `.claude/rules/detectkit/cli.md` |
|
|
43
|
+
| OSI (Open Semantic Interchange) interop: import an OSI metric into a native metric / export back (`dtk osi`) | `.claude/rules/detectkit/cli.md` |
|
|
43
44
|
|
|
44
45
|
### Skills
|
|
45
46
|
|
|
@@ -297,6 +297,7 @@ referenced by path). Key variables:
|
|
|
297
297
|
| Variable | Meaning |
|
|
298
298
|
|---|---|
|
|
299
299
|
| `{metric_name}`, `{description}` / `{description_line}` | identity |
|
|
300
|
+
| `{synonyms}` / `{synonyms_line}` | alt names from `ai_context.synonyms` (opt-in; not in default templates — add `{synonyms_line}` for an "Also known as: …" line) |
|
|
300
301
|
| `{project_name}` / `{project_name_prefix}` | project label (`"[name] "` prefix, or `""`) |
|
|
301
302
|
| `{timestamp}`, `{timezone}` | when (display tz via `alerting.timezone`, default UTC) |
|
|
302
303
|
| `{value}` / `{value_display}` | metric value (`value_display` is NaN-safe) |
|
|
@@ -15,6 +15,8 @@ Run all commands from a project directory (the one containing
|
|
|
15
15
|
| `dtk test-alert <metric>` | Send a mock alert to the metric's channels |
|
|
16
16
|
| `dtk unlock --select <sel>` | Clear a stuck pipeline lock |
|
|
17
17
|
| `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
|
|
18
|
+
| `dtk osi import <model>` | Scaffold a native metric from an OSI semantic-model metric (see "OSI interop") |
|
|
19
|
+
| `dtk osi export` | Publish metrics into an OSI fragment (config in `custom_extensions[detectkit]`) |
|
|
18
20
|
| `dtk --version` | Show installed detectkit version |
|
|
19
21
|
|
|
20
22
|
## Selectors (`--select` / `-s`)
|
|
@@ -95,7 +97,14 @@ anomalies**) and the **y = 0** toggle — stay visible in every mode. The chart
|
|
|
95
97
|
**zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
|
|
96
98
|
Clicking **Apply** writes the chosen
|
|
97
99
|
config back into the metric YAML **in place** (autotune, by contrast, writes a new
|
|
98
|
-
`__tuned_<id>.yml` and never edits the original).
|
|
100
|
+
`__tuned_<id>.yml` and never edits the original). Apply **merges** — it rewrites
|
|
101
|
+
only the detector(s) you tuned and keeps every **other** detector unchanged, so a
|
|
102
|
+
`manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
|
|
103
|
+
survives a retune. If a metric configures **more than one detector**, a **Tuning
|
|
104
|
+
detector** picker in the Tune rail lets you choose which one to tune (the cockpit
|
|
105
|
+
shows one band at a time); switching re-seeds every knob from that detector, and
|
|
106
|
+
non-tunable detectors (`prophet`/`timesfm`) plus the ones you didn't touch are
|
|
107
|
+
listed as "preserved on Apply". Reads the metric's loaded
|
|
99
108
|
datapoints (run `dtk run --steps load` first if empty); the selector must resolve
|
|
100
109
|
to a single metric.
|
|
101
110
|
|
|
@@ -153,8 +162,11 @@ relative to zero.
|
|
|
153
162
|
|
|
154
163
|
Safe write-back: the config is validated before anything is written, the previous
|
|
155
164
|
YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
|
|
156
|
-
overwritten
|
|
157
|
-
|
|
165
|
+
overwritten (the merge keeps your other detectors verbatim; the re-emitted header
|
|
166
|
+
names what was updated vs preserved). The `.history/` archive is **not** discovered
|
|
167
|
+
as a live metric, so a tuned metric never collides with its own snapshots as a
|
|
168
|
+
"duplicate metric name". Takes **no pipeline lock** (it only edits a config file);
|
|
169
|
+
re-run `dtk run` afterward to recompute detections under the new config.
|
|
158
170
|
`--no-serve` writes a static read-only preview HTML instead (no write-back —
|
|
159
171
|
**Save incidents** downloads the labels file); `--from` / `--to` bound the window;
|
|
160
172
|
`--no-open` prints the URL without opening a browser.
|
|
@@ -193,6 +205,44 @@ actually delete.
|
|
|
193
205
|
unless `-y/--yes`; refuses to run if the project defines no metrics or configs
|
|
194
206
|
fail to parse (so a wrong directory can't wipe valid data).
|
|
195
207
|
|
|
208
|
+
## OSI interop (`dtk osi`)
|
|
209
|
+
|
|
210
|
+
`dtk osi` converts between [Open Semantic Interchange](https://github.com/open-semantic-interchange/OSI)
|
|
211
|
+
(OSI) semantic models and native detectkit metrics. It's a **separate, additive**
|
|
212
|
+
layer: it never runs the pipeline, takes no lock, and the converter package
|
|
213
|
+
(`detectkit/semantic/`) is not imported by load/detect/alert — so it can't affect
|
|
214
|
+
a running project. OSI is treated as an *interchange* format (define the KPI once,
|
|
215
|
+
consume in BI + AI), not an execution engine. OSI adoption is still early, so the
|
|
216
|
+
broadly-useful piece today is `ai_context` (see the metrics rule) — the converters
|
|
217
|
+
are a forward bridge for teams that already run a governed semantic layer.
|
|
218
|
+
|
|
219
|
+
- `dtk osi import <model.osi.yml> --metric <name> --interval <grain>` — the
|
|
220
|
+
"enhanced init": resolve one OSI metric and **scaffold a normal native metric**
|
|
221
|
+
(SQL `query`, interval, a starter detector, the metric's `ai_context`). Review
|
|
222
|
+
it, then commit like any hand-written metric. Targets:
|
|
223
|
+
- `--target clickhouse` (default) — a direct `toStartOfInterval(...) GROUP BY`
|
|
224
|
+
query from the dataset's physical `source` (ANSI→ClickHouse via **sqlglot**,
|
|
225
|
+
the optional `[osi]` extra: `pip install 'detectkit[osi]'`).
|
|
226
|
+
- `--target cube --cube <name> --time-field <dim>` — a Cube **SQL-API**
|
|
227
|
+
`MEASURE(...)` query, so the metric runs through Cube and the alert matches a
|
|
228
|
+
Cube dashboard's number by construction. Point its `profile` at a Postgres
|
|
229
|
+
connection on Cube's SQL port.
|
|
230
|
+
- Only provably per-bucket-additive measures compile (`SUM`/`COUNT`/`COUNT(DISTINCT)`/
|
|
231
|
+
`AVG`/`MIN`/`MAX` + ratios like `SUM(x)/NULLIF(COUNT(DISTINCT y),0)`). Window
|
|
232
|
+
functions / non-aggregates / unknown aggregates are **refused** with a message
|
|
233
|
+
to use `query_file:` — never a plausible-but-wrong series.
|
|
234
|
+
- `dtk osi compile <model> --metric <name> --interval <g>` prints just the SQL
|
|
235
|
+
(for review). `dtk osi export [--select <sel>]` writes an OSI fragment carrying
|
|
236
|
+
a lossless snapshot of the config in `custom_extensions[detectkit]` + the
|
|
237
|
+
`ai_context` — a **one-way carrier** (`import` does not reconstruct from it; the
|
|
238
|
+
metric YAML stays the source of truth).
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
dtk osi compile model.osi.yml -m total_sales -i 1h # preview the SQL
|
|
242
|
+
dtk osi import model.osi.yml -m total_sales -i 1h -o metrics/ # scaffold a metric
|
|
243
|
+
dtk osi export -o semantic/detectkit.osi.yml # publish back to OSI
|
|
244
|
+
```
|
|
245
|
+
|
|
196
246
|
## Common workflows
|
|
197
247
|
|
|
198
248
|
```bash
|
|
@@ -13,6 +13,9 @@ description: API p95 latency # optional, shown in alerts
|
|
|
13
13
|
profile: prod # optional, overrides project default_profile
|
|
14
14
|
enabled: true # optional, false → skipped by `dtk run`
|
|
15
15
|
tags: [critical, api] # optional, used by `--select tag:<t>`
|
|
16
|
+
ai_context: # optional — OSI-compatible grounding (descriptive only)
|
|
17
|
+
instructions: "p95 API latency, ms; user-facing"
|
|
18
|
+
synonyms: ["api latency", "response time"]
|
|
16
19
|
|
|
17
20
|
interval: 5min # required — point spacing on the time grid
|
|
18
21
|
|
|
@@ -63,6 +66,19 @@ false_alert_budget: 0.3 # optional — `dtk tune` target false-alert rate
|
|
|
63
66
|
project-wide default; unset → project, then a built-in `0.5`. Tuning-only — it never
|
|
64
67
|
affects the load/detect/alert pipeline.
|
|
65
68
|
|
|
69
|
+
`ai_context` is **OSI-compatible grounding** you can add to any metric with **no
|
|
70
|
+
OSI model needed** — the metric's business meaning (`instructions`), alternative
|
|
71
|
+
names (`synonyms`) and example values (`examples`), mirroring the
|
|
72
|
+
[Open Semantic Interchange](https://github.com/open-semantic-interchange/OSI)
|
|
73
|
+
`ai_context` shape so a metric's meaning is portable to/from an OSI semantic model.
|
|
74
|
+
Accepts a bare string (→ `instructions`) or the full mapping. It is **purely
|
|
75
|
+
descriptive**: it never affects load/detect/alert or the detector id, and it does
|
|
76
|
+
**not** change any default-rendered alert. The `synonyms` are exposed to alert
|
|
77
|
+
templates as the **opt-in** `{synonyms}` / `{synonyms_line}` variables (a custom
|
|
78
|
+
`template` can add an "Also known as: …" line), and the whole block is carried in
|
|
79
|
+
the `dtk tune` cockpit payload as read-only grounding. Omit it and everything
|
|
80
|
+
renders as before.
|
|
81
|
+
|
|
66
82
|
## `interval` (required)
|
|
67
83
|
|
|
68
84
|
Point spacing. String (`"30s"`, `"1min"`, `"5min"`, `"10min"`, `"1hour"`,
|
|
@@ -146,9 +146,17 @@ false-alert rate exceeds it (tuning-only; labeling stays optional).
|
|
|
146
146
|
When the user is happy, they click **Apply to metric**. detectkit then, in order:
|
|
147
147
|
**validates** the config (a bad/untunable config is rejected and **nothing is
|
|
148
148
|
written**), **archives** the current YAML verbatim under
|
|
149
|
-
`metrics/.history/<metric>/`, and **re-emits** the metric in place
|
|
150
|
-
detector (and
|
|
151
|
-
one). Applying ends the session; saving incidents
|
|
149
|
+
`metrics/.history/<metric>/`, and **re-emits** the metric in place, **merging** the
|
|
150
|
+
tuned detector(s) back in (and updating the first alerting block's
|
|
151
|
+
`consecutive_anomalies` if it has one). Applying ends the session; saving incidents
|
|
152
|
+
does not. **Merge, not replace:** only the detector(s) the user tuned are
|
|
153
|
+
rewritten; every other detector is kept **verbatim** — so a metric with a
|
|
154
|
+
`manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
|
|
155
|
+
keeps both. When a metric has more than one detector, the Tune rail shows a
|
|
156
|
+
**Tuning detector** picker to choose which one to tune. If the user asks to tune a
|
|
157
|
+
metric that has several detectors, reassure them the others are preserved. (The
|
|
158
|
+
`.history/` archive is not discovered as a live metric, so it never causes a
|
|
159
|
+
"duplicate metric name" error.)
|
|
152
160
|
|
|
153
161
|
## Step 5 — Recompute under the new config
|
|
154
162
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""``dtk osi`` — Open Semantic Interchange interop (import / export / compile).
|
|
2
|
+
|
|
3
|
+
A self-contained, additive command group. It never runs the pipeline, takes no
|
|
4
|
+
lock, and touches no internal table:
|
|
5
|
+
|
|
6
|
+
- ``dtk osi import`` scaffolds a native detectkit metric from an OSI model metric
|
|
7
|
+
(review the output, then commit it like any hand-written metric).
|
|
8
|
+
- ``dtk osi export`` publishes detectkit metrics into an OSI fragment (a one-way
|
|
9
|
+
carrier: the full config rides in ``custom_extensions[detectkit]``; ``import``
|
|
10
|
+
does not reconstruct from it).
|
|
11
|
+
- ``dtk osi compile`` prints just the SQL an import would generate (for review).
|
|
12
|
+
|
|
13
|
+
Needs the optional ``[osi]`` extra (sqlglot) only for the ClickHouse target.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
def osi() -> None:
|
|
28
|
+
"""Open Semantic Interchange (OSI) interop: import / export / compile metrics."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _split_csv(value: str | None) -> list[str] | None:
|
|
32
|
+
if not value:
|
|
33
|
+
return None
|
|
34
|
+
items = [v.strip() for v in value.split(",") if v.strip()]
|
|
35
|
+
return items or None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dump_metric_yaml(body: dict[str, Any], header: str) -> str:
|
|
39
|
+
"""Render a metric body as YAML with a ``#`` provenance header (mirrors config_emitter)."""
|
|
40
|
+
text = yaml.safe_dump(body, sort_keys=False, allow_unicode=True, width=100)
|
|
41
|
+
return header + text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _import_header(result: Any, model_file: Path) -> str:
|
|
45
|
+
lines = [
|
|
46
|
+
"# Generated by `dtk osi import` — review before committing.",
|
|
47
|
+
f"# Source OSI model: {model_file}",
|
|
48
|
+
f"# Metric: {result.metric_name} · target: {result.target} · "
|
|
49
|
+
f"sql-fingerprint: {result.fingerprint}",
|
|
50
|
+
"# detectkit resumes from the last datapoint, so if you re-import and this",
|
|
51
|
+
"# fingerprint changes, backfill/clean to avoid mixing definitions.",
|
|
52
|
+
]
|
|
53
|
+
for w in result.warnings:
|
|
54
|
+
lines.append(f"# NOTE: {w}")
|
|
55
|
+
return "\n".join(lines) + "\n"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@osi.command(name="import")
|
|
59
|
+
@click.argument("model_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
60
|
+
@click.option("--metric", "-m", required=True, help="OSI metric name to import")
|
|
61
|
+
@click.option("--interval", "-i", required=True, help="Series grain (e.g. 1h, 10min, 3600)")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--target",
|
|
64
|
+
type=click.Choice(["clickhouse", "cube"]),
|
|
65
|
+
default="clickhouse",
|
|
66
|
+
show_default=True,
|
|
67
|
+
help="clickhouse = direct SQL from dataset.source; cube = Cube SQL-API (MEASURE)",
|
|
68
|
+
)
|
|
69
|
+
@click.option("--dataset", help="OSI dataset to build FROM (default: the model's only dataset)")
|
|
70
|
+
@click.option("--time-field", help="Time grain column / dimension (default: a field with is_time)")
|
|
71
|
+
@click.option("--where", help="Extra SQL predicate (ANSI) added to the WHERE clause")
|
|
72
|
+
@click.option("--cube", help="Cube name (cube target)")
|
|
73
|
+
@click.option("--cube-measure", help="Cube measure name (cube target; default: the metric name)")
|
|
74
|
+
@click.option("--time-dimension", help="Cube time dimension (cube target)")
|
|
75
|
+
@click.option("--seasonality", help="Comma-separated time-derived seasonality columns")
|
|
76
|
+
@click.option("--detector", default="mad", show_default=True, help="Starter detector type")
|
|
77
|
+
@click.option(
|
|
78
|
+
"--out",
|
|
79
|
+
"-o",
|
|
80
|
+
type=click.Path(path_type=Path),
|
|
81
|
+
help="Write the metric YAML here (a dir → <name>.yml). Default: print to stdout.",
|
|
82
|
+
)
|
|
83
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing --out file")
|
|
84
|
+
def import_(
|
|
85
|
+
model_file: Path,
|
|
86
|
+
metric: str,
|
|
87
|
+
interval: str,
|
|
88
|
+
target: str,
|
|
89
|
+
dataset: str | None,
|
|
90
|
+
time_field: str | None,
|
|
91
|
+
where: str | None,
|
|
92
|
+
cube: str | None,
|
|
93
|
+
cube_measure: str | None,
|
|
94
|
+
time_dimension: str | None,
|
|
95
|
+
seasonality: str | None,
|
|
96
|
+
detector: str,
|
|
97
|
+
out: Path | None,
|
|
98
|
+
force: bool,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Scaffold a native detectkit metric from an OSI MODEL_FILE metric."""
|
|
101
|
+
from detectkit.semantic import OsiError, import_osi_metric, load_osi_models
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
models = load_osi_models(model_file)
|
|
105
|
+
result = import_osi_metric(
|
|
106
|
+
models=models,
|
|
107
|
+
metric_name=metric,
|
|
108
|
+
interval=interval,
|
|
109
|
+
target=target,
|
|
110
|
+
dataset=dataset,
|
|
111
|
+
time_field=time_field,
|
|
112
|
+
where=where,
|
|
113
|
+
cube=cube,
|
|
114
|
+
cube_measure=cube_measure,
|
|
115
|
+
time_dimension=time_dimension,
|
|
116
|
+
seasonality=_split_csv(seasonality),
|
|
117
|
+
detector_type=detector,
|
|
118
|
+
)
|
|
119
|
+
except OsiError as exc:
|
|
120
|
+
raise click.ClickException(str(exc)) from exc
|
|
121
|
+
|
|
122
|
+
rendered = _dump_metric_yaml(result.metric, _import_header(result, model_file))
|
|
123
|
+
|
|
124
|
+
if out is None:
|
|
125
|
+
click.echo(rendered)
|
|
126
|
+
click.echo("# (printed only — pass --out to write a metric file)", err=True)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
dest = out / f"{result.metric_name}.yml" if out.is_dir() else out
|
|
130
|
+
if dest.exists() and not force:
|
|
131
|
+
raise click.ClickException(f"{dest} exists — pass --force to overwrite")
|
|
132
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
dest.write_text(rendered)
|
|
134
|
+
click.echo(f"Wrote {dest}")
|
|
135
|
+
for w in result.warnings:
|
|
136
|
+
click.echo(f" note: {w}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@osi.command()
|
|
140
|
+
@click.argument("model_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
141
|
+
@click.option("--metric", "-m", required=True, help="OSI metric name")
|
|
142
|
+
@click.option("--interval", "-i", required=True, help="Series grain (e.g. 1h, 10min, 3600)")
|
|
143
|
+
@click.option("--target", type=click.Choice(["clickhouse", "cube"]), default="clickhouse")
|
|
144
|
+
@click.option("--dataset", help="OSI dataset to build FROM")
|
|
145
|
+
@click.option("--time-field", help="Time grain column / dimension")
|
|
146
|
+
@click.option("--where", help="Extra SQL predicate (ANSI)")
|
|
147
|
+
@click.option("--cube", help="Cube name (cube target)")
|
|
148
|
+
@click.option("--cube-measure", help="Cube measure name (cube target)")
|
|
149
|
+
@click.option("--time-dimension", help="Cube time dimension (cube target)")
|
|
150
|
+
def compile( # noqa: A001 - mirrors the `dtk osi compile` verb
|
|
151
|
+
model_file: Path,
|
|
152
|
+
metric: str,
|
|
153
|
+
interval: str,
|
|
154
|
+
target: str,
|
|
155
|
+
dataset: str | None,
|
|
156
|
+
time_field: str | None,
|
|
157
|
+
where: str | None,
|
|
158
|
+
cube: str | None,
|
|
159
|
+
cube_measure: str | None,
|
|
160
|
+
time_dimension: str | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Print ONLY the SQL an import would generate (no file written)."""
|
|
163
|
+
from detectkit.semantic import OsiError, import_osi_metric, load_osi_models
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
models = load_osi_models(model_file)
|
|
167
|
+
result = import_osi_metric(
|
|
168
|
+
models=models,
|
|
169
|
+
metric_name=metric,
|
|
170
|
+
interval=interval,
|
|
171
|
+
target=target,
|
|
172
|
+
dataset=dataset,
|
|
173
|
+
time_field=time_field,
|
|
174
|
+
where=where,
|
|
175
|
+
cube=cube,
|
|
176
|
+
cube_measure=cube_measure,
|
|
177
|
+
time_dimension=time_dimension,
|
|
178
|
+
)
|
|
179
|
+
except OsiError as exc:
|
|
180
|
+
raise click.ClickException(str(exc)) from exc
|
|
181
|
+
click.echo(result.sql)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@osi.command()
|
|
185
|
+
@click.option(
|
|
186
|
+
"--select", "-s", "selector", help="Metric selector (name / path / tag:<t>); default: all"
|
|
187
|
+
)
|
|
188
|
+
@click.option("--model-name", default="detectkit_export", show_default=True, help="OSI model name")
|
|
189
|
+
@click.option(
|
|
190
|
+
"--out",
|
|
191
|
+
"-o",
|
|
192
|
+
type=click.Path(path_type=Path),
|
|
193
|
+
help="Write the OSI YAML here. Default: print to stdout.",
|
|
194
|
+
)
|
|
195
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing --out file")
|
|
196
|
+
def export(selector: str | None, model_name: str, out: Path | None, force: bool) -> None:
|
|
197
|
+
"""Export native detectkit metrics into an OSI fragment (config in custom_extensions)."""
|
|
198
|
+
from detectkit.cli.commands.run import find_project_root, select_metrics
|
|
199
|
+
from detectkit.config.validator import validate_project_metrics
|
|
200
|
+
from detectkit.semantic import export_models
|
|
201
|
+
|
|
202
|
+
root = find_project_root()
|
|
203
|
+
if root is None:
|
|
204
|
+
raise click.ClickException(
|
|
205
|
+
"not inside a detectkit project (no detectkit_project.yml found)"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
pairs = select_metrics(selector, root) if selector else validate_project_metrics(root)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
raise click.ClickException(str(exc)) from exc
|
|
212
|
+
configs = [cfg for _, cfg in pairs]
|
|
213
|
+
if not configs:
|
|
214
|
+
raise click.ClickException("no metrics selected")
|
|
215
|
+
|
|
216
|
+
doc = export_models(configs, model_name=model_name)
|
|
217
|
+
rendered = yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100)
|
|
218
|
+
header = (
|
|
219
|
+
f"# Generated by `dtk osi export` — {len(configs)} metric(s).\n"
|
|
220
|
+
"# The portable measure is a placeholder; the exact detectkit definition\n"
|
|
221
|
+
"# (detectors, alerting, query) rides in custom_extensions[detectkit].\n"
|
|
222
|
+
"# One-way carrier: `dtk osi import` does not reconstruct from this block —\n"
|
|
223
|
+
"# keep your metric YAML as the source of truth.\n"
|
|
224
|
+
)
|
|
225
|
+
rendered = header + rendered
|
|
226
|
+
|
|
227
|
+
if out is None:
|
|
228
|
+
click.echo(rendered)
|
|
229
|
+
return
|
|
230
|
+
if out.exists() and not force:
|
|
231
|
+
raise click.ClickException(f"{out} exists — pass --force to overwrite")
|
|
232
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
out.write_text(rendered)
|
|
234
|
+
click.echo(f"Wrote {out} ({len(configs)} metric(s))", err=False)
|
|
235
|
+
click.echo(
|
|
236
|
+
" note: one-way carrier — `dtk osi import` does not reconstruct from "
|
|
237
|
+
"custom_extensions[detectkit]; keep your metric YAML as the source of truth.",
|
|
238
|
+
err=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def run_osi() -> None:
|
|
243
|
+
"""Entry shim (kept symmetric with other commands; the group is wired in main)."""
|
|
244
|
+
osi(standalone_mode=True) # pragma: no cover
|
|
245
|
+
sys.exit(0) # pragma: no cover
|
|
@@ -12,7 +12,11 @@ import click
|
|
|
12
12
|
from detectkit.config.metric_config import MetricConfig
|
|
13
13
|
from detectkit.config.profile import ProfilesConfig
|
|
14
14
|
from detectkit.config.project_config import ProjectConfig
|
|
15
|
-
from detectkit.config.validator import
|
|
15
|
+
from detectkit.config.validator import (
|
|
16
|
+
discover_metric_files,
|
|
17
|
+
is_discoverable_metric_file,
|
|
18
|
+
validate_metric_uniqueness,
|
|
19
|
+
)
|
|
16
20
|
from detectkit.database.internal_tables import InternalTablesManager
|
|
17
21
|
from detectkit.orchestration.error_dispatch import dispatch_project_error_alert
|
|
18
22
|
from detectkit.orchestration.task_manager import PipelineStep, TaskManager
|
|
@@ -433,17 +437,16 @@ def select_metrics(selector: str, project_root: Path) -> list[tuple[Path, Metric
|
|
|
433
437
|
if selector == "*":
|
|
434
438
|
# "all metrics" — search recursively so nested metrics are included
|
|
435
439
|
# (mirrors validate_project_metrics); a plain glob of "metrics/*"
|
|
436
|
-
# would only see the top level.
|
|
437
|
-
metric_paths =
|
|
440
|
+
# would only see the top level. Excludes the metrics/.history/ archive.
|
|
441
|
+
metric_paths = discover_metric_files(metrics_dir)
|
|
438
442
|
else:
|
|
439
443
|
pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
|
|
440
444
|
# Keep only metric files: a bare glob also matches the `.gitkeep`
|
|
441
|
-
# stub created by `dtk init`, any other non-YAML files, and
|
|
442
|
-
#
|
|
445
|
+
# stub created by `dtk init`, any other non-YAML files, directories, and
|
|
446
|
+
# the metrics/.history/ archive dtk tune writes (whose snapshots keep the
|
|
447
|
+
# original name:) — all of which would crash the parser or collide on name.
|
|
443
448
|
metric_paths = [
|
|
444
|
-
p
|
|
445
|
-
for p in project_root.glob(pattern)
|
|
446
|
-
if p.is_file() and p.suffix in (".yml", ".yaml")
|
|
449
|
+
p for p in project_root.glob(pattern) if is_discoverable_metric_file(p, metrics_dir)
|
|
447
450
|
]
|
|
448
451
|
# Metric name selector
|
|
449
452
|
else:
|
|
@@ -485,26 +488,25 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> list[Path]:
|
|
|
485
488
|
|
|
486
489
|
matching_metrics = []
|
|
487
490
|
|
|
488
|
-
#
|
|
489
|
-
for
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
click.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
continue
|
|
491
|
+
# Live metric files only (excludes the metrics/.history/ archive).
|
|
492
|
+
for metric_file in discover_metric_files(metrics_dir):
|
|
493
|
+
try:
|
|
494
|
+
with open(metric_file) as f:
|
|
495
|
+
config = yaml.safe_load(f)
|
|
496
|
+
|
|
497
|
+
if config and "tags" in config:
|
|
498
|
+
if tag in config["tags"]:
|
|
499
|
+
matching_metrics.append(metric_file)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
# Warn about unparseable files but continue searching
|
|
502
|
+
click.echo(
|
|
503
|
+
click.style(
|
|
504
|
+
f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
|
|
505
|
+
fg="yellow",
|
|
506
|
+
),
|
|
507
|
+
err=True,
|
|
508
|
+
)
|
|
509
|
+
continue
|
|
508
510
|
|
|
509
511
|
return matching_metrics
|
|
510
512
|
|
|
@@ -522,25 +524,24 @@ def find_metric_by_name(metrics_dir: Path, name: str) -> Path | None:
|
|
|
522
524
|
"""
|
|
523
525
|
import yaml
|
|
524
526
|
|
|
525
|
-
#
|
|
526
|
-
for
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
click.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
continue
|
|
527
|
+
# Live metric files only (excludes the metrics/.history/ archive).
|
|
528
|
+
for metric_file in discover_metric_files(metrics_dir):
|
|
529
|
+
try:
|
|
530
|
+
with open(metric_file) as f:
|
|
531
|
+
config = yaml.safe_load(f)
|
|
532
|
+
|
|
533
|
+
if config and config.get("name") == name:
|
|
534
|
+
return metric_file
|
|
535
|
+
except Exception as e:
|
|
536
|
+
# Warn about unparseable files but continue searching
|
|
537
|
+
click.echo(
|
|
538
|
+
click.style(
|
|
539
|
+
f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
|
|
540
|
+
fg="yellow",
|
|
541
|
+
),
|
|
542
|
+
err=True,
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
544
545
|
|
|
545
546
|
return None
|
|
546
547
|
|