detectkit 0.40.0__tar.gz → 0.42.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.
Files changed (138) hide show
  1. {detectkit-0.40.0/detectkit.egg-info → detectkit-0.42.0}/PKG-INFO +1 -1
  2. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/webhook.py +116 -67
  4. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/__init__.py +0 -2
  5. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/CLAUDE.section.md +6 -4
  6. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/alerting.md +19 -9
  7. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/autotune.md +35 -47
  8. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/cli.md +5 -2
  9. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +32 -33
  10. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +2 -1
  11. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/autotune.py +24 -141
  12. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/init.py +3 -2
  13. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/main.py +6 -28
  14. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/reporting/html_report.py +6 -5
  15. {detectkit-0.40.0 → detectkit-0.42.0/detectkit.egg-info}/PKG-INFO +1 -1
  16. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit.egg-info/SOURCES.txt +0 -2
  17. detectkit-0.40.0/detectkit/autotune/html_labeler.py +0 -969
  18. detectkit-0.40.0/detectkit/autotune/label_server.py +0 -177
  19. {detectkit-0.40.0 → detectkit-0.42.0}/LICENSE +0 -0
  20. {detectkit-0.40.0 → detectkit-0.42.0}/MANIFEST.in +0 -0
  21. {detectkit-0.40.0 → detectkit-0.42.0}/README.md +0 -0
  22. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/__init__.py +0 -0
  23. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/__init__.py +0 -0
  24. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/base.py +0 -0
  25. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/branding.py +0 -0
  26. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/email.py +0 -0
  27. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/factory.py +0 -0
  28. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/mattermost.py +0 -0
  29. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/slack.py +0 -0
  30. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/channels/telegram.py +0 -0
  31. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  32. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  33. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  34. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  35. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  36. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  37. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  38. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  39. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  40. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/_base.py +0 -0
  41. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/_types.py +0 -0
  42. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/autotuner.py +0 -0
  43. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/config_emitter.py +0 -0
  44. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/crossval.py +0 -0
  45. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/detector_select.py +0 -0
  46. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/distribution.py +0 -0
  47. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/grid_search.py +0 -0
  48. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/labels.py +0 -0
  49. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/result.py +0 -0
  50. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/scoring.py +0 -0
  51. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/seasonality_search.py +0 -0
  52. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/settings.py +0 -0
  53. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/autotune/window_select.py +0 -0
  54. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/__init__.py +0 -0
  55. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/_output.py +0 -0
  56. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  57. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  58. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  59. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  60. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  61. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  62. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/__init__.py +0 -0
  63. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/clean.py +0 -0
  64. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/init_claude.py +0 -0
  65. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/run.py +0 -0
  66. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/test_alert.py +0 -0
  67. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/tune.py +0 -0
  68. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/cli/commands/unlock.py +0 -0
  69. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/config/__init__.py +0 -0
  70. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/config/metric_config.py +0 -0
  71. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/config/profile.py +0 -0
  72. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/config/project_config.py +0 -0
  73. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/config/validator.py +0 -0
  74. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/core/__init__.py +0 -0
  75. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/core/interval.py +0 -0
  76. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/core/models.py +0 -0
  77. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/__init__.py +0 -0
  78. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/_sql_manager.py +0 -0
  79. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/clickhouse_manager.py +0 -0
  80. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/__init__.py +0 -0
  81. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  82. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  83. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_base.py +0 -0
  84. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  85. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_detections.py +0 -0
  86. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  87. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  88. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_schema.py +0 -0
  89. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  90. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/internal_tables/manager.py +0 -0
  91. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/manager.py +0 -0
  92. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/mysql_manager.py +0 -0
  93. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/postgres_manager.py +0 -0
  94. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/database/tables.py +0 -0
  95. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/__init__.py +0 -0
  96. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/base.py +0 -0
  97. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/factory.py +0 -0
  98. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/seasonality.py +0 -0
  99. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/__init__.py +0 -0
  100. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  101. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/iqr.py +0 -0
  102. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/mad.py +0 -0
  103. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  104. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/detectors/statistical/zscore.py +0 -0
  105. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/loaders/__init__.py +0 -0
  106. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/loaders/metric_loader.py +0 -0
  107. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/loaders/query_template.py +0 -0
  108. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/__init__.py +0 -0
  109. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/error_dispatch.py +0 -0
  110. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  111. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  112. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  113. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  114. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  115. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  116. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  117. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/reporting/__init__.py +0 -0
  118. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/reporting/assets/report.js +0 -0
  119. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/reporting/builder.py +0 -0
  120. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/__init__.py +0 -0
  121. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/assets/tune.js +0 -0
  122. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/config_writer.py +0 -0
  123. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/html.py +0 -0
  124. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/payload.py +0 -0
  125. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/tuning/server.py +0 -0
  126. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/utils/__init__.py +0 -0
  127. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/utils/datetime_utils.py +0 -0
  128. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/utils/env_interpolation.py +0 -0
  129. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/utils/json_utils.py +0 -0
  130. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit/utils/stats.py +0 -0
  131. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit.egg-info/dependency_links.txt +0 -0
  132. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit.egg-info/entry_points.txt +0 -0
  133. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit.egg-info/requires.txt +0 -0
  134. {detectkit-0.40.0 → detectkit-0.42.0}/detectkit.egg-info/top_level.txt +0 -0
  135. {detectkit-0.40.0 → detectkit-0.42.0}/pyproject.toml +0 -0
  136. {detectkit-0.40.0 → detectkit-0.42.0}/requirements.txt +0 -0
  137. {detectkit-0.40.0 → detectkit-0.42.0}/setup.cfg +0 -0
  138. {detectkit-0.40.0 → detectkit-0.42.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.40.0
3
+ Version: 0.42.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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.40.0"
7
+ __version__ = "0.42.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 a single
29
- **Slack/Mattermost message attachment** — a colored accent bar, a title,
30
- a short markdown lead (how long the anomaly has been running) with the
31
- **Rule** chip beneath it, and a compact **fields grid** (Value / Expected /
32
- Quorum / Severity / Started / Latest Started / Cleared on recovery — then
33
- full-width Detectors / Parameters), branded with a ``footer`` +
34
- ``footer_icon``. This renders richly on both
35
- Slack and Mattermost from one payload. A custom ``template`` degrades to a
36
- plain text-only attachment (the template is one opaque string that can't be
37
- sliced into fields), keeping the color, title and branding.
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": [{"color": ..., "title": ..., "fields": [...], ...}]
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
- attachment: dict[str, Any] = {
181
- "color": color,
182
- "title": title,
183
- "text": self.format_message(alert_data, template),
184
- "mrkdwn_in": ["text"],
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
- attachment = self._build_rich_attachment(alert_data, ctx, color, title)
200
+ attachments = self._build_rich_attachments(alert_data, ctx, color, title)
188
201
 
189
- # Dashboard link → clickable attachment title (native on both platforms).
202
+ # Dashboard link → clickable title on the base (first) attachment.
190
203
  if alert_data.dashboard_url:
191
- attachment["title_link"] = alert_data.dashboard_url
192
-
193
- # Brand the attachment footer (reliable on Slack even when top-level
194
- # username/icon are locked to the app install). Pair the brand name with
195
- # the project name when set ("detectkit · my_project") so two projects
196
- # posting to the same channel stay distinguishable even past the title.
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
- attachment["footer"] = footer
215
+ attachments[-1]["footer"] = footer
201
216
  if self.icon_url:
202
- attachment["footer_icon"] = self.icon_url
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 "Detected at" field carries the time
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
- attachment["ts"] = ts_unix
222
+ attachments[-1]["ts"] = ts_unix
209
223
 
210
224
  payload: dict[str, Any] = {
211
225
  "username": self.username,
212
- "attachments": [attachment],
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 _build_rich_attachment(
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 fields-based attachment for *alert_data*.
242
-
243
- The lead text and which fields render depend on the alert kind
244
- (anomaly / recovery / no-data / error); long values (params, detectors,
245
- error message) use full-width fields, short stats use the 2-col grid.
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
- fields: list[dict[str, Any]] = []
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
- fields.append({"title": name, "value": value, "short": True})
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
- fields.append({"title": name, "value": value, "short": False})
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
- # Description (how long it's been going on) leads; the Rule chip sits
273
- # right above the value/expected fields it explains.
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
- short("Quorum", f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}")
278
- short("Severity", f"{alert_data.severity:.2f}")
279
- # The problematic span: when the anomaly began and its latest point.
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
- short("Anomaly began", ctx["started_display"])
282
- short("Latest reading", ctx["timestamp"])
313
+ detail("Anomaly began", ctx["started_display"])
314
+ detail("Latest reading", ctx["timestamp"])
283
315
  else:
284
- full("Detected at", ctx["timestamp"])
285
- full("Detectors", code(ctx["detector_name"]))
316
+ detail("Detected at", ctx["timestamp"])
317
+ detail("Detectors", code(ctx["detector_name"]))
286
318
  if ctx["detector_params"]:
287
- full("Parameters", f"```{ctx['detector_params']}```")
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
- # The incident timeline: anomaly onset → when the alert fired →
293
- # when it recovered. ``short`` skips the fired field when unknown.
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
- short("Anomaly began", ctx["started_display"])
296
- short("Alert fired", ctx["fired_display"])
297
- short("Recovered", ctx["timestamp"])
331
+ detail("Anomaly began", ctx["started_display"])
332
+ detail("Alert fired", ctx["fired_display"])
333
+ detail("Recovered", ctx["timestamp"])
298
334
  else:
299
- full("Cleared at", ctx["timestamp"])
300
- full("Detectors", code(ctx["detector_name"]))
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 block kept as its own flexible field, but every entry is a
312
- # compact clickable label, never a raw URL string. A Grafana dashboard
313
- # URL can be a paragraph long once it carries variables; nobody should
314
- # read that in an alert, so we hide it behind the label and render in the
315
- # platform's link syntax. Holds dashboard + any extra links + the "how to
316
- # read this alert" guide, joined by " · ".
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
- return {
375
+ base: dict[str, Any] = {
339
376
  "fallback": fallback,
340
377
  "color": color,
341
378
  "title": title,
342
379
  "text": lead,
343
- "fields": 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
  ]
@@ -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 autotune --select <metric> --label` (a visual labeler) and to asking the
22
- user. This access is optional and separate from the `profiles.yml` connection
23
- detectkit uses for the pipeline.
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
 
@@ -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** — one message *attachment* with a
196
- status-colored accent bar, a clickable title (the metric; links to
197
- `dashboard_url` when set), a short markdown lead (the duration sentence — see
198
- "Incident timing" below) with the **Rule** chip beneath it, and a compact
199
- fields grid: short fields Value / Expected / Quorum / Severity / Anomaly began /
200
- Latest reading (Anomaly began / Alert fired / Recovered on recovery), then full-width Detectors / Parameters,
201
- plus a branded footer + footer icon. @mentions ride in the **top-level**
202
- message text so they notify. A custom `template` instead renders as a plain
203
- text-only attachment (color/title/ branding kept, no fields grid).
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
@@ -64,28 +64,24 @@ ratios to choose.
64
64
  ## Command
65
65
 
66
66
  ```bash
67
- dtk autotune --select <sel> [--incidents FILE] [--label] [--scoring METRIC] \
67
+ dtk autotune --select <sel> [--incidents FILE] [--scoring METRIC] \
68
68
  [--from DATE] [--to DATE] [--profile NAME] [--force] [--dry-run] [--report]
69
69
  ```
70
70
 
71
71
  - `--incidents FILE|DIR` — a labels file (below) → **supervised** tuning. May be a
72
72
  **directory** (e.g. `incidents/<name>/`): interactive runs prompt to pick a
73
- version (default newest), non-interactive use the newest. With nothing given, an
73
+ version (default newest), non-interactive use the newest. **Omit it and `dtk
74
+ autotune` auto-discovers the newest labels in `incidents/<metric>/`** (the store
75
+ `dtk tune`'s **Save incidents** writes) — so after labeling in `dtk tune` you run
76
+ `dtk autotune --select <metric>` with no flag. With no labels anywhere, an
74
77
  interactive terminal prompts to enter incidents inline; declining (or running
75
78
  non-interactively) tunes **unsupervised**.
76
- - `--label` open the interactive labeler (zoom/pan, edit incident edges,
77
- per-incident descriptions, named sets). **Default:** a local 127.0.0.1 server +
78
- browser; **Save & tune** writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml`
79
- (named after the metric, optional set name as a suffix) and the run
80
- **continues into tuning on it**. It **seeds from the metric's newest saved set**
81
- (or from `--incidents FILE|DIR` when given), so re-running `--label` continues
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.
79
+ - **To label incidents**, use `dtk tune --select <metric>`: switch to **Label**
80
+ mode (drag spans, **Threshold capture** every span past a horizontal line,
81
+ **Lasso** the anomaly cloud) or **Review** mode (confirm fired alerts as
82
+ incidents), then **Save incidents**. That writes versioned files into
83
+ `incidents/<metric>/` the **same store this command reads** so the labels feed
84
+ the next supervised tune with no `--incidents` flag (see `cli.md`).
89
85
  - `--scoring` — `mcc` (default), `f1`, `f_beta`, `balanced_accuracy`, `roc_auc`,
90
86
  `pr_auc`. MCC uses the whole confusion matrix and suits rare anomalies.
91
87
  - `--dry-run` — run the search but persist nothing and write no config.
@@ -115,45 +111,35 @@ incidents:
115
111
  - {at: "2026-05-11 09:05:00"} # point
116
112
  ```
117
113
 
118
- ### Getting labels — offer the interactive labeler first
114
+ ### Getting labels — label in `dtk tune` first
119
115
 
120
- When labels would help, **offer the interactive HTML labeler before asking the
116
+ When labels would help, **offer to mark incidents in `dtk tune` before asking the
121
117
  user to recall timestamps** — it is the easiest, most reliable path:
122
118
 
123
- 1. Run `dtk autotune --select <name> --label`. It starts a local labeler server
124
- and opens the browser (use `--no-open` on a remote box and share the printed
125
- 127.0.0.1 URL; the user port-forwards or runs it locally).
126
- 2. The user marks incidents on the chart (scroll to zoom, drag the navigator to
127
- move, click-drag to mark, drag an incident's edges to adjust, add a
128
- description, optionally name the set), then clicks **Save & tune**. For many
129
- clear outliers, **Threshold capture** grabs every span past a horizontal line
130
- at once (above/below, with an optional gap-bridge); it captures within the
131
- current view by default, and dragging across the chart limits it to a time
132
- window (different boundary per period) the painted window is **saved with the
133
- set and restored on reopen** (a `capture_windows:` block; metadata only). For an
134
- irregular cloud, **Lasso capture** loops around the points freehand and turns
135
- each grid-adjacent run (small gaps bridged) into one incident span. Each
136
- band's (or select + Delete)
137
- removes one, and **focus** on a list row jumps the chart to it.
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).
119
+ 1. Run `dtk tune --select <name>`. It opens a localhost browser view of the real
120
+ series (use `--no-open` on a remote box and share the printed 127.0.0.1 URL).
121
+ 2. Mark the real incidents one of two ways:
122
+ - **Label** mode drag a span over each incident, or **Threshold capture**
123
+ every span past a horizontal line in one gesture, or **Lasso anomalies** to
124
+ loop a cloud of anomaly dots into per-streak incident spans.
125
+ - **Review** mode the fired alerts lead; click each alert marker to confirm it
126
+ **valid** (which marks it as a ground-truth incident) or **false alarm**.
127
+ **Confirm all unreviewed valid** does the lot a clean metric whose alerts
128
+ are all good is validated in a few clicks without hand-drawing spans.
129
+ 3. Click **Save incidents**. It writes a versioned
130
+ `incidents/<metric>/<…>.yml` automatically.
131
+ 4. Run `dtk autotune --select <name>` with **no `--incidents` flag** — it
132
+ auto-discovers the newest file in `incidents/<metric>/` and tunes supervised on
133
+ it. (You can still pass `--incidents <file-or-dir>` to pick a specific set.)
149
134
 
150
135
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
151
136
  have exact times. If they already know the times (or you found them via a DB
152
137
  MCP), write the labels file / inline `incidents:` directly instead. If there are
153
138
  no known incidents, run unsupervised — the baseline below is good on its own.
154
139
 
155
- With no labels (no `--incidents`, no config `labels_file`, no interactive
156
- entry), tuning falls back to an **unsupervised** objective that blends three
140
+ With no labels (no `--incidents`, no config `labels_file`, none auto-discovered
141
+ in `incidents/<metric>/`, no interactive entry), tuning falls back to an
142
+ **unsupervised** objective that blends three
157
143
  band-fit / flag-budget terms — `0.4·budget + 0.3·sharpness + 0.3·separation`: a
158
144
  smooth one-sided flag-rate budget (no hard cliff), sharpness (a tight,
159
145
  well-calibrated confidence interval), and separation (flagged points sitting
@@ -192,8 +178,10 @@ differs from `seasonality_candidates`, which only narrows the *set of columns*
192
178
  the search may consider but still runs the search and may choose none.
193
179
 
194
180
  Label precedence (highest first): `--incidents` flag → `labels_file` → inline
195
- `incidents` → interactive prompt none. `labels_file` and `incidents` are
196
- mutually exclusive. `--scoring` likewise overrides `scoring_metric`.
181
+ `incidents` → auto-discovered `incidents/<metric>/` (the newest file, e.g. from
182
+ `dtk tune`'s **Save incidents**) → interactive prompt → none (unsupervised).
183
+ `labels_file` and `incidents` are mutually exclusive. `--scoring` likewise
184
+ overrides `scoring_metric`.
197
185
 
198
186
  ## The annotated config
199
187
 
@@ -67,8 +67,11 @@ Automatically chooses a metric's seasonality, detector type, hyperparameters and
67
67
  history window, then writes an annotated `metrics/<name>__tuned_<id>.yml`. Reads
68
68
  the metric's loaded datapoints (run `dtk run --steps load` first if empty), never
69
69
  edits the original, never alerts. `--incidents FILE` enables supervised tuning
70
- against labeled incidents; without it, an unsupervised objective is used.
71
- `--dry-run` searches without writing. `--report [PATH]` writes the same
70
+ against labeled incidents; omit it and autotune **auto-discovers** the newest
71
+ labels in `incidents/<metric>/` (the store `dtk tune`'s **Save incidents** writes
72
+ — label there in Label/Review mode, then just run `dtk autotune`); with no labels
73
+ anywhere, an unsupervised objective is used. `--dry-run` searches without writing.
74
+ `--report [PATH]` writes the same
72
75
  self-contained HTML report as `dtk run` for the tuned winner (default
73
76
  `reports/<metric>__tuned_<id>.html`; `<dir>` or a `.html` file also accepted).
74
77
  Full reference: `autotune.md`.
@@ -79,44 +79,39 @@ generalize is rejected.
79
79
  Labels turn tuning from a good unsupervised default into a config optimised
80
80
  against *your* real incidents (it then optimises MCC and also tunes the alert
81
81
  window `consecutive_anomalies`). **The easiest, most reliable way to produce them
82
- is the interactive HTML labeler — offer this first**, before asking the user to
82
+ is to mark incidents in `dtk tune` — offer this first**, before asking the user to
83
83
  recall timestamps:
84
84
 
85
85
  ```bash
86
- dtk autotune --select <name> --label
86
+ dtk tune --select <name>
87
87
  ```
88
88
 
89
- By default this starts a **local labeler server** (127.0.0.1) and opens the
90
- browser; on a remote machine add `--no-open` and share the printed URL. Walk the
91
- user through it:
89
+ This opens a localhost browser view of the metric's **real** series; on a remote
90
+ machine add `--no-open` and share the printed URL. Walk the user through it:
92
91
 
93
92
  1. Navigate a long/dense series: **scroll to zoom**, double-click to reset, drag
94
- the **navigator strip** to move the view (window = pan, edges = stretch).
95
- 2. **Click-drag across the chart** to mark each incident (red band + a row below
96
- with an optional **description**). Adjust one by dragging its **edges**, or its
97
- **middle** to move it; optionally **name the set**. Remove one by clicking its
98
- **✕** on the chart (or selecting it and pressing **Delete**) no hunting in
99
- the list; **focus** on a list row jumps the chart to that incident; *Clear all*
100
- resets.
101
- 3. When many outliers are obvious, use **Threshold capture**: set a horizontal
102
- line (hover the chart or type a value), pick **above/below**, optionally bridge
103
- small gaps, and **Add N spans** marks them all at once then tidy with the ✕.
104
- It captures within the current view by default; **drag across the chart** to
105
- limit it to a time window (so a metric that behaves differently across periods
106
- can take a different boundary in each). **↺ whole view** clears the window.
107
- 4. Click **Save & tune**. The server writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml`
108
- automatically (named after the metric, with the optional set name as a suffix;
109
- versioned — re-labeling never overwrites) and the **same command
110
- continues into the tuning run on it**. No manual file moving.
111
-
112
- **Editing an existing set:** re-running `--label` **seeds the page from the
113
- metric's newest saved set** (or from `--label --incidents <file-or-dir>`), so
114
- labeling can grow across sessions — mark a few more, save a new version. (`--no-serve`
115
- is the offline fallback: it writes a static `metrics/<name>__labeler.html` whose
116
- Export downloads a versioned file you then move into `incidents/<name>/` and pass
117
- via `--incidents`; its **Import file…** button loads a saved set back in.) To
118
- re-tune later on a saved set, point `--incidents` at `incidents/<name>/`
119
- (interactive runs let the user pick a version).
93
+ the **navigator strip** to move the view.
94
+ 2. Mark the real incidents in one of two modes:
95
+ - **Label** mode **drag a span** over each incident (edges to resize, middle
96
+ to move, ✕/Delete to remove). When many outliers are obvious, **Threshold
97
+ capture** grabs every span past a horizontal line in one gesture (set the
98
+ line, pick the side, optionally bridge gaps); **Lasso anomalies** loops a
99
+ freeform cloud of anomaly dots into one incident span per grid-adjacent run.
100
+ - **Review** mode the fired alerts lead; **click each alert marker** to cycle
101
+ its verdict un-reviewed **valid** (green) **false alarm** (slate).
102
+ **Confirming an alert valid marks it as a ground-truth incident**, and
103
+ **Confirm all unreviewed valid** does the lot so a clean metric whose alerts
104
+ are all good is validated in a few clicks without hand-drawing spans.
105
+ 3. Click **Save incidents**. It writes `incidents/<metric>/<…>.yml` automatically
106
+ (versioned — saving never overwrites). Saving doesn't end the session, so you
107
+ can keep tuning; you can also click **Apply** to write the detector config back.
108
+
109
+ **Then run autotune with no `--incidents` flag** `dtk autotune --select <name>`
110
+ **auto-discovers the newest file in `incidents/<metric>/`** (the same store
111
+ `dtk tune` saves to) and tunes supervised on it. To re-tune on a specific set,
112
+ pass `--incidents <file-or-dir>` (point it at `incidents/<name>/` and interactive
113
+ runs let the user pick a version). Labeling grows across sessions reopen
114
+ `dtk tune`, mark a few more, **Save incidents** again, re-run autotune.
120
115
 
121
116
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
122
117
  have exact timestamps — it is far easier than dictating times, and they label
@@ -158,10 +153,14 @@ is good on its own; it just can't learn *which* spikes you specifically care abo
158
153
  ## Step 3 — Run autotune
159
154
 
160
155
  ```bash
161
- # Supervised (recommended when you have incidents):
156
+ # Supervised labeled in `dtk tune`: just run it, autotune auto-discovers
157
+ # the newest file in incidents/<name>/ (no --incidents flag needed):
158
+ dtk autotune --select <name>
159
+
160
+ # Supervised — point at a specific labels file or set explicitly:
162
161
  dtk autotune --select <name> --incidents incidents/<name>.yml
163
162
 
164
- # Unsupervised (no labels — tunes on data statistics):
163
+ # Unsupervised (no labels anywhere — tunes on data statistics):
165
164
  dtk autotune --select <name>
166
165
  ```
167
166