detectkit 0.39.2__tar.gz → 0.41.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 (139) hide show
  1. {detectkit-0.39.2/detectkit.egg-info → detectkit-0.41.0}/PKG-INFO +1 -1
  2. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/webhook.py +116 -67
  4. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/alerting.md +19 -9
  5. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/cli.md +12 -6
  6. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/metrics.md +7 -0
  7. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/project.md +6 -0
  8. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/tune.py +6 -0
  9. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/config/metric_config.py +23 -0
  10. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/config/project_config.py +24 -0
  11. detectkit-0.41.0/detectkit/tuning/assets/tune.js +176 -0
  12. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/tuning/payload.py +9 -0
  13. {detectkit-0.39.2 → detectkit-0.41.0/detectkit.egg-info}/PKG-INFO +1 -1
  14. detectkit-0.39.2/detectkit/tuning/assets/tune.js +0 -171
  15. {detectkit-0.39.2 → detectkit-0.41.0}/LICENSE +0 -0
  16. {detectkit-0.39.2 → detectkit-0.41.0}/MANIFEST.in +0 -0
  17. {detectkit-0.39.2 → detectkit-0.41.0}/README.md +0 -0
  18. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/__init__.py +0 -0
  19. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/__init__.py +0 -0
  20. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/base.py +0 -0
  21. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/branding.py +0 -0
  22. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/email.py +0 -0
  23. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/factory.py +0 -0
  24. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/mattermost.py +0 -0
  25. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/slack.py +0 -0
  26. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/channels/telegram.py +0 -0
  27. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  28. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  29. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  30. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  31. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  32. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  33. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  34. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  35. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  36. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/__init__.py +0 -0
  37. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/_base.py +0 -0
  38. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/_types.py +0 -0
  39. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/autotuner.py +0 -0
  40. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/config_emitter.py +0 -0
  41. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/crossval.py +0 -0
  42. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/detector_select.py +0 -0
  43. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/distribution.py +0 -0
  44. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/grid_search.py +0 -0
  45. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/html_labeler.py +0 -0
  46. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/label_server.py +0 -0
  47. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/labels.py +0 -0
  48. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/result.py +0 -0
  49. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/scoring.py +0 -0
  50. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/seasonality_search.py +0 -0
  51. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/settings.py +0 -0
  52. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/autotune/window_select.py +0 -0
  53. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/__init__.py +0 -0
  54. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/_output.py +0 -0
  55. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  56. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  57. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  58. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  59. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  60. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  61. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  62. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  63. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/__init__.py +0 -0
  64. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/autotune.py +0 -0
  65. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/clean.py +0 -0
  66. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/init.py +0 -0
  67. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/init_claude.py +0 -0
  68. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/run.py +0 -0
  69. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/test_alert.py +0 -0
  70. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/commands/unlock.py +0 -0
  71. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/cli/main.py +0 -0
  72. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/config/__init__.py +0 -0
  73. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/config/profile.py +0 -0
  74. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/config/validator.py +0 -0
  75. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/core/__init__.py +0 -0
  76. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/core/interval.py +0 -0
  77. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/core/models.py +0 -0
  78. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/__init__.py +0 -0
  79. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/_sql_manager.py +0 -0
  80. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/clickhouse_manager.py +0 -0
  81. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/__init__.py +0 -0
  82. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  83. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  84. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_base.py +0 -0
  85. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  86. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_detections.py +0 -0
  87. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  88. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  89. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_schema.py +0 -0
  90. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  91. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/internal_tables/manager.py +0 -0
  92. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/manager.py +0 -0
  93. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/mysql_manager.py +0 -0
  94. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/postgres_manager.py +0 -0
  95. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/database/tables.py +0 -0
  96. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/__init__.py +0 -0
  97. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/base.py +0 -0
  98. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/factory.py +0 -0
  99. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/seasonality.py +0 -0
  100. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/__init__.py +0 -0
  101. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  102. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/iqr.py +0 -0
  103. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/mad.py +0 -0
  104. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  105. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/detectors/statistical/zscore.py +0 -0
  106. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/loaders/__init__.py +0 -0
  107. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/loaders/metric_loader.py +0 -0
  108. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/loaders/query_template.py +0 -0
  109. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/__init__.py +0 -0
  110. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/error_dispatch.py +0 -0
  111. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  112. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  113. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  114. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  115. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  116. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  117. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  118. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/reporting/__init__.py +0 -0
  119. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/reporting/assets/report.js +0 -0
  120. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/reporting/builder.py +0 -0
  121. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/reporting/html_report.py +0 -0
  122. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/tuning/__init__.py +0 -0
  123. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/tuning/config_writer.py +0 -0
  124. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/tuning/html.py +0 -0
  125. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/tuning/server.py +0 -0
  126. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/utils/__init__.py +0 -0
  127. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/utils/datetime_utils.py +0 -0
  128. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/utils/env_interpolation.py +0 -0
  129. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/utils/json_utils.py +0 -0
  130. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit/utils/stats.py +0 -0
  131. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit.egg-info/SOURCES.txt +0 -0
  132. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit.egg-info/dependency_links.txt +0 -0
  133. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit.egg-info/entry_points.txt +0 -0
  134. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit.egg-info/requires.txt +0 -0
  135. {detectkit-0.39.2 → detectkit-0.41.0}/detectkit.egg-info/top_level.txt +0 -0
  136. {detectkit-0.39.2 → detectkit-0.41.0}/pyproject.toml +0 -0
  137. {detectkit-0.39.2 → detectkit-0.41.0}/requirements.txt +0 -0
  138. {detectkit-0.39.2 → detectkit-0.41.0}/setup.cfg +0 -0
  139. {detectkit-0.39.2 → detectkit-0.41.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.39.2
3
+ Version: 0.41.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.39.2"
7
+ __version__ = "0.41.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.
@@ -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
@@ -104,17 +104,23 @@ the lot), and **Label** (band hides; **mark the real incidents** by drag, **Lass
104
104
  anomalies** — loop a cloud of anomaly dots, each consecutive run, gaps bridged up to
105
105
  `consecutive_anomalies`, becomes one span — or **Threshold capture** every span past
106
106
  a horizontal line, each widened to a full interval so the alert lands inside; the
107
- painted window saves as `capture_windows`). A confirmed (valid) alert is the user
108
- asserting a real incident happened there it folds in as a virtual incident (counts
109
- toward recall + correct) so a clean metric whose alerts are all good is validated in
110
- a few clicks **without hand-drawing spans**, and is written as an incident on Save.
107
+ painted window saves as `capture_windows`). **Confirming an alert valid IS marking an
108
+ incident**: the confirmed streak becomes a first-class **ground-truth incident** that
109
+ shows in the Marked-incidents list (a read-only "✓ confirmed alert" row; its
110
+ un-confirms the alert), counts toward recall + correct, and is written on Save so a
111
+ clean metric whose alerts are all good is validated in a few clicks **without
112
+ hand-drawing spans**. The list, the live metrics and Save share **one** ground-truth
113
+ set (hand-marked spans **plus** confirmed-valid alerts, deduped by overlap).
111
114
  As you tune, a metrics bar shows **incident catch rate (recall)** — how many
112
- ground-truth incidents (marked + validated) your config catches (an incident is
115
+ ground-truth incidents (marked + confirmed) your config catches (an incident is
113
116
  caught when an alert's anomaly **streak overlaps** it, not just the fire instant) —
114
117
  **false-alert rate** (what share of alerts fall outside any incident and aren't
115
118
  confirmed valid; "≈1 in N false", a decimal so a mostly-false rate doesn't round to
116
119
  a misleading "1 in 1") — and **reviewed N/M**; only incidents within the loaded
117
- window are scored. **Save incidents** writes a
120
+ window are scored. An optional **false-alert budget** (`false_alert_budget`, a
121
+ fraction in `(0, 1]`, on the **metric** then **project**, default `0.5`) gently flags
122
+ the false-alert chip when the rate exceeds it (tuning-only; labeling stays optional).
123
+ **Save incidents** writes a
118
124
  versioned `incidents/<metric>/*.yml` — the same store `dtk autotune` reads, so the
119
125
  same labels feed the next supervised tune (it seeds from the newest file on open,
120
126
  **anchoring the budget-sized loaded window on the seeded incidents** — ending just
@@ -54,8 +54,15 @@ alerting: # optional — see alerting.md
54
54
  tables: # optional — per-metric internal table overrides
55
55
  datapoints: _dtk_datapoints_api
56
56
  detections: _dtk_detections_api
57
+
58
+ false_alert_budget: 0.3 # optional — `dtk tune` target false-alert rate (0,1]; overrides project
57
59
  ```
58
60
 
61
+ `false_alert_budget` is a per-metric **target false-alert rate** (a fraction in
62
+ `(0, 1]`) the `dtk tune` cockpit gently flags when exceeded. It overrides the
63
+ project-wide default; unset → project, then a built-in `0.5`. Tuning-only — it never
64
+ affects the load/detect/alert pipeline.
65
+
59
66
  ## `interval` (required)
60
67
 
61
68
  Point spacing. String (`"30s"`, `"1min"`, `"5min"`, `"10min"`, `"1hour"`,
@@ -31,11 +31,17 @@ timeouts: # per-step, seconds
31
31
  alert: 300 # alert step (default 300)
32
32
 
33
33
  alert_help_url: null # optional, see below — "How to read this alert" link
34
+ false_alert_budget: null # optional — `dtk tune` target FDR (0,1]; per-metric override wins
34
35
 
35
36
  error_alerting: # optional, see below
36
37
  enabled: false
37
38
  ```
38
39
 
40
+ `false_alert_budget` is a project-wide **target false-alert rate** (a fraction in
41
+ `(0, 1]`, e.g. `0.3` = 30%) for manual tuning: the `dtk tune` cockpit gently flags a
42
+ metric when its false-alert rate exceeds it. A per-metric `false_alert_budget`
43
+ overrides it; unset → a built-in `0.5`. Tuning-only — it never touches the pipeline.
44
+
39
45
  ### `alert_help_url` — "How to read this alert" link
40
46
 
41
47
  Every default-rendered alert on every channel carries a `How to read this alert`
@@ -93,6 +93,11 @@ def run_tune(
93
93
 
94
94
  from_dt = parse_date(from_date) if from_date else None
95
95
  to_dt = parse_date(to_date) if to_date else None
96
+ # False-alert-rate budget for the cockpit's quality bar: metric overrides
97
+ # project; the builder falls back to a built-in default when both are unset.
98
+ budget = config.false_alert_budget
99
+ if budget is None:
100
+ budget = getattr(project_config, "false_alert_budget", None)
96
101
  # The builder resolves the window itself (recent ~TUNE_DEFAULT_POINTS by
97
102
  # default, or the explicit --from/--to span) and reads only that slice — no
98
103
  # need to pull the whole history just to find the bounds.
@@ -105,6 +110,7 @@ def run_tune(
105
110
  incidents=preload_incidents,
106
111
  capture_windows=preload_capture,
107
112
  alert_reviews=preload_reviews,
113
+ false_alert_budget=budget,
108
114
  )
109
115
  n_points = len(payload["points"])
110
116
  if n_points == 0:
@@ -612,8 +612,31 @@ class MetricConfig(BaseModel):
612
612
  autotune: AutoTuneConfig | None = Field(
613
613
  default=None, description="Optional auto-tuning constraints (for `dtk autotune`)"
614
614
  )
615
+ # Per-metric false-alert-rate (FDR) budget for manual tuning, overriding the
616
+ # project-wide `false_alert_budget`. The `dtk tune` cockpit flags — non
617
+ # intrusively — when the share of fired alerts that don't overlap a real
618
+ # incident exceeds this fraction. Tuning-only: it never affects load/detect/
619
+ # alert and labeling stays optional.
620
+ false_alert_budget: float | None = Field(
621
+ default=None,
622
+ description=(
623
+ "False-alert-rate budget (a fraction in (0, 1], e.g. 0.3 = 30%) the "
624
+ "`dtk tune` cockpit flags when exceeded. Overrides the project-wide "
625
+ "default; unset → fall back to project, then a built-in default."
626
+ ),
627
+ )
615
628
  enabled: bool = Field(default=True, description="Whether metric is enabled")
616
629
 
630
+ @field_validator("false_alert_budget")
631
+ @classmethod
632
+ def validate_false_alert_budget(cls, v: float | None) -> float | None:
633
+ """A budget, if set, is a false-alert-rate fraction in ``(0, 1]``."""
634
+ if v is None:
635
+ return v
636
+ if not 0.0 < v <= 1.0:
637
+ raise ValueError("false_alert_budget must be a fraction in (0, 1] (e.g. 0.3 = 30%)")
638
+ return v
639
+
617
640
  # Parsed interval (computed from string/int)
618
641
  _interval: Interval | None = None
619
642
 
@@ -196,6 +196,30 @@ class ProjectConfig(BaseModel):
196
196
  "or false to hide it."
197
197
  ),
198
198
  )
199
+ # Project-wide default false-alert-rate (FDR) budget for manual tuning. The
200
+ # `dtk tune` cockpit flags — non-intrusively — when the share of fired alerts
201
+ # that don't overlap a real incident exceeds this fraction, so you have a
202
+ # target to tune against. A per-metric `false_alert_budget` overrides it.
203
+ # Labeling stays optional: the budget only colours an already-computed number,
204
+ # it never blocks anything or affects the load/detect/alert pipeline.
205
+ false_alert_budget: float | None = Field(
206
+ default=None,
207
+ description=(
208
+ "Default false-alert-rate budget (a fraction in (0, 1], e.g. 0.3 = "
209
+ "30%) the `dtk tune` cockpit flags when exceeded. Per-metric "
210
+ "`false_alert_budget` takes priority; unset → a built-in default."
211
+ ),
212
+ )
213
+
214
+ @field_validator("false_alert_budget")
215
+ @classmethod
216
+ def validate_false_alert_budget(cls, v: float | None) -> float | None:
217
+ """A budget, if set, is a false-alert-rate fraction in ``(0, 1]``."""
218
+ if v is None:
219
+ return v
220
+ if not 0.0 < v <= 1.0:
221
+ raise ValueError("false_alert_budget must be a fraction in (0, 1] (e.g. 0.3 = 30%)")
222
+ return v
199
223
 
200
224
  @field_validator("alert_help_url")
201
225
  @classmethod