detectkit 0.40.0__tar.gz → 0.43.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.40.0/detectkit.egg-info → detectkit-0.43.0}/PKG-INFO +1 -1
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/__init__.py +1 -1
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/webhook.py +116 -67
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/__init__.py +0 -2
- detectkit-0.43.0/detectkit/autotune/runner.py +102 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/CLAUDE.section.md +9 -5
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/alerting.md +19 -9
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/autotune.md +43 -47
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/cli.md +18 -5
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +32 -33
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +2 -1
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/autotune.py +30 -180
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/init.py +3 -2
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/tune.py +4 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/main.py +6 -28
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/reporting/html_report.py +6 -5
- detectkit-0.43.0/detectkit/tuning/assets/tune.js +187 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/tuning/payload.py +31 -17
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/tuning/server.py +166 -12
- {detectkit-0.40.0 → detectkit-0.43.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit.egg-info/SOURCES.txt +1 -2
- detectkit-0.40.0/detectkit/autotune/html_labeler.py +0 -969
- detectkit-0.40.0/detectkit/autotune/label_server.py +0 -177
- detectkit-0.40.0/detectkit/tuning/assets/tune.js +0 -176
- {detectkit-0.40.0 → detectkit-0.43.0}/LICENSE +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/MANIFEST.in +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/README.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/core/models.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/pyproject.toml +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/requirements.txt +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/setup.cfg +0 -0
- {detectkit-0.40.0 → detectkit-0.43.0}/setup.py +0 -0
|
@@ -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.43.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -25,16 +25,22 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
25
25
|
- Slack incoming webhooks
|
|
26
26
|
- Custom webhook endpoints
|
|
27
27
|
|
|
28
|
-
Rendering: the default (no custom ``template``) payload is
|
|
29
|
-
**Slack/Mattermost
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
Rendering: the default (no custom ``template``) anomaly/recovery payload is
|
|
29
|
+
**two stacked Slack/Mattermost attachments** — a colored **base card** that
|
|
30
|
+
is always visible (the title, a short markdown lead with the **Rule** chip,
|
|
31
|
+
the Value / Expected fields and an always-visible compact **Links** field)
|
|
32
|
+
and a neutral **detail card** carrying the verbose tail (Quorum / Severity /
|
|
33
|
+
the anomalous span / Detectors / Parameters) as one markdown ``text`` block.
|
|
34
|
+
Slack and Mattermost natively collapse only an attachment's ``text`` (Slack
|
|
35
|
+
above 700 characters / 5 line breaks, Mattermost above ~200px of rendered
|
|
36
|
+
height) and **never** collapse the ``fields`` grid — so routing the bulk
|
|
37
|
+
into the detail card's ``text`` lets the platform fold it behind a
|
|
38
|
+
"Show more" toggle while the base (value, expected and links) stays in view.
|
|
39
|
+
Short kinds (no-data / error) render as a single base card. A custom
|
|
40
|
+
``template`` degrades to a plain text-only attachment (one opaque string
|
|
41
|
+
that can't be sliced into fields), keeping the color, title and branding.
|
|
42
|
+
Branding (``footer`` + ``footer_icon``) rides on the **last** attachment so
|
|
43
|
+
it sits at the bottom of the whole message, below the folded detail card.
|
|
38
44
|
|
|
39
45
|
Mentions ride in the **top-level** ``text`` (mentions inside attachments do
|
|
40
46
|
not reliably notify on Slack). A ``dashboard_url`` makes the attachment
|
|
@@ -48,7 +54,12 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
48
54
|
"icon_url": "https://.../bot-icon.png", # or "icon_emoji"
|
|
49
55
|
"text": "<!here> ...", # mentions (top level)
|
|
50
56
|
"channel": "#channel", # optional (Slack)
|
|
51
|
-
"attachments": [
|
|
57
|
+
"attachments": [
|
|
58
|
+
# base card — always visible (never folds; fields don't collapse)
|
|
59
|
+
{"color": ..., "title": ..., "fields": [Value, Expected, Links], ...},
|
|
60
|
+
# detail card — neutral, foldable text (anomaly/recovery only)
|
|
61
|
+
{"text": "<verbose tail>", "mrkdwn_in": ["text"]},
|
|
62
|
+
]
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
Branding: the bot defaults to the **detectkit brand avatar** (``icon_url``)
|
|
@@ -177,39 +188,42 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
177
188
|
if template is not None:
|
|
178
189
|
# Custom template → opaque text-only attachment (can't be sliced
|
|
179
190
|
# into fields), but keep the color, title, branding and mrkdwn.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
attachments: list[dict[str, Any]] = [
|
|
192
|
+
{
|
|
193
|
+
"color": color,
|
|
194
|
+
"title": title,
|
|
195
|
+
"text": self.format_message(alert_data, template),
|
|
196
|
+
"mrkdwn_in": ["text"],
|
|
197
|
+
}
|
|
198
|
+
]
|
|
186
199
|
else:
|
|
187
|
-
|
|
200
|
+
attachments = self._build_rich_attachments(alert_data, ctx, color, title)
|
|
188
201
|
|
|
189
|
-
# Dashboard link → clickable
|
|
202
|
+
# Dashboard link → clickable title on the base (first) attachment.
|
|
190
203
|
if alert_data.dashboard_url:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
# Brand the attachment footer (reliable on Slack even when
|
|
194
|
-
# username/icon are locked to the app install)
|
|
195
|
-
# the
|
|
196
|
-
#
|
|
204
|
+
attachments[0]["title_link"] = alert_data.dashboard_url
|
|
205
|
+
|
|
206
|
+
# Brand the LAST attachment's footer (reliable on Slack even when
|
|
207
|
+
# top-level username/icon are locked to the app install) so the brand
|
|
208
|
+
# line sits at the bottom of the whole message — below the folded detail
|
|
209
|
+
# card when one is present. Pair the brand name with the project name
|
|
210
|
+
# when set ("detectkit · my_project") so two projects posting to the same
|
|
211
|
+
# channel stay distinguishable even past the title.
|
|
197
212
|
footer = self.username or BRAND_USERNAME
|
|
198
213
|
if alert_data.project_name:
|
|
199
214
|
footer = f"{footer} · {alert_data.project_name}"
|
|
200
|
-
|
|
215
|
+
attachments[-1]["footer"] = footer
|
|
201
216
|
if self.icon_url:
|
|
202
|
-
|
|
217
|
+
attachments[-1]["footer_icon"] = self.icon_url
|
|
203
218
|
# Slack-only sugar: a real timestamp under the footer. Mattermost
|
|
204
|
-
# ignores it; the human-readable
|
|
205
|
-
# on both.
|
|
219
|
+
# ignores it; the human-readable timestamp rides in the cards on both.
|
|
206
220
|
ts_unix = self._unix_ts(alert_data.timestamp)
|
|
207
221
|
if ts_unix is not None:
|
|
208
|
-
|
|
222
|
+
attachments[-1]["ts"] = ts_unix
|
|
209
223
|
|
|
210
224
|
payload: dict[str, Any] = {
|
|
211
225
|
"username": self.username,
|
|
212
|
-
"attachments":
|
|
226
|
+
"attachments": attachments,
|
|
213
227
|
}
|
|
214
228
|
|
|
215
229
|
# Mentions ride in TOP-LEVEL text — placed inside an attachment they
|
|
@@ -231,29 +245,43 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
231
245
|
|
|
232
246
|
return payload
|
|
233
247
|
|
|
234
|
-
def
|
|
248
|
+
def _build_rich_attachments(
|
|
235
249
|
self,
|
|
236
250
|
alert_data: AlertData,
|
|
237
251
|
ctx: dict[str, Any],
|
|
238
252
|
color: str,
|
|
239
253
|
title: str,
|
|
240
|
-
) -> dict[str, Any]:
|
|
241
|
-
"""Build the default
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
) -> list[dict[str, Any]]:
|
|
255
|
+
"""Build the default attachment(s) for *alert_data*.
|
|
256
|
+
|
|
257
|
+
Returns a **base card** (always visible) and, for anomaly/recovery, a
|
|
258
|
+
neutral **detail card** whose long ``text`` block the chat client folds
|
|
259
|
+
behind "Show more". Slack and Mattermost collapse only an attachment's
|
|
260
|
+
``text`` (never the ``fields`` grid), so the base keeps the value, the
|
|
261
|
+
expected band and the links permanently in view while the verbose tail
|
|
262
|
+
(quorum, severity, the anomalous span, detectors, parameters) rides in
|
|
263
|
+
the foldable detail card. No-data / error are short, so they render as a
|
|
264
|
+
single base card. Both cards render from one payload on Slack and
|
|
265
|
+
Mattermost.
|
|
246
266
|
"""
|
|
247
267
|
kind = self.status_kind(alert_data)
|
|
248
|
-
|
|
268
|
+
base_fields: list[dict[str, Any]] = []
|
|
269
|
+
detail_lines: list[str] = []
|
|
249
270
|
|
|
250
271
|
def short(name: str, value: str) -> None:
|
|
272
|
+
"""Add a 2-col field to the always-visible base card."""
|
|
251
273
|
if value:
|
|
252
|
-
|
|
274
|
+
base_fields.append({"title": name, "value": value, "short": True})
|
|
253
275
|
|
|
254
276
|
def full(name: str, value: str) -> None:
|
|
277
|
+
"""Add a full-width field to the always-visible base card."""
|
|
255
278
|
if value:
|
|
256
|
-
|
|
279
|
+
base_fields.append({"title": name, "value": value, "short": False})
|
|
280
|
+
|
|
281
|
+
def detail(name: str, value: str) -> None:
|
|
282
|
+
"""Add one line to the foldable detail card: bold label + value."""
|
|
283
|
+
if value:
|
|
284
|
+
detail_lines.append(f"{self._bold(name)} {value}")
|
|
257
285
|
|
|
258
286
|
def code(s: str) -> str:
|
|
259
287
|
return f"`{s}`" if s else ""
|
|
@@ -261,7 +289,8 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
261
289
|
# The configured firing rule, set apart as a bold "Rule" label + an
|
|
262
290
|
# inline-code chip so it reads as "this is the config that fired" at a
|
|
263
291
|
# glance. Backticks render identically on Slack and Mattermost; the bold
|
|
264
|
-
# label is platform-aware (see ``_bold``).
|
|
292
|
+
# label is platform-aware (see ``_bold``). Stays in the always-visible
|
|
293
|
+
# base lead.
|
|
265
294
|
rule_chip = f"{self._bold('Rule')} " + code(
|
|
266
295
|
f"min_detectors={ctx['min_detectors']} · "
|
|
267
296
|
f"direction={ctx['direction_policy']} · "
|
|
@@ -269,35 +298,42 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
269
298
|
)
|
|
270
299
|
|
|
271
300
|
if kind == "anomaly":
|
|
272
|
-
#
|
|
273
|
-
#
|
|
301
|
+
# Base (always visible): the lead (how long it's been going on), the
|
|
302
|
+
# Rule chip, and the value vs the expected band.
|
|
274
303
|
lead = f"{ctx['anomaly_lead']}\n{rule_chip}"
|
|
275
304
|
short("Value", code(ctx["value_display"]))
|
|
276
305
|
short("Expected", code(ctx["expected_range"]))
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
306
|
+
# Detail (folds): quorum, severity, the span, detectors + params.
|
|
307
|
+
detail(
|
|
308
|
+
"Quorum",
|
|
309
|
+
f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
|
|
310
|
+
)
|
|
311
|
+
detail("Severity", f"{alert_data.severity:.2f}")
|
|
280
312
|
if ctx["started_display"]:
|
|
281
|
-
|
|
282
|
-
|
|
313
|
+
detail("Anomaly began", ctx["started_display"])
|
|
314
|
+
detail("Latest reading", ctx["timestamp"])
|
|
283
315
|
else:
|
|
284
|
-
|
|
285
|
-
|
|
316
|
+
detail("Detected at", ctx["timestamp"])
|
|
317
|
+
detail("Detectors", code(ctx["detector_name"]))
|
|
286
318
|
if ctx["detector_params"]:
|
|
287
|
-
|
|
319
|
+
# Fenced block on its own lines so it renders as a code block (and
|
|
320
|
+
# adds line breaks that help trip the platform's fold).
|
|
321
|
+
detail_lines.append(
|
|
322
|
+
f"{self._bold('Parameters')}\n```\n{ctx['detector_params']}\n```"
|
|
323
|
+
)
|
|
288
324
|
elif kind == "recovery":
|
|
289
325
|
lead = f"{ctx['recovery_lead']}\n{rule_chip}"
|
|
290
326
|
short("Value", code(ctx["value_display"]))
|
|
291
327
|
short("Expected", code(ctx["expected_range"]))
|
|
292
|
-
#
|
|
293
|
-
#
|
|
328
|
+
# Detail (folds): the incident timeline (onset → fired → recovered)
|
|
329
|
+
# and the detectors. ``detail`` skips the fired line when unknown.
|
|
294
330
|
if ctx["started_display"]:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
331
|
+
detail("Anomaly began", ctx["started_display"])
|
|
332
|
+
detail("Alert fired", ctx["fired_display"])
|
|
333
|
+
detail("Recovered", ctx["timestamp"])
|
|
298
334
|
else:
|
|
299
|
-
|
|
300
|
-
|
|
335
|
+
detail("Cleared at", ctx["timestamp"])
|
|
336
|
+
detail("Detectors", code(ctx["detector_name"]))
|
|
301
337
|
elif kind == "no_data":
|
|
302
338
|
lead = "Query returned no datapoint for the latest expected interval."
|
|
303
339
|
full("Expected at", ctx["timestamp"])
|
|
@@ -308,12 +344,13 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
308
344
|
err = f"{ctx['error_type']}: {ctx['error_message']}".strip(": ")
|
|
309
345
|
full("Error", code(err))
|
|
310
346
|
|
|
311
|
-
# Links
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
# platform's link syntax
|
|
316
|
-
#
|
|
347
|
+
# Links — always visible in the base card. The dashboard / "how to read
|
|
348
|
+
# this alert" links are actionable, so they never fold. Every entry is a
|
|
349
|
+
# compact clickable label, never a raw URL string (a Grafana URL can be a
|
|
350
|
+
# paragraph long once it carries variables; nobody should read that in an
|
|
351
|
+
# alert), rendered in the platform's link syntax and joined by " · " on a
|
|
352
|
+
# single line to stay compact. (The title is also a clickable dashboard
|
|
353
|
+
# link.)
|
|
317
354
|
link_parts = []
|
|
318
355
|
if alert_data.dashboard_url:
|
|
319
356
|
link_parts.append(self._link_markup(alert_data.dashboard_url, "Dashboard"))
|
|
@@ -335,14 +372,26 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
335
372
|
f"(expected {ctx['expected_range']}) at {ctx['timestamp']}"
|
|
336
373
|
)
|
|
337
374
|
|
|
338
|
-
|
|
375
|
+
base: dict[str, Any] = {
|
|
339
376
|
"fallback": fallback,
|
|
340
377
|
"color": color,
|
|
341
378
|
"title": title,
|
|
342
379
|
"text": lead,
|
|
343
|
-
"fields":
|
|
380
|
+
"fields": base_fields,
|
|
344
381
|
"mrkdwn_in": ["text", "fields"],
|
|
345
382
|
}
|
|
383
|
+
attachments: list[dict[str, Any]] = [base]
|
|
384
|
+
# Detail card: neutral (no color bar) so it reads as a continuation of
|
|
385
|
+
# the base rather than a second alert, with the verbose tail in one
|
|
386
|
+
# foldable ``text`` block.
|
|
387
|
+
if detail_lines:
|
|
388
|
+
attachments.append(
|
|
389
|
+
{
|
|
390
|
+
"text": "\n".join(detail_lines),
|
|
391
|
+
"mrkdwn_in": ["text"],
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
return attachments
|
|
346
395
|
|
|
347
396
|
def _bold(self, text: str) -> str:
|
|
348
397
|
"""Render *text* bold in the target platform's markdown.
|
|
@@ -15,7 +15,6 @@ from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
|
|
|
15
15
|
from detectkit.autotune._types import ScoringMetric, TuneMode
|
|
16
16
|
from detectkit.autotune.autotuner import AutoTuner, run_autotune_engine
|
|
17
17
|
from detectkit.autotune.config_emitter import compute_run_id, emit_tuned_config
|
|
18
|
-
from detectkit.autotune.html_labeler import render_labeler_html
|
|
19
18
|
from detectkit.autotune.labels import (
|
|
20
19
|
GroundTruth,
|
|
21
20
|
IncidentLabels,
|
|
@@ -39,6 +38,5 @@ __all__ = [
|
|
|
39
38
|
"emit_tuned_config",
|
|
40
39
|
"parse_incident_labels",
|
|
41
40
|
"parse_labels_file",
|
|
42
|
-
"render_labeler_html",
|
|
43
41
|
"run_autotune_engine",
|
|
44
42
|
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared autotune entry point reused by ``dtk autotune`` and ``dtk tune``.
|
|
2
|
+
|
|
3
|
+
The pure ``run_autotune_engine`` operates on an in-memory ``data`` dict, a
|
|
4
|
+
``GroundTruth`` and a ``TuneSettings``. Both callers need the same plumbing to
|
|
5
|
+
get there from a metric's ``AutoTuneConfig`` + labels: resolve the scoring
|
|
6
|
+
metric, cap the training history, build the settings, project the labels onto
|
|
7
|
+
the grid, and run the engine. That plumbing used to live privately in
|
|
8
|
+
``cli/commands/autotune.py``; it lives here so the ``dtk tune`` server can reuse
|
|
9
|
+
it verbatim for its server-side **Autotune** mode (no browser port of the
|
|
10
|
+
scoring/CV/seasonality search). Neither this module nor the engine touches the
|
|
11
|
+
database or the filesystem — the caller loads the datapoints and decides what to
|
|
12
|
+
persist.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from detectkit.autotune._types import ScoringMetric
|
|
22
|
+
from detectkit.autotune.autotuner import run_autotune_engine
|
|
23
|
+
from detectkit.autotune.labels import IncidentLabels
|
|
24
|
+
from detectkit.autotune.result import AutoTuneResult
|
|
25
|
+
from detectkit.autotune.settings import TuneSettings
|
|
26
|
+
from detectkit.config.metric_config import AutoTuneConfig
|
|
27
|
+
|
|
28
|
+
# Cap the scored training span when the user doesn't pin max_history, so tuning
|
|
29
|
+
# stays responsive on very long histories (recent data is the most relevant).
|
|
30
|
+
DEFAULT_TRAIN_CAP = 50_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_scoring(scoring_override: str | None, autotune_cfg: AutoTuneConfig) -> ScoringMetric:
|
|
34
|
+
"""Resolve the scoring metric: CLI override > config > MCC default.
|
|
35
|
+
|
|
36
|
+
Raises ``ValueError`` on an unknown name (callers translate as they see fit).
|
|
37
|
+
"""
|
|
38
|
+
value = scoring_override or autotune_cfg.scoring_metric or ScoringMetric.MCC.value
|
|
39
|
+
try:
|
|
40
|
+
return ScoringMetric(value)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
allowed = ", ".join(m.value for m in ScoringMetric)
|
|
43
|
+
raise ValueError(f"Invalid scoring metric '{value}'. Allowed: {allowed}") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_settings(*, scoring: ScoringMetric, autotune_cfg: AutoTuneConfig) -> TuneSettings:
|
|
47
|
+
"""Map an ``AutoTuneConfig`` block (+ resolved scoring) to ``TuneSettings``."""
|
|
48
|
+
return TuneSettings(
|
|
49
|
+
metric=scoring,
|
|
50
|
+
beta=autotune_cfg.beta,
|
|
51
|
+
fold_count=autotune_cfg.folds,
|
|
52
|
+
stability_lambda=autotune_cfg.stability_lambda,
|
|
53
|
+
allowed_detector_types=autotune_cfg.detector_types,
|
|
54
|
+
allowed_seasonality=autotune_cfg.seasonality_candidates,
|
|
55
|
+
force_seasonality=autotune_cfg.force_seasonality,
|
|
56
|
+
fixed_params=dict(autotune_cfg.fixed_params),
|
|
57
|
+
max_history=autotune_cfg.max_history,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cap_history(data: dict[str, np.ndarray], max_history: int | None) -> dict[str, np.ndarray]:
|
|
62
|
+
"""Keep the most recent ``max_history`` (or the default cap) points."""
|
|
63
|
+
n = len(data["timestamp"])
|
|
64
|
+
cap = max_history if max_history is not None else DEFAULT_TRAIN_CAP
|
|
65
|
+
if n <= cap:
|
|
66
|
+
return data
|
|
67
|
+
return {
|
|
68
|
+
"timestamp": data["timestamp"][-cap:],
|
|
69
|
+
"value": data["value"][-cap:],
|
|
70
|
+
"seasonality_data": data["seasonality_data"][-cap:],
|
|
71
|
+
"seasonality_columns": data["seasonality_columns"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def autotune_from_data(
|
|
76
|
+
*,
|
|
77
|
+
metric_name: str,
|
|
78
|
+
data: dict[str, np.ndarray],
|
|
79
|
+
labels: IncidentLabels,
|
|
80
|
+
interval_seconds: int,
|
|
81
|
+
autotune_cfg: AutoTuneConfig,
|
|
82
|
+
scoring_override: str | None = None,
|
|
83
|
+
on_stage: Callable[[str, str], None] | None = None,
|
|
84
|
+
) -> AutoTuneResult:
|
|
85
|
+
"""Run the full autotune engine on an in-memory series + labels.
|
|
86
|
+
|
|
87
|
+
Caps the history, resolves the scoring metric, projects ``labels`` onto the
|
|
88
|
+
(capped) grid as ground truth, builds the settings and runs the engine.
|
|
89
|
+
Pure: no DB, no filesystem, no lock — the caller decides what to persist.
|
|
90
|
+
"""
|
|
91
|
+
data = cap_history(data, autotune_cfg.max_history)
|
|
92
|
+
scoring = resolve_scoring(scoring_override, autotune_cfg)
|
|
93
|
+
ground_truth = labels.to_ground_truth(data["timestamp"], interval_seconds)
|
|
94
|
+
settings = build_settings(scoring=scoring, autotune_cfg=autotune_cfg)
|
|
95
|
+
return run_autotune_engine(
|
|
96
|
+
metric_name=metric_name,
|
|
97
|
+
data=data,
|
|
98
|
+
ground_truth=ground_truth,
|
|
99
|
+
interval_seconds=interval_seconds,
|
|
100
|
+
settings=settings,
|
|
101
|
+
on_stage=on_stage,
|
|
102
|
+
)
|
|
@@ -17,10 +17,12 @@ run. But you assist far better with **read access to the same database** (e.g. a
|
|
|
17
17
|
database MCP for the project's ClickHouse / PostgreSQL / MySQL): 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
|
-
user to run every query by hand. Without it, fall back to
|
|
21
|
-
`dtk
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
user to run every query by hand. Without it, fall back to marking incidents
|
|
21
|
+
visually in `dtk tune --select <metric>` (**Label** mode to drag spans / Threshold
|
|
22
|
+
capture / Lasso the anomaly cloud, or **Review** mode to confirm fired alerts as
|
|
23
|
+
incidents), then **Save incidents** — which `dtk autotune` auto-discovers in
|
|
24
|
+
`incidents/<metric>/` — and to asking the user. This access is optional and
|
|
25
|
+
separate from the `profiles.yml` connection detectkit uses for the pipeline.
|
|
24
26
|
|
|
25
27
|
### Where to look (read the matching file before answering)
|
|
26
28
|
|
|
@@ -52,7 +54,9 @@ version — **read the relevant one on demand** instead of guessing:
|
|
|
52
54
|
`dtk autotune`, and explains the chosen, annotated config (an automatic,
|
|
53
55
|
cross-validated search). For **hands-on** tuning instead, `dtk tune` opens a
|
|
54
56
|
browser view where you drag the detector's knobs against the real series and
|
|
55
|
-
write the result back in place
|
|
57
|
+
write the result back in place — and its **Autotune** mode can run that same
|
|
58
|
+
search server-side and re-seed the knobs without leaving the cockpit (see
|
|
59
|
+
`cli.md`).
|
|
56
60
|
- **Hit a detectkit bug, or have feedback** — once you've ruled out a local
|
|
57
61
|
config fix (see the gotchas below), use the **`dtk-feedback`** skill to file a
|
|
58
62
|
redacted bug report, feature request, or comment as a GitHub issue on the
|
|
@@ -192,15 +192,25 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
|
|
|
192
192
|
**project name** as a `[name] ` prefix (from `detectkit_project.yml`) — see
|
|
193
193
|
[Project label](#project-label-multi-project-channels) below.
|
|
194
194
|
|
|
195
|
-
- **Slack / Mattermost / generic webhook** —
|
|
196
|
-
|
|
197
|
-
`
|
|
198
|
-
|
|
199
|
-
fields grid
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
195
|
+
- **Slack / Mattermost / generic webhook** — an anomaly/recovery renders as
|
|
196
|
+
**two stacked attachments** so long alerts fold in the channel. Both platforms
|
|
197
|
+
collapse only an attachment's `text` block behind a "Show more" toggle (Slack
|
|
198
|
+
above 700 chars / 5 line breaks; Mattermost above ~200px) and never collapse
|
|
199
|
+
the `fields` grid, so the message splits into:
|
|
200
|
+
- an always-visible **base card** — status-colored accent bar, a clickable
|
|
201
|
+
title (the metric; links to `dashboard_url` when set), a short markdown lead
|
|
202
|
+
(the duration sentence — see "Incident timing" below) with the **Rule** chip
|
|
203
|
+
beneath it, and a compact fields grid kept to **Value / Expected** plus an
|
|
204
|
+
always-visible compact **Links** field (dashboard + extra links + the "how to
|
|
205
|
+
read this alert" guide as clickable labels);
|
|
206
|
+
- a neutral, foldable **detail card** — the verbose tail as one markdown
|
|
207
|
+
`text` block: Quorum / Severity / the anomalous span (Anomaly began → Latest
|
|
208
|
+
reading; began → fired → recovered on recovery) / Detectors / Parameters.
|
|
209
|
+
|
|
210
|
+
No-data / error stay a single base card. The branded footer + footer icon ride
|
|
211
|
+
on the **last** attachment. @mentions ride in the **top-level** message text so
|
|
212
|
+
they notify. A custom `template` instead renders as a single plain text-only
|
|
213
|
+
attachment (color/title/branding kept, no fields grid, no fold split).
|
|
204
214
|
- **Telegram** — default `parse_mode` is now **HTML**. The default message is
|
|
205
215
|
structured and HTML-escaped: a colored status dot (red anomaly / green
|
|
206
216
|
recovery / yellow no-data / blue error), a bold headline, the lead + rule, then
|
|
@@ -17,6 +17,14 @@ same windowed detectors and `detector_id` identity). The fastest path is the
|
|
|
17
17
|
> `metrics/.history/<metric>/` first). Use `autotune` to search automatically and
|
|
18
18
|
> emit a new file; use `tune` to dial a detector in by eye and commit it. See
|
|
19
19
|
> `cli.md`.
|
|
20
|
+
>
|
|
21
|
+
> **You can also run this engine *inside* `dtk tune`** — its **Autotune** mode runs
|
|
22
|
+
> the same search **server-side** (using the incidents you've marked as ground
|
|
23
|
+
> truth), re-seeds the knobs with the winner, and lets you **Apply** in place. That's
|
|
24
|
+
> the same computation as this command; the difference is it edits the metric YAML in
|
|
25
|
+
> place (advisory — no run record / `__tuned_<id>.yml` / persisted detections) rather
|
|
26
|
+
> than emitting a new tuned file. Reach for `dtk autotune` when you want the audited
|
|
27
|
+
> run + tuned file + persisted winner detections.
|
|
20
28
|
|
|
21
29
|
## What it searches
|
|
22
30
|
|
|
@@ -64,28 +72,24 @@ ratios to choose.
|
|
|
64
72
|
## Command
|
|
65
73
|
|
|
66
74
|
```bash
|
|
67
|
-
dtk autotune --select <sel> [--incidents FILE] [--
|
|
75
|
+
dtk autotune --select <sel> [--incidents FILE] [--scoring METRIC] \
|
|
68
76
|
[--from DATE] [--to DATE] [--profile NAME] [--force] [--dry-run] [--report]
|
|
69
77
|
```
|
|
70
78
|
|
|
71
79
|
- `--incidents FILE|DIR` — a labels file (below) → **supervised** tuning. May be a
|
|
72
80
|
**directory** (e.g. `incidents/<name>/`): interactive runs prompt to pick a
|
|
73
|
-
version (default newest), non-interactive use the newest.
|
|
81
|
+
version (default newest), non-interactive use the newest. **Omit it and `dtk
|
|
82
|
+
autotune` auto-discovers the newest labels in `incidents/<metric>/`** (the store
|
|
83
|
+
`dtk tune`'s **Save incidents** writes) — so after labeling in `dtk tune` you run
|
|
84
|
+
`dtk autotune --select <metric>` with no flag. With no labels anywhere, an
|
|
74
85
|
interactive terminal prompts to enter incidents inline; declining (or running
|
|
75
86
|
non-interactively) tunes **unsupervised**.
|
|
76
|
-
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
editing in place. Beyond click-drag marking it offers **Threshold capture**
|
|
83
|
-
(grab every span above/below a horizontal line in one gesture), **Lasso capture**
|
|
84
|
-
(loop around a cloud of points; each grid-adjacent run, gaps bridged, becomes one
|
|
85
|
-
incident span), **on-chart delete** (each band's ✕, or select + Delete key), and
|
|
86
|
-
**Import file…** (load a labels file you pick). `--no-serve` writes a static
|
|
87
|
-
`metrics/<name>__labeler.html` (Export downloads the file) and exits; `--no-open`
|
|
88
|
-
prints the URL instead of launching a browser.
|
|
87
|
+
- **To label incidents**, use `dtk tune --select <metric>`: switch to **Label**
|
|
88
|
+
mode (drag spans, **Threshold capture** every span past a horizontal line,
|
|
89
|
+
**Lasso** the anomaly cloud) or **Review** mode (confirm fired alerts as
|
|
90
|
+
incidents), then **Save incidents**. That writes versioned files into
|
|
91
|
+
`incidents/<metric>/` — the **same store this command reads** — so the labels feed
|
|
92
|
+
the next supervised tune with no `--incidents` flag (see `cli.md`).
|
|
89
93
|
- `--scoring` — `mcc` (default), `f1`, `f_beta`, `balanced_accuracy`, `roc_auc`,
|
|
90
94
|
`pr_auc`. MCC uses the whole confusion matrix and suits rare anomalies.
|
|
91
95
|
- `--dry-run` — run the search but persist nothing and write no config.
|
|
@@ -115,45 +119,35 @@ incidents:
|
|
|
115
119
|
- {at: "2026-05-11 09:05:00"} # point
|
|
116
120
|
```
|
|
117
121
|
|
|
118
|
-
### Getting labels —
|
|
122
|
+
### Getting labels — label in `dtk tune` first
|
|
119
123
|
|
|
120
|
-
When labels would help, **offer
|
|
124
|
+
When labels would help, **offer to mark incidents in `dtk tune` before asking the
|
|
121
125
|
user to recall timestamps** — it is the easiest, most reliable path:
|
|
122
126
|
|
|
123
|
-
1. Run `dtk
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
3. That writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml` automatically
|
|
139
|
-
(named after the metric, optional set name as a suffix; versioned —
|
|
140
|
-
re-labeling never overwrites) and the **same command continues into the tuning
|
|
141
|
-
run** on it. No manual file moving.
|
|
142
|
-
4. **Editing over time:** re-running `--label` seeds the page from the metric's
|
|
143
|
-
newest saved set, so labeling can grow across sessions. To re-tune later on
|
|
144
|
-
saved sets, point `--incidents` at the folder (`incidents/<name>/`) —
|
|
145
|
-
interactive runs let the user pick a version, and `--label --incidents <file>`
|
|
146
|
-
opens that exact set for editing. The static `--no-serve` path still exists
|
|
147
|
-
(Export downloads a file you move into `incidents/<name>/`; its **Import
|
|
148
|
-
file…** button loads one back in).
|
|
127
|
+
1. Run `dtk tune --select <name>`. It opens a localhost browser view of the real
|
|
128
|
+
series (use `--no-open` on a remote box and share the printed 127.0.0.1 URL).
|
|
129
|
+
2. Mark the real incidents one of two ways:
|
|
130
|
+
- **Label** mode — drag a span over each incident, or **Threshold capture**
|
|
131
|
+
every span past a horizontal line in one gesture, or **Lasso anomalies** to
|
|
132
|
+
loop a cloud of anomaly dots into per-streak incident spans.
|
|
133
|
+
- **Review** mode — the fired alerts lead; click each alert marker to confirm it
|
|
134
|
+
**valid** (which marks it as a ground-truth incident) or **false alarm**.
|
|
135
|
+
**Confirm all unreviewed valid** does the lot — a clean metric whose alerts
|
|
136
|
+
are all good is validated in a few clicks without hand-drawing spans.
|
|
137
|
+
3. Click **Save incidents**. It writes a versioned
|
|
138
|
+
`incidents/<metric>/<…>.yml` automatically.
|
|
139
|
+
4. Run `dtk autotune --select <name>` with **no `--incidents` flag** — it
|
|
140
|
+
auto-discovers the newest file in `incidents/<metric>/` and tunes supervised on
|
|
141
|
+
it. (You can still pass `--incidents <file-or-dir>` to pick a specific set.)
|
|
149
142
|
|
|
150
143
|
Prefer this whenever the user can *recognise* incidents on a chart but doesn't
|
|
151
144
|
have exact times. If they already know the times (or you found them via a DB
|
|
152
145
|
MCP), write the labels file / inline `incidents:` directly instead. If there are
|
|
153
146
|
no known incidents, run unsupervised — the baseline below is good on its own.
|
|
154
147
|
|
|
155
|
-
With no labels (no `--incidents`, no config `labels_file`,
|
|
156
|
-
entry), tuning falls back to an
|
|
148
|
+
With no labels (no `--incidents`, no config `labels_file`, none auto-discovered
|
|
149
|
+
in `incidents/<metric>/`, no interactive entry), tuning falls back to an
|
|
150
|
+
**unsupervised** objective that blends three
|
|
157
151
|
band-fit / flag-budget terms — `0.4·budget + 0.3·sharpness + 0.3·separation`: a
|
|
158
152
|
smooth one-sided flag-rate budget (no hard cliff), sharpness (a tight,
|
|
159
153
|
well-calibrated confidence interval), and separation (flagged points sitting
|
|
@@ -192,8 +186,10 @@ differs from `seasonality_candidates`, which only narrows the *set of columns*
|
|
|
192
186
|
the search may consider but still runs the search and may choose none.
|
|
193
187
|
|
|
194
188
|
Label precedence (highest first): `--incidents` flag → `labels_file` → inline
|
|
195
|
-
`incidents` →
|
|
196
|
-
|
|
189
|
+
`incidents` → auto-discovered `incidents/<metric>/` (the newest file, e.g. from
|
|
190
|
+
`dtk tune`'s **Save incidents**) → interactive prompt → none (unsupervised).
|
|
191
|
+
`labels_file` and `incidents` are mutually exclusive. `--scoring` likewise
|
|
192
|
+
overrides `scoring_metric`.
|
|
197
193
|
|
|
198
194
|
## The annotated config
|
|
199
195
|
|