detectkit 0.57.0__tar.gz → 0.58.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.57.0/detectkit.egg-info → detectkit-0.58.0}/PKG-INFO +3 -1
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/__init__.py +1 -1
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/webhook.py +273 -7
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -1
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/cli.md +28 -1
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/project.md +23 -3
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +15 -11
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/autotune.py +42 -19
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/clean.py +27 -16
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/init.py +11 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/run.py +235 -18
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/main.py +18 -4
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/profile.py +13 -11
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/mysql_manager.py +47 -2
- {detectkit-0.57.0 → detectkit-0.58.0/detectkit.egg-info}/PKG-INFO +3 -1
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/requires.txt +3 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/pyproject.toml +3 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/LICENSE +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/MANIFEST.in +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/README.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/axis_spec.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/metric_io.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/models.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/autoreg.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/assets/tune.js +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/payload.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/assets/ui.js +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/metric_files.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/overview.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/server.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/requirements.txt +0 -0
- {detectkit-0.57.0 → detectkit-0.58.0}/setup.cfg +0 -0
- {detectkit-0.57.0 → detectkit-0.58.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.58.0
|
|
4
4
|
Summary: Metric monitoring with automatic anomaly detection
|
|
5
5
|
Author: detectkit team
|
|
6
6
|
License: MIT
|
|
@@ -36,6 +36,8 @@ Provides-Extra: postgres
|
|
|
36
36
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
|
|
37
37
|
Provides-Extra: mysql
|
|
38
38
|
Requires-Dist: pymysql>=1.0.0; extra == "mysql"
|
|
39
|
+
Provides-Extra: mariadb
|
|
40
|
+
Requires-Dist: pymysql>=1.0.0; extra == "mariadb"
|
|
39
41
|
Provides-Extra: all-db
|
|
40
42
|
Requires-Dist: clickhouse-driver>=0.2.0; extra == "all-db"
|
|
41
43
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "all-db"
|
|
@@ -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.58.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -5,6 +5,10 @@ Sends alerts to any webhook endpoint that accepts JSON payload.
|
|
|
5
5
|
Compatible with Mattermost, Slack, and other webhook-based systems.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
8
12
|
from datetime import datetime, timezone
|
|
9
13
|
from typing import Any
|
|
10
14
|
|
|
@@ -83,6 +87,17 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
83
87
|
channel (str): Target channel (optional, for Slack/Mattermost)
|
|
84
88
|
timeout (int): Request timeout in seconds (default: 10)
|
|
85
89
|
extra_headers (dict): Additional HTTP headers (optional)
|
|
90
|
+
format (str): Payload shape — "attachments" (default; today's
|
|
91
|
+
byte-identical Slack/Mattermost card), "json" (a structured event
|
|
92
|
+
payload, ``schema_version`` 1) or "alertmanager" (a Prometheus
|
|
93
|
+
Alertmanager webhook-receiver payload, version "4"). Any other
|
|
94
|
+
value raises ``ValueError`` at construction. A custom ``template``
|
|
95
|
+
passed to :meth:`send` is ignored for "json"/"alertmanager" —
|
|
96
|
+
those are structured formats with no free-text body.
|
|
97
|
+
secret (str): When set, every outgoing request (any format) is
|
|
98
|
+
HMAC-SHA256 signed GitHub-webhook style: header
|
|
99
|
+
``X-Detectkit-Signature-256: sha256=<hexdigest>`` computed over
|
|
100
|
+
the exact request body bytes with *secret* as the key.
|
|
86
101
|
|
|
87
102
|
Example:
|
|
88
103
|
>>> # Mattermost
|
|
@@ -103,6 +118,8 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
103
118
|
... )
|
|
104
119
|
"""
|
|
105
120
|
|
|
121
|
+
_FORMATS = ("attachments", "json", "alertmanager")
|
|
122
|
+
|
|
106
123
|
def __init__(
|
|
107
124
|
self,
|
|
108
125
|
webhook_url: str,
|
|
@@ -112,10 +129,15 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
112
129
|
channel: str | None = None,
|
|
113
130
|
timeout: int = 10,
|
|
114
131
|
extra_headers: dict[str, str] | None = None,
|
|
132
|
+
format: str = "attachments",
|
|
133
|
+
secret: str | None = None,
|
|
115
134
|
):
|
|
116
135
|
"""Initialize webhook channel."""
|
|
117
136
|
if not webhook_url:
|
|
118
137
|
raise ValueError("webhook_url is required")
|
|
138
|
+
if format not in self._FORMATS:
|
|
139
|
+
allowed = ", ".join(self._FORMATS)
|
|
140
|
+
raise ValueError(f"Unknown webhook format: '{format}'. Allowed: {allowed}")
|
|
119
141
|
|
|
120
142
|
self.webhook_url = webhook_url
|
|
121
143
|
self.username = username
|
|
@@ -129,6 +151,8 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
129
151
|
self.channel = channel
|
|
130
152
|
self.timeout = timeout
|
|
131
153
|
self.extra_headers = extra_headers or {}
|
|
154
|
+
self.format = format
|
|
155
|
+
self.secret = secret
|
|
132
156
|
|
|
133
157
|
def send(
|
|
134
158
|
self,
|
|
@@ -140,10 +164,12 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
140
164
|
|
|
141
165
|
Args:
|
|
142
166
|
alert_data: Alert data to send
|
|
143
|
-
template: Optional custom message template.
|
|
144
|
-
|
|
145
|
-
text (in place of the
|
|
146
|
-
otherwise the rich
|
|
167
|
+
template: Optional custom message template. Applies only to the
|
|
168
|
+
default "attachments" format, where the attachment carries the
|
|
169
|
+
formatted template verbatim as the body text (in place of the
|
|
170
|
+
structured lead/Value/tail sections); otherwise the rich
|
|
171
|
+
default is built. Ignored for "json"/"alertmanager" — those
|
|
172
|
+
are structured formats with no free-text body.
|
|
147
173
|
|
|
148
174
|
Returns:
|
|
149
175
|
True if sent successfully, False otherwise
|
|
@@ -155,17 +181,31 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
155
181
|
>>> channel = WebhookChannel(webhook_url="https://...")
|
|
156
182
|
>>> success = channel.send(alert_data)
|
|
157
183
|
"""
|
|
158
|
-
|
|
184
|
+
if self.format == "json":
|
|
185
|
+
payload = self.build_event_payload(alert_data)
|
|
186
|
+
elif self.format == "alertmanager":
|
|
187
|
+
payload = self.build_alertmanager_payload(alert_data)
|
|
188
|
+
else:
|
|
189
|
+
payload = self.build_payload(alert_data, template)
|
|
159
190
|
|
|
160
|
-
#
|
|
191
|
+
# Serialize ourselves (rather than letting ``requests`` do it via
|
|
192
|
+
# ``json=``) so the HMAC signature covers the exact bytes sent.
|
|
193
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
161
194
|
headers = {"Content-Type": "application/json"}
|
|
162
195
|
headers.update(self.extra_headers)
|
|
196
|
+
if self.format in ("json", "alertmanager"):
|
|
197
|
+
headers["X-Detectkit-Event"] = self.status_kind(alert_data)
|
|
198
|
+
if self.secret:
|
|
199
|
+
# GitHub-webhook style signature, computed AFTER extra_headers so
|
|
200
|
+
# a misconfigured extra header can't clobber it.
|
|
201
|
+
signature = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
202
|
+
headers["X-Detectkit-Signature-256"] = f"sha256={signature}"
|
|
163
203
|
|
|
164
204
|
# Send to webhook
|
|
165
205
|
try:
|
|
166
206
|
response = requests.post(
|
|
167
207
|
self.webhook_url,
|
|
168
|
-
|
|
208
|
+
data=body,
|
|
169
209
|
headers=headers,
|
|
170
210
|
timeout=self.timeout,
|
|
171
211
|
)
|
|
@@ -405,6 +445,232 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
405
445
|
}
|
|
406
446
|
]
|
|
407
447
|
|
|
448
|
+
def _display_lead(self, alert_data: AlertData, ctx: dict[str, Any], kind: str) -> str:
|
|
449
|
+
"""The plain-language lead sentence shared by both structured formats.
|
|
450
|
+
|
|
451
|
+
Anomaly/recovery reuse the same ready-made sentence the attachment
|
|
452
|
+
body leads with; no-data/error get a fixed one-liner (they have no
|
|
453
|
+
streak/duration story).
|
|
454
|
+
"""
|
|
455
|
+
if kind == "anomaly":
|
|
456
|
+
return str(ctx["anomaly_lead"])
|
|
457
|
+
if kind == "recovery":
|
|
458
|
+
return str(ctx["recovery_lead"])
|
|
459
|
+
if kind == "no_data":
|
|
460
|
+
return "Query returned no datapoint for the latest expected interval."
|
|
461
|
+
return "The detectkit pipeline failed for this metric."
|
|
462
|
+
|
|
463
|
+
def build_event_payload(self, alert_data: AlertData) -> dict[str, Any]:
|
|
464
|
+
"""Structured JSON event payload (``format="json"``).
|
|
465
|
+
|
|
466
|
+
``schema_version`` 1 — **frozen**: this shape is a public contract for
|
|
467
|
+
downstream consumers (custom receivers, log pipelines), so existing
|
|
468
|
+
keys must not change meaning or disappear; only additive changes are
|
|
469
|
+
safe without bumping the version. Built from ``alert_data`` plus
|
|
470
|
+
:meth:`build_context` (the same resolved values every other rendering
|
|
471
|
+
surface — the attachment, Telegram, email — uses), so this event and
|
|
472
|
+
the human-readable message never disagree.
|
|
473
|
+
"""
|
|
474
|
+
ctx = self.build_context(alert_data)
|
|
475
|
+
kind = self.status_kind(alert_data)
|
|
476
|
+
streak = alert_data.consecutive_count
|
|
477
|
+
interval_seconds = alert_data.interval_seconds
|
|
478
|
+
duration_seconds = streak * interval_seconds if interval_seconds is not None else None
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"schema_version": 1,
|
|
482
|
+
"source": "detectkit",
|
|
483
|
+
"kind": kind,
|
|
484
|
+
"status": "resolved" if alert_data.is_recovery else "firing",
|
|
485
|
+
"project": alert_data.project_name,
|
|
486
|
+
"metric": alert_data.metric_name,
|
|
487
|
+
"description": alert_data.description,
|
|
488
|
+
"timestamp": self._iso_utc(alert_data.timestamp),
|
|
489
|
+
"value": self._clean_float(alert_data.value),
|
|
490
|
+
"expected": {
|
|
491
|
+
"lower": self._bounded_value(alert_data.confidence_lower),
|
|
492
|
+
"upper": self._bounded_value(alert_data.confidence_upper),
|
|
493
|
+
},
|
|
494
|
+
"severity": self._clean_float(alert_data.severity),
|
|
495
|
+
"direction": alert_data.direction,
|
|
496
|
+
"detector": {
|
|
497
|
+
"name": alert_data.detector_name,
|
|
498
|
+
"params": self._parse_detector_params(alert_data.detector_params),
|
|
499
|
+
},
|
|
500
|
+
"rule": {
|
|
501
|
+
"min_detectors": ctx["min_detectors"],
|
|
502
|
+
"direction": ctx["direction_policy"],
|
|
503
|
+
"consecutive": ctx["consecutive_required"],
|
|
504
|
+
"window_points": alert_data.window_points,
|
|
505
|
+
"min_anomaly_share": alert_data.min_anomaly_share,
|
|
506
|
+
"fired_by_share": alert_data.fired_by_share,
|
|
507
|
+
"display": ctx["rule_display"],
|
|
508
|
+
},
|
|
509
|
+
"quorum": {
|
|
510
|
+
"detector_count": alert_data.detector_count,
|
|
511
|
+
"min_detectors": ctx["min_detectors"],
|
|
512
|
+
},
|
|
513
|
+
"incident": {
|
|
514
|
+
"onset": self._iso_utc(alert_data.onset_timestamp),
|
|
515
|
+
"streak": streak,
|
|
516
|
+
"capped": alert_data.streak_capped,
|
|
517
|
+
"interval_seconds": interval_seconds,
|
|
518
|
+
"duration_seconds": duration_seconds,
|
|
519
|
+
},
|
|
520
|
+
"links": {
|
|
521
|
+
"dashboard": alert_data.dashboard_url,
|
|
522
|
+
"help": ctx["help_url"] or None,
|
|
523
|
+
"extra": alert_data.links,
|
|
524
|
+
},
|
|
525
|
+
"mentions": alert_data.mentions,
|
|
526
|
+
"synonyms": alert_data.ai_synonyms,
|
|
527
|
+
"error": (
|
|
528
|
+
{"type": alert_data.error_type, "message": alert_data.error_message}
|
|
529
|
+
if kind == "error"
|
|
530
|
+
else None
|
|
531
|
+
),
|
|
532
|
+
"display": {
|
|
533
|
+
"title": self.format_title(alert_data),
|
|
534
|
+
"lead": self._display_lead(alert_data, ctx, kind),
|
|
535
|
+
"value": ctx["value_display"],
|
|
536
|
+
"expected_range": ctx["expected_range"],
|
|
537
|
+
"timestamp": ctx["timestamp"],
|
|
538
|
+
},
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
def build_alertmanager_payload(self, alert_data: AlertData) -> dict[str, Any]:
|
|
542
|
+
"""Prometheus Alertmanager webhook-receiver payload (``format="alertmanager"``).
|
|
543
|
+
|
|
544
|
+
Version "4" — **frozen** shape, same contract rationale as
|
|
545
|
+
:meth:`build_event_payload`. A recovery deliberately carries the
|
|
546
|
+
**same labels** as the anomaly it resolves (``kind`` maps back to
|
|
547
|
+
``"anomaly"``) so Alertmanager-style consumers pair the two via
|
|
548
|
+
``fingerprint`` — only ``status``/``endsAt`` differ.
|
|
549
|
+
"""
|
|
550
|
+
ctx = self.build_context(alert_data)
|
|
551
|
+
kind = self.status_kind(alert_data)
|
|
552
|
+
is_recovery = alert_data.is_recovery
|
|
553
|
+
|
|
554
|
+
labels_kind = "anomaly" if is_recovery else kind
|
|
555
|
+
labels: dict[str, str] = {
|
|
556
|
+
"alertname": alert_data.metric_name,
|
|
557
|
+
"metric": alert_data.metric_name,
|
|
558
|
+
"kind": labels_kind,
|
|
559
|
+
"source": "detectkit",
|
|
560
|
+
"severity": "warning" if labels_kind == "no_data" else "critical",
|
|
561
|
+
}
|
|
562
|
+
if alert_data.project_name:
|
|
563
|
+
labels["project"] = alert_data.project_name
|
|
564
|
+
|
|
565
|
+
lead = self._display_lead(alert_data, ctx, kind)
|
|
566
|
+
annotations: dict[str, str] = {
|
|
567
|
+
"summary": self.format_title(alert_data),
|
|
568
|
+
"description": lead,
|
|
569
|
+
}
|
|
570
|
+
if kind in ("anomaly", "recovery"):
|
|
571
|
+
annotations["value"] = ctx["value_display"]
|
|
572
|
+
annotations["expected"] = ctx["expected_range"]
|
|
573
|
+
# Direction rides as an ANNOTATION, never a label: the orchestrator
|
|
574
|
+
# stamps recovery AlertData with direction="none", so a direction label
|
|
575
|
+
# would change the label set — and the fingerprint — between a firing
|
|
576
|
+
# alert and its recovery, breaking trigger/resolve pairing.
|
|
577
|
+
if kind == "anomaly" and alert_data.direction and alert_data.direction != "none":
|
|
578
|
+
annotations["direction"] = alert_data.direction
|
|
579
|
+
|
|
580
|
+
fingerprint = self._label_fingerprint(labels)
|
|
581
|
+
onset = (
|
|
582
|
+
alert_data.onset_timestamp
|
|
583
|
+
if alert_data.onset_timestamp is not None
|
|
584
|
+
else alert_data.timestamp
|
|
585
|
+
)
|
|
586
|
+
status = "resolved" if is_recovery else "firing"
|
|
587
|
+
|
|
588
|
+
alert = {
|
|
589
|
+
"status": status,
|
|
590
|
+
"labels": labels,
|
|
591
|
+
"annotations": annotations,
|
|
592
|
+
"startsAt": self._iso_utc(onset),
|
|
593
|
+
"endsAt": (
|
|
594
|
+
self._iso_utc(alert_data.timestamp) if is_recovery else "0001-01-01T00:00:00Z"
|
|
595
|
+
),
|
|
596
|
+
"generatorURL": alert_data.dashboard_url or "",
|
|
597
|
+
"fingerprint": fingerprint,
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
"version": "4",
|
|
602
|
+
"groupKey": f"detectkit/{alert_data.project_name or 'default'}/{alert_data.metric_name}",
|
|
603
|
+
"truncatedAlerts": 0,
|
|
604
|
+
"status": status,
|
|
605
|
+
"receiver": "detectkit",
|
|
606
|
+
"groupLabels": {"alertname": alert_data.metric_name},
|
|
607
|
+
"commonLabels": labels,
|
|
608
|
+
"commonAnnotations": annotations,
|
|
609
|
+
"externalURL": "",
|
|
610
|
+
"alerts": [alert],
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
@staticmethod
|
|
614
|
+
def _label_fingerprint(labels: dict[str, str]) -> str:
|
|
615
|
+
"""Stable short hash of *labels*, sorted so key order never matters."""
|
|
616
|
+
label_str = "\n".join(f"{k}={v}" for k, v in sorted(labels.items()))
|
|
617
|
+
return hashlib.sha256(label_str.encode("utf-8")).hexdigest()[:16]
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _clean_float(value: Any) -> float | None:
|
|
621
|
+
"""``float(value)``, or ``None`` for a missing/NaN value."""
|
|
622
|
+
if value is None:
|
|
623
|
+
return None
|
|
624
|
+
if isinstance(value, float) and math.isnan(value):
|
|
625
|
+
return None
|
|
626
|
+
return float(value)
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _bounded_value(bound: Any) -> float | None:
|
|
630
|
+
"""A confidence bound as JSON, or ``None`` when unset/NaN/inf.
|
|
631
|
+
|
|
632
|
+
Mirrors ``BaseAlertChannel.build_context``'s ``_bounded`` check (a
|
|
633
|
+
NaN/inf bound means "no bound on that side", e.g. a one-sided
|
|
634
|
+
ManualBounds detector).
|
|
635
|
+
"""
|
|
636
|
+
if bound is None or (isinstance(bound, float) and (math.isnan(bound) or math.isinf(bound))):
|
|
637
|
+
return None
|
|
638
|
+
return float(bound)
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _parse_detector_params(raw: str) -> Any:
|
|
642
|
+
"""Detector params as parsed JSON when possible, else the raw string.
|
|
643
|
+
|
|
644
|
+
``detector_params`` is stored as a JSON string; consumers of the
|
|
645
|
+
structured formats want it as real JSON, not a string-within-a-string
|
|
646
|
+
— but a non-JSON/non-container value (or an empty string) is passed
|
|
647
|
+
through rather than dropped.
|
|
648
|
+
"""
|
|
649
|
+
if not raw:
|
|
650
|
+
return None
|
|
651
|
+
try:
|
|
652
|
+
parsed = json.loads(raw)
|
|
653
|
+
except (ValueError, TypeError):
|
|
654
|
+
return raw
|
|
655
|
+
return parsed if isinstance(parsed, dict | list) else raw
|
|
656
|
+
|
|
657
|
+
@staticmethod
|
|
658
|
+
def _iso_utc(ts: Any) -> str | None:
|
|
659
|
+
"""ISO-8601 UTC string (``2026-07-11T10:30:00Z``) for a naive-UTC
|
|
660
|
+
timestamp, or ``None`` when unset/unparseable."""
|
|
661
|
+
if ts is None:
|
|
662
|
+
return None
|
|
663
|
+
try:
|
|
664
|
+
if isinstance(ts, np.datetime64):
|
|
665
|
+
ts = ts.astype("datetime64[s]").astype(datetime)
|
|
666
|
+
if isinstance(ts, datetime):
|
|
667
|
+
if ts.tzinfo is not None:
|
|
668
|
+
ts = ts.astimezone(timezone.utc).replace(tzinfo=None)
|
|
669
|
+
return str(ts.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
670
|
+
except (ValueError, TypeError, OverflowError):
|
|
671
|
+
return None
|
|
672
|
+
return None
|
|
673
|
+
|
|
408
674
|
def _bold(self, text: str) -> str:
|
|
409
675
|
"""Render *text* bold in the target platform's markdown.
|
|
410
676
|
|
|
@@ -14,7 +14,7 @@ conventions.
|
|
|
14
14
|
**Database access for _you_ (recommended, not required).** detectkit itself
|
|
15
15
|
connects to the database directly via its drivers — it **never** needs an MCP to
|
|
16
16
|
run. But you assist far better with **read access to the same database** (e.g. a
|
|
17
|
-
database MCP for the project's ClickHouse / PostgreSQL / MySQL): you can inspect
|
|
17
|
+
database MCP for the project's ClickHouse / PostgreSQL / MySQL / MariaDB): you can inspect
|
|
18
18
|
a metric's series, find real incidents to label for `dtk autotune`, sanity-check
|
|
19
19
|
a metric query before running it, and confirm detections — instead of asking the
|
|
20
20
|
user to run every query by hand. Without it, fall back to marking incidents
|
|
@@ -37,11 +37,26 @@ Used by `run`, `unlock`, and `clean` (drift mode). Three forms:
|
|
|
37
37
|
(`--select "*" --exclude "metrics/staging/*"`). Metric names must be unique
|
|
38
38
|
across the project; duplicates raise an error listing the conflicting files.
|
|
39
39
|
|
|
40
|
+
## Exit codes
|
|
41
|
+
|
|
42
|
+
`dtk run`, `dtk autotune`, and `dtk clean` return a process exit code meant for
|
|
43
|
+
schedulers and CI to gate on:
|
|
44
|
+
|
|
45
|
+
| Code | Meaning |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `0` | Success — every metric completed the requested steps. |
|
|
48
|
+
| `1` | Failure — any metric failed, the run aborted early, a startup/config/DB error occurred, or the selector matched no metrics. |
|
|
49
|
+
| `2` | Usage error — bad CLI arguments. |
|
|
50
|
+
|
|
51
|
+
On `dtk autotune`, a metric that's disabled in its config counts as **skipped**,
|
|
52
|
+
not failed, and doesn't turn the exit code non-zero by itself. Other commands
|
|
53
|
+
keep Click's default behavior (0 on success, non-zero on any exception).
|
|
54
|
+
|
|
40
55
|
## `dtk run`
|
|
41
56
|
|
|
42
57
|
```bash
|
|
43
58
|
dtk run --select <sel> [--steps load,detect,alert] [--from DATE] [--to DATE] \
|
|
44
|
-
[--full-refresh] [--force] [--profile NAME] [--report [PATH]]
|
|
59
|
+
[--full-refresh] [--force] [--profile NAME] [--report [PATH]] [--json]
|
|
45
60
|
```
|
|
46
61
|
|
|
47
62
|
- `--steps` — which of `load`, `detect`, `alert` to run (default all); they always
|
|
@@ -66,6 +81,12 @@ dtk run --select <sel> [--steps load,detect,alert] [--from DATE] [--to DATE] \
|
|
|
66
81
|
tables, so even a `--steps load` run can produce one. Dual-mode: bare `--report`
|
|
67
82
|
→ `reports/<metric>.html`; `--report <dir>` → `<dir>/<metric>.html`;
|
|
68
83
|
`--report file.html` → that file.
|
|
84
|
+
- `--json` — human-readable progress still goes to **stderr**; **stdout** gets
|
|
85
|
+
exactly one JSON summary line for scripting/CI:
|
|
86
|
+
`{schema_version: 1, command, project, selector, steps, started_at,
|
|
87
|
+
finished_at, status: success|failed|error, metrics: [{name, status,
|
|
88
|
+
steps_completed, datapoints_loaded, anomalies_detected, alerts_sent, error}],
|
|
89
|
+
totals: {metrics, succeeded, failed, skipped, ...}, exit_code}`.
|
|
69
90
|
|
|
70
91
|
## `dtk autotune --select <sel>`
|
|
71
92
|
|
|
@@ -396,6 +417,12 @@ timers / Windows Task Scheduler. Always `cd` into the project first:
|
|
|
396
417
|
Pair scheduling with `error_alerting` (in `detectkit_project.yml`) so in-process
|
|
397
418
|
failures page someone; cron monitoring covers `dtk run` not running at all.
|
|
398
419
|
|
|
420
|
+
Beyond cron, orchestrators can gate directly on the process instead of parsing
|
|
421
|
+
logs: an Airflow `BashOperator`, a Dagster op, a Prefect task, or a CI job all
|
|
422
|
+
fail their step when `dtk run` / `dtk autotune` / `dtk clean` exits non-zero
|
|
423
|
+
(see "Exit codes" above), and can additionally parse `dtk run --json`'s stdout
|
|
424
|
+
summary for per-metric detail.
|
|
425
|
+
|
|
399
426
|
## Troubleshooting
|
|
400
427
|
|
|
401
428
|
- **"Metric not found"** — selector doesn't match. Use the bare name
|
|
@@ -116,9 +116,10 @@ alert_channels:
|
|
|
116
116
|
|
|
117
117
|
### Database profiles
|
|
118
118
|
|
|
119
|
-
> ClickHouse, PostgreSQL and
|
|
120
|
-
> two *databases*; PostgreSQL connects to one
|
|
121
|
-
> `
|
|
119
|
+
> ClickHouse, PostgreSQL, MySQL and MariaDB are all fully supported.
|
|
120
|
+
> ClickHouse/MySQL/MariaDB use two *databases*; PostgreSQL connects to one
|
|
121
|
+
> `database` and uses two *schemas*.
|
|
122
|
+
> `dtk init --db-type {clickhouse,postgres,mysql,mariadb}` scaffolds the right shape.
|
|
122
123
|
|
|
123
124
|
**ClickHouse**:
|
|
124
125
|
```yaml
|
|
@@ -166,6 +167,12 @@ profiles:
|
|
|
166
167
|
settings: {} # optional — extra pymysql.connect kwargs
|
|
167
168
|
```
|
|
168
169
|
|
|
170
|
+
**MariaDB**: identical fields to the MySQL block above — just spell `type:
|
|
171
|
+
mariadb` instead of `type: mysql`. The vendor (MySQL vs MariaDB) is
|
|
172
|
+
auto-detected at connect (`SELECT VERSION()`), so MariaDB gets its own
|
|
173
|
+
`VALUES()`-form upsert instead of MySQL 8.0.19's row-alias form.
|
|
174
|
+
`pip install 'detectkit[mariadb]'`.
|
|
175
|
+
|
|
169
176
|
### Alert channels
|
|
170
177
|
|
|
171
178
|
Defined once in `profiles.yml`, referenced by name in each metric's
|
|
@@ -228,9 +235,22 @@ alert_channels:
|
|
|
228
235
|
webhook_alerts:
|
|
229
236
|
type: webhook
|
|
230
237
|
webhook_url: "{{ env_var('WEBHOOK_URL') }}" # required
|
|
238
|
+
format: attachments # optional: attachments (default) | json | alertmanager
|
|
239
|
+
secret: "{{ env_var('WEBHOOK_SECRET') }}" # optional — HMAC-SHA256-signs the request body
|
|
231
240
|
extra_headers: # optional
|
|
232
241
|
Authorization: "Bearer {{ env_var('WEBHOOK_TOKEN') }}"
|
|
233
242
|
```
|
|
243
|
+
> `format` — `attachments` (default) renders the same platform-style card as
|
|
244
|
+
> Mattermost/Slack; `json` sends a versioned structured event
|
|
245
|
+
> (`schema_version: 1`; fields like `kind`/`status`/`project`/`metric`/
|
|
246
|
+
> `timestamp`/`value`/`expected`/`severity`/`direction`/`detector`/`rule`/
|
|
247
|
+
> `quorum`/`incident`/`links`/`display`); `alertmanager` sends a Prometheus
|
|
248
|
+
> Alertmanager webhook-receiver payload (v4) — a trigger/resolve pair sharing
|
|
249
|
+
> identical labels/fingerprint, `severity: critical` for anomaly/error and
|
|
250
|
+
> `warning` for no-data (no-data never auto-resolves). `json`/`alertmanager`
|
|
251
|
+
> ignore a custom `template` and add an `X-Detectkit-Event` header.
|
|
252
|
+
> `secret` — signs the raw request body as HMAC-SHA256 into
|
|
253
|
+
> `X-Detectkit-Signature-256: sha256=<hex>`, for any `format`.
|
|
234
254
|
|
|
235
255
|
## Notes
|
|
236
256
|
|
{detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md
RENAMED
|
@@ -50,17 +50,19 @@ side by side, ask which one to set up.
|
|
|
50
50
|
|
|
51
51
|
## Step 1 — Pick the database backend
|
|
52
52
|
|
|
53
|
-
**ClickHouse, PostgreSQL and
|
|
54
|
-
project uses (default to ClickHouse if unsure). `dtk init
|
|
55
|
-
{clickhouse,postgres,mysql}` scaffolds `profiles.yml` for the
|
|
56
|
-
The location fields differ:
|
|
57
|
-
- **ClickHouse** / **MySQL** — two *databases*: `internal_database` / `data_database`.
|
|
53
|
+
**ClickHouse, PostgreSQL, MySQL and MariaDB are all fully supported.** Ask
|
|
54
|
+
which one the project uses (default to ClickHouse if unsure). `dtk init
|
|
55
|
+
--db-type {clickhouse,postgres,mysql,mariadb}` scaffolds `profiles.yml` for the
|
|
56
|
+
chosen backend. The location fields differ:
|
|
57
|
+
- **ClickHouse** / **MySQL** / **MariaDB** — two *databases*: `internal_database` / `data_database`.
|
|
58
58
|
- **PostgreSQL** — connect to a `database` (must already exist), then two
|
|
59
59
|
*schemas*: `internal_schema` / `data_schema`.
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
`type: mariadb` is an identical profile alias to `type: mysql` (same location
|
|
62
|
+
fields; the vendor is auto-detected at connect). The metric query SQL dialect
|
|
63
|
+
also differs (e.g. `toStartOfInterval` on ClickHouse vs `date_trunc`/
|
|
64
|
+
`to_timestamp` on Postgres vs `FROM_UNIXTIME` on MySQL/MariaDB). Everything
|
|
65
|
+
else — detectors, alerting, the CLI — is identical.
|
|
64
66
|
|
|
65
67
|
## Step 2 — Connection details (gather, don't guess)
|
|
66
68
|
|
|
@@ -136,9 +138,11 @@ identity fields below are optional overrides.
|
|
|
136
138
|
- **email** — `smtp_host`, `smtp_port`, `from_email`, `to_emails` (list);
|
|
137
139
|
optional `from_name` (From display name, default `detectkit`);
|
|
138
140
|
`smtp_username` / `smtp_password` via env_var.
|
|
139
|
-
- **webhook** — generic `webhook_url` (required) + optional `
|
|
140
|
-
(
|
|
141
|
-
|
|
141
|
+
- **webhook** — generic `webhook_url` (required) + optional `format`
|
|
142
|
+
(`attachments` default / `json` / `alertmanager`), `secret` (HMAC-SHA256-signs
|
|
143
|
+
the request body into `X-Detectkit-Signature-256`), and `extra_headers` (also
|
|
144
|
+
accepts `username` / `icon_url` / `icon_emoji` / `channel`). There is no
|
|
145
|
+
`url`, `method` or `headers` field.
|
|
142
146
|
|
|
143
147
|
```yaml
|
|
144
148
|
# at the end of profiles.yml
|
|
@@ -239,14 +239,19 @@ def run_autotune(
|
|
|
239
239
|
force: bool,
|
|
240
240
|
dry_run: bool,
|
|
241
241
|
report_path: str | None = None,
|
|
242
|
-
) ->
|
|
243
|
-
"""Auto-tune each selected metric's detector configuration.
|
|
242
|
+
) -> int:
|
|
243
|
+
"""Auto-tune each selected metric's detector configuration.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
0 if every metric tuned or was explicitly skipped, 1 if any startup
|
|
247
|
+
step failed or any metric's tuning attempt failed.
|
|
248
|
+
"""
|
|
244
249
|
from_dt = parse_date(from_date) if from_date else None
|
|
245
250
|
to_dt = parse_date(to_date) if to_date else None
|
|
246
251
|
|
|
247
252
|
loaded = _load_project(profile)
|
|
248
253
|
if loaded is None:
|
|
249
|
-
return
|
|
254
|
+
return 1
|
|
250
255
|
project_root, _project_config, internal_manager, _db_manager = loaded
|
|
251
256
|
project_name = getattr(_project_config, "name", None)
|
|
252
257
|
|
|
@@ -254,15 +259,17 @@ def run_autotune(
|
|
|
254
259
|
metrics = select_metrics(select, project_root)
|
|
255
260
|
except ValueError as exc:
|
|
256
261
|
click.echo(click.style(f"Error: {exc}", fg="red", bold=True))
|
|
257
|
-
return
|
|
262
|
+
return 1
|
|
258
263
|
if not metrics:
|
|
259
264
|
click.echo(click.style(f"No metrics found matching selector: {select}", fg="yellow"))
|
|
260
|
-
return
|
|
265
|
+
return 1
|
|
261
266
|
|
|
262
267
|
click.echo(f"Found {len(metrics)} metric(s) to tune")
|
|
263
|
-
|
|
268
|
+
ok_count = 0
|
|
269
|
+
skipped_count = 0
|
|
270
|
+
failed_count = 0
|
|
264
271
|
for metric_path, config in metrics:
|
|
265
|
-
|
|
272
|
+
status = _tune_one(
|
|
266
273
|
metric_path=metric_path,
|
|
267
274
|
config=config,
|
|
268
275
|
project_root=project_root,
|
|
@@ -277,10 +284,20 @@ def run_autotune(
|
|
|
277
284
|
project_name=project_name,
|
|
278
285
|
project_loading_delay=getattr(_project_config, "loading_delay", None),
|
|
279
286
|
)
|
|
280
|
-
if ok:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
if status == "ok":
|
|
288
|
+
ok_count += 1
|
|
289
|
+
elif status == "skipped":
|
|
290
|
+
skipped_count += 1
|
|
291
|
+
else:
|
|
292
|
+
failed_count += 1
|
|
293
|
+
|
|
294
|
+
parts = [f"{ok_count} succeeded"]
|
|
295
|
+
if skipped_count:
|
|
296
|
+
parts.append(f"{skipped_count} skipped")
|
|
297
|
+
if failed_count:
|
|
298
|
+
parts.append(f"{failed_count} failed")
|
|
299
|
+
echo_done(f"Tuned {len(metrics)} metric(s), {', '.join(parts)}.")
|
|
300
|
+
return 1 if failed_count else 0
|
|
284
301
|
|
|
285
302
|
|
|
286
303
|
def _tune_one(
|
|
@@ -298,13 +315,19 @@ def _tune_one(
|
|
|
298
315
|
report_path: str | None = None,
|
|
299
316
|
project_name: str | None = None,
|
|
300
317
|
project_loading_delay: str | int | None = None,
|
|
301
|
-
) ->
|
|
302
|
-
"""Tune one metric end to end
|
|
318
|
+
) -> str:
|
|
319
|
+
"""Tune one metric end to end.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
``"ok"`` on success, ``"skipped"`` when autotune is disabled for this
|
|
323
|
+
metric (an explicit config choice, not a failure), or ``"failed"``
|
|
324
|
+
(no datapoints, a validation/engine error, or an unexpected exception).
|
|
325
|
+
"""
|
|
303
326
|
name = config.name
|
|
304
327
|
autotune_cfg = config.autotune or AutoTuneConfig()
|
|
305
328
|
if not autotune_cfg.enabled:
|
|
306
329
|
echo_noop(name, "autotune disabled in config")
|
|
307
|
-
return
|
|
330
|
+
return "skipped"
|
|
308
331
|
|
|
309
332
|
interval_seconds = config.get_interval().seconds
|
|
310
333
|
|
|
@@ -312,14 +335,14 @@ def _tune_one(
|
|
|
312
335
|
data = internal_manager.load_datapoints(name, from_timestamp=from_dt, to_timestamp=to_dt)
|
|
313
336
|
if len(data["timestamp"]) == 0:
|
|
314
337
|
echo_noop(name, "no datapoints — run `dtk run --select " + name + " --steps load` first")
|
|
315
|
-
return
|
|
338
|
+
return "failed"
|
|
316
339
|
|
|
317
340
|
click.echo(click.style(f"Tuning metric: {name}", fg="cyan", bold=True))
|
|
318
341
|
click.echo(f" Config file: {metric_path.relative_to(project_root)}")
|
|
319
342
|
|
|
320
343
|
if not force and not internal_manager.acquire_lock(name, "pipeline", "pipeline"):
|
|
321
344
|
echo_error(name, "locked by another run (use --force or `dtk unlock`)")
|
|
322
|
-
return
|
|
345
|
+
return "failed"
|
|
323
346
|
|
|
324
347
|
try:
|
|
325
348
|
data = cap_history(data, autotune_cfg.max_history)
|
|
@@ -375,7 +398,7 @@ def _tune_one(
|
|
|
375
398
|
project_loading_delay=project_loading_delay,
|
|
376
399
|
)
|
|
377
400
|
internal_manager.release_lock(name, "pipeline", "pipeline", status="completed")
|
|
378
|
-
return
|
|
401
|
+
return "ok"
|
|
379
402
|
|
|
380
403
|
except (AutoTuneError, ValueError, FileNotFoundError) as exc:
|
|
381
404
|
internal_manager.release_lock(
|
|
@@ -384,13 +407,13 @@ def _tune_one(
|
|
|
384
407
|
if not dry_run:
|
|
385
408
|
_save_failed_run(internal_manager, name, interval_seconds, scoring_override, str(exc))
|
|
386
409
|
echo_error(name, str(exc))
|
|
387
|
-
return
|
|
410
|
+
return "failed"
|
|
388
411
|
except Exception as exc: # noqa: BLE001 — never leave a held lock behind
|
|
389
412
|
internal_manager.release_lock(
|
|
390
413
|
name, "pipeline", "pipeline", status="failed", error_message=str(exc)
|
|
391
414
|
)
|
|
392
415
|
echo_error(name, f"unexpected error: {exc}")
|
|
393
|
-
return
|
|
416
|
+
return "failed"
|
|
394
417
|
|
|
395
418
|
|
|
396
419
|
def _finalize(
|