detectkit 0.45.1__tar.gz → 0.46.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.45.1/detectkit.egg-info → detectkit-0.46.0}/PKG-INFO +1 -1
  2. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/webhook.py +145 -129
  4. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/alerting.md +20 -20
  5. {detectkit-0.45.1 → detectkit-0.46.0/detectkit.egg-info}/PKG-INFO +1 -1
  6. {detectkit-0.45.1 → detectkit-0.46.0}/LICENSE +0 -0
  7. {detectkit-0.45.1 → detectkit-0.46.0}/MANIFEST.in +0 -0
  8. {detectkit-0.45.1 → detectkit-0.46.0}/README.md +0 -0
  9. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/__init__.py +0 -0
  10. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/__init__.py +0 -0
  11. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/base.py +0 -0
  12. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/branding.py +0 -0
  13. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/email.py +0 -0
  14. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/factory.py +0 -0
  15. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/mattermost.py +0 -0
  16. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/slack.py +0 -0
  17. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/channels/telegram.py +0 -0
  18. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  19. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  20. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  21. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  22. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  23. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  24. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  25. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  26. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  27. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/__init__.py +0 -0
  28. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/_base.py +0 -0
  29. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/_types.py +0 -0
  30. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/autotuner.py +0 -0
  31. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/config_emitter.py +0 -0
  32. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/crossval.py +0 -0
  33. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/detector_select.py +0 -0
  34. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/distribution.py +0 -0
  35. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/grid_search.py +0 -0
  36. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/labels.py +0 -0
  37. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/result.py +0 -0
  38. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/runner.py +0 -0
  39. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/scoring.py +0 -0
  40. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/seasonality_search.py +0 -0
  41. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/settings.py +0 -0
  42. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/autotune/window_select.py +0 -0
  43. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/__init__.py +0 -0
  44. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/_output.py +0 -0
  45. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  46. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  47. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  48. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  49. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  50. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  51. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  52. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  53. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  54. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  55. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  56. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  57. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/__init__.py +0 -0
  58. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/autotune.py +0 -0
  59. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/clean.py +0 -0
  60. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/init.py +0 -0
  61. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/init_claude.py +0 -0
  62. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/run.py +0 -0
  63. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/test_alert.py +0 -0
  64. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/tune.py +0 -0
  65. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/commands/unlock.py +0 -0
  66. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/cli/main.py +0 -0
  67. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/config/__init__.py +0 -0
  68. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/config/metric_config.py +0 -0
  69. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/config/profile.py +0 -0
  70. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/config/project_config.py +0 -0
  71. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/config/validator.py +0 -0
  72. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/core/__init__.py +0 -0
  73. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/core/interval.py +0 -0
  74. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/core/models.py +0 -0
  75. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/__init__.py +0 -0
  76. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/_sql_manager.py +0 -0
  77. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/clickhouse_manager.py +0 -0
  78. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/__init__.py +0 -0
  79. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  80. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  81. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  83. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_detections.py +0 -0
  84. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  85. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  86. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_schema.py +0 -0
  87. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  88. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/internal_tables/manager.py +0 -0
  89. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/manager.py +0 -0
  90. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/mysql_manager.py +0 -0
  91. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/postgres_manager.py +0 -0
  92. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/database/tables.py +0 -0
  93. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/__init__.py +0 -0
  94. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/base.py +0 -0
  95. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/factory.py +0 -0
  96. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/seasonality.py +0 -0
  97. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/__init__.py +0 -0
  98. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  99. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/iqr.py +0 -0
  100. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/mad.py +0 -0
  101. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  102. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/detectors/statistical/zscore.py +0 -0
  103. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/loaders/__init__.py +0 -0
  104. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/loaders/metric_loader.py +0 -0
  105. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/loaders/query_template.py +0 -0
  106. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/__init__.py +0 -0
  107. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/error_dispatch.py +0 -0
  108. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  109. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  110. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  111. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  112. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  113. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  114. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  115. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/reporting/__init__.py +0 -0
  116. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/reporting/assets/report.js +0 -0
  117. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/reporting/builder.py +0 -0
  118. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/reporting/html_report.py +0 -0
  119. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/__init__.py +0 -0
  120. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/assets/tune.js +0 -0
  121. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/config_writer.py +0 -0
  122. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/html.py +0 -0
  123. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/payload.py +0 -0
  124. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/tuning/server.py +0 -0
  125. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/utils/__init__.py +0 -0
  126. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/utils/datetime_utils.py +0 -0
  127. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/utils/env_interpolation.py +0 -0
  128. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/utils/json_utils.py +0 -0
  129. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit/utils/stats.py +0 -0
  130. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit.egg-info/SOURCES.txt +0 -0
  131. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit.egg-info/dependency_links.txt +0 -0
  132. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit.egg-info/entry_points.txt +0 -0
  133. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit.egg-info/requires.txt +0 -0
  134. {detectkit-0.45.1 → detectkit-0.46.0}/detectkit.egg-info/top_level.txt +0 -0
  135. {detectkit-0.45.1 → detectkit-0.46.0}/pyproject.toml +0 -0
  136. {detectkit-0.45.1 → detectkit-0.46.0}/requirements.txt +0 -0
  137. {detectkit-0.45.1 → detectkit-0.46.0}/setup.cfg +0 -0
  138. {detectkit-0.45.1 → detectkit-0.46.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.45.1
3
+ Version: 0.46.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.45.1"
7
+ __version__ = "0.46.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -25,22 +25,23 @@ class WebhookChannel(BaseAlertChannel):
25
25
  - Slack incoming webhooks
26
26
  - Custom webhook endpoints
27
27
 
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.
28
+ Rendering: the default (no custom ``template``) payload is a **single
29
+ status-colored Slack/Mattermost attachment** whose whole body rides in one
30
+ markdown ``text`` block, ordered most-important-first the lead + **Rule**
31
+ chip, then Value / Expected, the action **Links**, and finally the verbose
32
+ evidence tail (Quorum / Severity / the anomalous span / Detectors /
33
+ Parameters). Both platforms natively collapse a long attachment ``text``
34
+ behind a **"Show more"** toggle (Slack above 700 characters / 5 line breaks;
35
+ Mattermost wraps only the ``text`` in its ``maxHeight`` 200px fold — the
36
+ ``title``, the color bar and the ``footer`` render *outside* that fold), so a
37
+ long anomaly folds its tail exactly like a reference AlertManager alert while
38
+ the one colored bar, the clickable title and the **branded footer (with the
39
+ logo)** stay in view even when the body is collapsed. No-data / error alerts
40
+ stay short, single un-folded cards; a long anomaly or a full recovery
41
+ timeline (onset fired recovered) folds its tail. A custom ``template``
42
+ renders the same shape one colored, branded, text-only attachment.
43
+ Branding (``footer`` + ``footer_icon``, the brand logo) rides on that single
44
+ attachment and, being outside the text fold, is always visible.
44
45
 
45
46
  Mentions ride in the **top-level** ``text`` (mentions inside attachments do
46
47
  not reliably notify on Slack). A ``dashboard_url`` makes the attachment
@@ -55,10 +56,16 @@ class WebhookChannel(BaseAlertChannel):
55
56
  "text": "<!here> ...", # mentions (top level)
56
57
  "channel": "#channel", # optional (Slack)
57
58
  "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"]},
59
+ # one colored card; the long body folds behind "Show more",
60
+ # the footer (brand + logo) stays visible below the fold.
61
+ {
62
+ "color": ...,
63
+ "title": ...,
64
+ "text": "<lead + Rule + value/expected + links + tail>",
65
+ "footer": "detectkit · <project>",
66
+ "footer_icon": "https://.../bot-icon.png",
67
+ "mrkdwn_in": ["text"],
68
+ },
62
69
  ]
63
70
  }
64
71
 
@@ -134,8 +141,9 @@ class WebhookChannel(BaseAlertChannel):
134
141
  Args:
135
142
  alert_data: Alert data to send
136
143
  template: Optional custom message template. When given, the
137
- attachment carries the formatted template as a single text
138
- blob (no fields grid); otherwise the rich default is built.
144
+ attachment carries the formatted template verbatim as the body
145
+ text (in place of the structured lead/Value/tail sections);
146
+ otherwise the rich default is built.
139
147
 
140
148
  Returns:
141
149
  True if sent successfully, False otherwise
@@ -199,16 +207,18 @@ class WebhookChannel(BaseAlertChannel):
199
207
  else:
200
208
  attachments = self._build_rich_attachments(alert_data, ctx, color, title)
201
209
 
202
- # Dashboard link → clickable title on the base (first) attachment.
210
+ # Dashboard link → clickable title on the attachment.
203
211
  if alert_data.dashboard_url:
204
212
  attachments[0]["title_link"] = alert_data.dashboard_url
205
213
 
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.
214
+ # Brand the (single) attachment's footer (reliable on Slack even when
215
+ # top-level username/icon are locked to the app install). Mattermost and
216
+ # Slack render an attachment's footer *outside* the "Show more" text
217
+ # fold, so the brand line and its logo (``footer_icon``) stays
218
+ # visible at the bottom of the message even when the body is collapsed.
219
+ # Pair the brand name with the project name when set
220
+ # ("detectkit · my_project") so two projects posting to the same channel
221
+ # stay distinguishable even past the title.
212
222
  footer = self.username or BRAND_USERNAME
213
223
  if alert_data.project_name:
214
224
  footer = f"{footer} · {alert_data.project_name}"
@@ -252,114 +262,131 @@ class WebhookChannel(BaseAlertChannel):
252
262
  color: str,
253
263
  title: str,
254
264
  ) -> 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.
265
+ """Build the default **single** attachment for *alert_data*.
266
+
267
+ Everything rides in one status-colored attachment whose body is a single
268
+ markdown ``text`` block, ordered most-important-first: the lead + Rule
269
+ chip, then value / expected, the action links, and finally the verbose
270
+ evidence tail (quorum, severity, the anomalous span, detectors,
271
+ parameters). Mattermost wraps only an attachment's ``text`` in its
272
+ "Show more" fold (``maxHeight`` 200px) and renders the ``title``, color
273
+ bar and ``footer`` outside it; Slack folds a long ``text`` too. So a long
274
+ anomaly collapses its tail behind "Show more" exactly the one-block,
275
+ one-color, foldable layout of a reference AlertManager alert — while the
276
+ colored bar, the clickable title and the branded footer/logo (attached in
277
+ :meth:`build_payload`) stay in view even when collapsed. No-data / error
278
+ stay short, single un-folded cards; a long anomaly (or a full recovery
279
+ timeline) folds its tail.
266
280
  """
267
281
  kind = self.status_kind(alert_data)
268
- base_fields: list[dict[str, Any]] = []
269
- detail_lines: list[str] = []
270
-
271
- def short(name: str, value: str) -> None:
272
- """Add a 2-col field to the always-visible base card."""
273
- if value:
274
- base_fields.append({"title": name, "value": value, "short": True})
275
-
276
- def full(name: str, value: str) -> None:
277
- """Add a full-width field to the always-visible base card."""
278
- if value:
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}")
285
282
 
286
283
  def code(s: str) -> str:
287
284
  return f"`{s}`" if s else ""
288
285
 
286
+ def line(name: str, value: str) -> str:
287
+ """One ``bold label + value`` body line (empty value → dropped)."""
288
+ return f"{self._bold(name)} {value}" if value else ""
289
+
289
290
  # The configured firing rule, set apart as a bold "Rule" label + an
290
291
  # inline-code chip so it reads as "this is the config that fired" at a
291
292
  # glance. Backticks render identically on Slack and Mattermost; the bold
292
- # label is platform-aware (see ``_bold``). Stays in the always-visible
293
- # base lead.
293
+ # label is platform-aware (see ``_bold``). Leads the body, above the fold.
294
294
  rule_chip = f"{self._bold('Rule')} " + code(
295
295
  f"min_detectors={ctx['min_detectors']} · "
296
296
  f"direction={ctx['direction_policy']} · "
297
297
  f"consecutive={ctx['consecutive_required']}"
298
298
  )
299
299
 
300
+ # Links — compact clickable labels (never raw URL strings: a Grafana URL
301
+ # can be a paragraph long once it carries variables), rendered in the
302
+ # platform's link syntax and joined by " · ". Kept high in the body so
303
+ # they stay above the fold and actionable at a glance. (The title is also
304
+ # a clickable dashboard link.)
305
+ link_parts: list[str] = []
306
+ if alert_data.dashboard_url:
307
+ link_parts.append(self._link_markup(alert_data.dashboard_url, "Dashboard"))
308
+ for label, url in alert_data.links.items():
309
+ link_parts.append(self._link_markup(url, label))
310
+ if ctx["help_url"]:
311
+ link_parts.append(self._link_markup(ctx["help_url"], ctx["help_label"]))
312
+ links_line = line("Links", " · ".join(link_parts)) if link_parts else ""
313
+
314
+ # The body is built as sections joined by a blank line; the verbose tail
315
+ # is last so the platform fold hides it first.
316
+ sections: list[list[str]] = []
300
317
  if kind == "anomaly":
301
- # Base (always visible): the lead (how long it's been going on), the
302
- # Rule chip, and the value vs the expected band.
303
- lead = f"{ctx['anomaly_lead']}\n{rule_chip}"
304
- short("Value", code(ctx["value_display"]))
305
- short("Expected", code(ctx["expected_range"]))
306
- # Detail (folds): quorum, severity, the span, detectors + params.
307
- detail(
308
- "Quorum",
309
- f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
318
+ sections.append([ctx["anomaly_lead"], rule_chip])
319
+ sections.append(
320
+ [
321
+ line("Value", code(ctx["value_display"])),
322
+ line("Expected", code(ctx["expected_range"])),
323
+ links_line,
324
+ ]
310
325
  )
311
- detail("Severity", f"{alert_data.severity:.2f}")
326
+ tail = [
327
+ line(
328
+ "Quorum",
329
+ f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
330
+ ),
331
+ line("Severity", f"{alert_data.severity:.2f}"),
332
+ ]
312
333
  if ctx["started_display"]:
313
- detail("Anomaly began", ctx["started_display"])
314
- detail("Latest reading", ctx["timestamp"])
334
+ tail.append(line("Anomaly began", ctx["started_display"]))
335
+ tail.append(line("Latest reading", ctx["timestamp"]))
315
336
  else:
316
- detail("Detected at", ctx["timestamp"])
317
- detail("Detectors", code(ctx["detector_name"]))
337
+ tail.append(line("Detected at", ctx["timestamp"]))
338
+ tail.append(line("Detectors", code(ctx["detector_name"])))
318
339
  if ctx["detector_params"]:
319
340
  # Fenced block on its own lines so it renders as a code block (and
320
341
  # 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
- )
342
+ tail.append(f"{self._bold('Parameters')}\n```\n{ctx['detector_params']}\n```")
343
+ sections.append(tail)
324
344
  elif kind == "recovery":
325
- lead = f"{ctx['recovery_lead']}\n{rule_chip}"
326
- short("Value", code(ctx["value_display"]))
327
- short("Expected", code(ctx["expected_range"]))
328
- # Detail (folds): the incident timeline (onset → fired → recovered)
329
- # and the detectors. ``detail`` skips the fired line when unknown.
345
+ sections.append([ctx["recovery_lead"], rule_chip])
346
+ sections.append(
347
+ [
348
+ line("Value", code(ctx["value_display"])),
349
+ line("Expected", code(ctx["expected_range"])),
350
+ links_line,
351
+ ]
352
+ )
353
+ # The incident timeline (onset → fired → recovered) + detectors;
354
+ # ``line`` drops the fired entry when unknown.
330
355
  if ctx["started_display"]:
331
- detail("Anomaly began", ctx["started_display"])
332
- detail("Alert fired", ctx["fired_display"])
333
- detail("Recovered", ctx["timestamp"])
356
+ tail = [
357
+ line("Anomaly began", ctx["started_display"]),
358
+ line("Alert fired", ctx["fired_display"]),
359
+ line("Recovered", ctx["timestamp"]),
360
+ ]
334
361
  else:
335
- detail("Cleared at", ctx["timestamp"])
336
- detail("Detectors", code(ctx["detector_name"]))
362
+ tail = [line("Cleared at", ctx["timestamp"])]
363
+ tail.append(line("Detectors", code(ctx["detector_name"])))
364
+ sections.append(tail)
337
365
  elif kind == "no_data":
338
- lead = "Query returned no datapoint for the latest expected interval."
339
- full("Expected at", ctx["timestamp"])
340
- short("Expected", code(ctx["expected_range"]))
366
+ sections.append(["Query returned no datapoint for the latest expected interval."])
367
+ sections.append(
368
+ [
369
+ line("Expected at", ctx["timestamp"]),
370
+ line("Expected", code(ctx["expected_range"])),
371
+ links_line,
372
+ ]
373
+ )
341
374
  else: # error
342
- lead = "The detectkit pipeline failed for this metric."
343
- full("Detected at", ctx["timestamp"])
344
375
  err = f"{ctx['error_type']}: {ctx['error_message']}".strip(": ")
345
- full("Error", code(err))
346
-
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.)
354
- link_parts = []
355
- if alert_data.dashboard_url:
356
- link_parts.append(self._link_markup(alert_data.dashboard_url, "Dashboard"))
357
- for label, url in alert_data.links.items():
358
- link_parts.append(self._link_markup(url, label))
359
- if ctx["help_url"]:
360
- link_parts.append(self._link_markup(ctx["help_url"], ctx["help_label"]))
361
- if link_parts:
362
- full("Links", " · ".join(link_parts))
376
+ sections.append(["The detectkit pipeline failed for this metric."])
377
+ sections.append(
378
+ [
379
+ line("Detected at", ctx["timestamp"]),
380
+ line("Error", code(err)),
381
+ links_line,
382
+ ]
383
+ )
384
+
385
+ text = "\n\n".join(
386
+ "\n".join(entry for entry in section if entry)
387
+ for section in sections
388
+ if any(entry for entry in section)
389
+ )
363
390
 
364
391
  # A plain-text one-liner for notification previews / unsupported clients.
365
392
  if kind == "no_data":
@@ -372,26 +399,15 @@ class WebhookChannel(BaseAlertChannel):
372
399
  f"(expected {ctx['expected_range']}) at {ctx['timestamp']}"
373
400
  )
374
401
 
375
- base: dict[str, Any] = {
376
- "fallback": fallback,
377
- "color": color,
378
- "title": title,
379
- "text": lead,
380
- "fields": base_fields,
381
- "mrkdwn_in": ["text", "fields"],
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
402
+ return [
403
+ {
404
+ "fallback": fallback,
405
+ "color": color,
406
+ "title": title,
407
+ "text": text,
408
+ "mrkdwn_in": ["text"],
409
+ }
410
+ ]
395
411
 
396
412
  def _bold(self, text: str) -> str:
397
413
  """Render *text* bold in the target platform's markdown.
@@ -167,7 +167,7 @@ config needed. Control it project-wide with `alert_help_url` in
167
167
  Per-channel rendering (defaults only; the resolved help URL is rendered per channel as follows):
168
168
 
169
169
  - **Slack / Mattermost / generic webhook** — a clickable `How to read this alert`
170
- label in the compact `Links` field (alongside `Dashboard` + any extra links),
170
+ label in the compact `Links` line (alongside `Dashboard` + any extra links),
171
171
  never a raw URL. Rendered in the platform's link syntax (Slack `<url|label>`,
172
172
  Mattermost/generic markdown links) so a long dashboard URL stays hidden behind
173
173
  its label.
@@ -191,25 +191,25 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
191
191
  **project name** as a `[name] ` prefix (from `detectkit_project.yml`) — see
192
192
  [Project label](#project-label-multi-project-channels) below.
193
193
 
194
- - **Slack / Mattermost / generic webhook** — an anomaly/recovery renders as
195
- **two stacked attachments** so long alerts fold in the channel. Both platforms
196
- collapse only an attachment's `text` block behind a "Show more" toggle (Slack
197
- above 700 chars / 5 line breaks; Mattermost above ~200px) and never collapse
198
- the `fields` grid, so the message splits into:
199
- - an always-visible **base card** status-colored accent bar, a clickable
200
- title (the metric; links to `dashboard_url` when set), a short markdown lead
201
- (the duration sentence see "Incident timing" below) with the **Rule** chip
202
- beneath it, and a compact fields grid kept to **Value / Expected** plus an
203
- always-visible compact **Links** field (dashboard + extra links + the "how to
204
- read this alert" guide as clickable labels);
205
- - a neutral, foldable **detail card** the verbose tail as one markdown
206
- `text` block: Quorum / Severity / the anomalous span (Anomaly began → Latest
207
- reading; began fired recovered on recovery) / Detectors / Parameters.
208
-
209
- No-data / error stay a single base card. The branded footer + footer icon ride
210
- on the **last** attachment. @mentions ride in the **top-level** message text so
211
- they notify. A custom `template` instead renders as a single plain text-only
212
- attachment (color/title/branding kept, no fields grid, no fold split).
194
+ - **Slack / Mattermost / generic webhook** — an alert renders as **one
195
+ status-colored attachment** whose whole body is a single markdown `text` block,
196
+ ordered **most-important-first** so a long alert folds its tail behind a
197
+ **"Show more"** toggle one block, one color, just like a reference
198
+ AlertManager alert. The body order: the clickable title (the metric; links to
199
+ `dashboard_url` when set), then the markdown lead (the duration sentence — see
200
+ "Incident timing" below) with the **Rule** chip beneath it, **Value /
201
+ Expected**, the compact **Links** line (dashboard + extra links + the "how to
202
+ read this alert" guide as clickable labels, never raw URLs), and finally the
203
+ verbose tail (Quorum / Severity / the anomalous span Anomaly began → Latest
204
+ reading; began fired recovered on recovery — / Detectors / Parameters).
205
+ Both clients fold **only** the `text` (Slack above 700 chars / 5 line breaks;
206
+ Mattermost above ~200px) and render the title, the color bar and the **footer**
207
+ *outside* the fold so the branded **footer + footer icon (the logo)** stays
208
+ visible even when the body is collapsed. No-data / error stay short, single
209
+ un-folded cards; a long anomaly (or a full recovery timeline) folds its tail.
210
+ @mentions ride in the **top-level** message text so they notify. A custom
211
+ `template` renders as a single plain text-only attachment (the raw template
212
+ replaces the structured lead/Value/tail sections; color/title/branding kept).
213
213
  - **Telegram** — default `parse_mode` is now **HTML**. The default message is
214
214
  structured and HTML-escaped: a colored status dot (red anomaly / green
215
215
  recovery / yellow no-data / blue error), a bold headline, the lead + rule, then
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.45.1
3
+ Version: 0.46.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes