detectkit 0.15.0__tar.gz → 0.16.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 (105) hide show
  1. {detectkit-0.15.0/detectkit.egg-info → detectkit-0.16.0}/PKG-INFO +1 -1
  2. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/base.py +20 -0
  4. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/branding.py +10 -0
  5. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/email.py +15 -2
  6. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/telegram.py +5 -0
  7. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/webhook.py +9 -0
  8. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_base.py +5 -0
  9. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_decision.py +2 -0
  10. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
  11. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/alerting.md +30 -0
  12. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/project.md +21 -0
  13. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/test_alert.py +13 -1
  14. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/config/project_config.py +58 -0
  15. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/error_dispatch.py +6 -0
  16. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_alert_step.py +7 -0
  17. {detectkit-0.15.0 → detectkit-0.16.0/detectkit.egg-info}/PKG-INFO +1 -1
  18. {detectkit-0.15.0 → detectkit-0.16.0}/LICENSE +0 -0
  19. {detectkit-0.15.0 → detectkit-0.16.0}/MANIFEST.in +0 -0
  20. {detectkit-0.15.0 → detectkit-0.16.0}/README.md +0 -0
  21. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/__init__.py +0 -0
  22. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/__init__.py +0 -0
  23. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/factory.py +0 -0
  24. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/mattermost.py +0 -0
  25. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/channels/slack.py +0 -0
  26. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  27. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  28. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  29. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  30. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  31. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/__init__.py +0 -0
  32. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/_output.py +0 -0
  33. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  34. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  35. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  36. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  37. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  38. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  39. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  40. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  41. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/__init__.py +0 -0
  42. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/clean.py +0 -0
  43. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/init.py +0 -0
  44. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/init_claude.py +0 -0
  45. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/run.py +0 -0
  46. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/commands/unlock.py +0 -0
  47. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/cli/main.py +0 -0
  48. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/config/__init__.py +0 -0
  49. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/config/metric_config.py +0 -0
  50. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/config/profile.py +0 -0
  51. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/config/validator.py +0 -0
  52. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/core/__init__.py +0 -0
  53. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/core/interval.py +0 -0
  54. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/core/models.py +0 -0
  55. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/__init__.py +0 -0
  56. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/_sql_manager.py +0 -0
  57. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/clickhouse_manager.py +0 -0
  58. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/__init__.py +0 -0
  59. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  60. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_base.py +0 -0
  61. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  62. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_detections.py +0 -0
  63. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  64. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  65. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_schema.py +0 -0
  66. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  67. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/internal_tables/manager.py +0 -0
  68. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/manager.py +0 -0
  69. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/mysql_manager.py +0 -0
  70. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/postgres_manager.py +0 -0
  71. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/database/tables.py +0 -0
  72. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/__init__.py +0 -0
  73. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/base.py +0 -0
  74. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/factory.py +0 -0
  75. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/seasonality.py +0 -0
  76. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/__init__.py +0 -0
  77. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  78. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/iqr.py +0 -0
  79. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/mad.py +0 -0
  80. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  81. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/detectors/statistical/zscore.py +0 -0
  82. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/loaders/__init__.py +0 -0
  83. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/loaders/metric_loader.py +0 -0
  84. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/loaders/query_template.py +0 -0
  85. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/__init__.py +0 -0
  86. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  87. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  88. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  89. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  90. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  91. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  92. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/utils/__init__.py +0 -0
  93. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/utils/datetime_utils.py +0 -0
  94. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/utils/env_interpolation.py +0 -0
  95. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/utils/json_utils.py +0 -0
  96. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit/utils/stats.py +0 -0
  97. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit.egg-info/SOURCES.txt +0 -0
  98. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit.egg-info/dependency_links.txt +0 -0
  99. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit.egg-info/entry_points.txt +0 -0
  100. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit.egg-info/requires.txt +0 -0
  101. {detectkit-0.15.0 → detectkit-0.16.0}/detectkit.egg-info/top_level.txt +0 -0
  102. {detectkit-0.15.0 → detectkit-0.16.0}/pyproject.toml +0 -0
  103. {detectkit-0.15.0 → detectkit-0.16.0}/requirements.txt +0 -0
  104. {detectkit-0.15.0 → detectkit-0.16.0}/setup.cfg +0 -0
  105. {detectkit-0.15.0 → detectkit-0.16.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.15.0
3
+ Version: 0.16.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.15.0"
7
+ __version__ = "0.16.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -9,6 +9,8 @@ from abc import ABC, abstractmethod
9
9
  from dataclasses import dataclass, field
10
10
  from typing import Any
11
11
 
12
+ from detectkit.alerting.channels.branding import ALERT_GUIDE_LABEL
13
+
12
14
 
13
15
  @dataclass
14
16
  class AlertData:
@@ -79,6 +81,12 @@ class AlertData:
79
81
  # callers and templates render unchanged.
80
82
  dashboard_url: str | None = None
81
83
  links: dict[str, str] = field(default_factory=dict)
84
+ # "How to read this alert" link surfaced on every default-rendered message so
85
+ # non-operator stakeholders can click through to a plain-language guide. The
86
+ # orchestrator resolves it from ``ProjectConfig.alert_help_url`` (defaulting to
87
+ # the official docs); direct-API callers leave it ``None`` and render unchanged.
88
+ # Exposed to templates as ``{help_url}`` / ``{help_line}``.
89
+ help_url: str | None = None
82
90
  # Alert rule (the parameters the alert fired with) — see class docstring.
83
91
  min_detectors: int | None = None
84
92
  direction_policy: str | None = None
@@ -300,6 +308,11 @@ class BaseAlertChannel(ABC):
300
308
  dashboard_url = alert_data.dashboard_url or ""
301
309
  dashboard_line = f"Dashboard: {dashboard_url}\n" if dashboard_url else ""
302
310
 
311
+ # "How to read this alert" link (same shape as dashboard): a raw
312
+ # placeholder plus a ready-to-drop line, both empty when unset.
313
+ help_url = alert_data.help_url or ""
314
+ help_line = f"{ALERT_GUIDE_LABEL}: {help_url}\n" if help_url else ""
315
+
303
316
  # Project name + synth prefix for templates. Prefix is empty when
304
317
  # project_name is None so default templates render cleanly for
305
318
  # callers that don't set it.
@@ -344,6 +357,9 @@ class BaseAlertChannel(ABC):
344
357
  "description_line": description_line,
345
358
  "dashboard_url": dashboard_url,
346
359
  "dashboard_line": dashboard_line,
360
+ "help_url": help_url,
361
+ "help_line": help_line,
362
+ "help_label": ALERT_GUIDE_LABEL,
347
363
  "mentions": mentions_str,
348
364
  "mentions_line": mentions_line,
349
365
  }
@@ -469,6 +485,7 @@ class BaseAlertChannel(ABC):
469
485
  "Detectors: {detector_name}\n"
470
486
  "Parameters: {detector_params}\n"
471
487
  "{dashboard_line}"
488
+ "{help_line}"
472
489
  "{mentions_line}"
473
490
  )
474
491
 
@@ -492,6 +509,7 @@ class BaseAlertChannel(ABC):
492
509
  "· Value: {value_display} | Expected: {expected_range}\n"
493
510
  "Detectors: {detector_name}\n"
494
511
  "{dashboard_line}"
512
+ "{help_line}"
495
513
  "{mentions_line}"
496
514
  )
497
515
 
@@ -528,6 +546,7 @@ class BaseAlertChannel(ABC):
528
546
  "Time: {timestamp}\n"
529
547
  "Status: query returned no datapoint for the latest interval\n"
530
548
  "{dashboard_line}"
549
+ "{help_line}"
531
550
  "{mentions_line}"
532
551
  )
533
552
 
@@ -543,6 +562,7 @@ class BaseAlertChannel(ABC):
543
562
  "Time: {timestamp}\n"
544
563
  "Error: {error_type}: {error_message}\n"
545
564
  "{dashboard_line}"
565
+ "{help_line}"
546
566
  "{mentions_line}"
547
567
  )
548
568
 
@@ -18,3 +18,13 @@ BRAND_USERNAME = "detectkit"
18
18
  # or opt out of the avatar entirely with ``icon_emoji``. The PNG is generated by
19
19
  # ``website/scripts/make-bot-icon.mjs`` and served from ``website/public/``.
20
20
  BRAND_ICON_URL = "https://dtk.pipelab.dev/bot-icon.png"
21
+
22
+ # Default "how to read this alert" link surfaced on every default-rendered alert,
23
+ # so non-operator stakeholders (PMs, analysts, on-call) seeing a notification can
24
+ # click through to a plain-language guide explaining what they're looking at. It
25
+ # points at the official docs page by default; a project can redirect it to its
26
+ # own runbook (or hide it) via ``ProjectConfig.alert_help_url`` — the resolved URL
27
+ # is stamped onto ``AlertData.help_url`` by the orchestrator. ``ALERT_GUIDE_LABEL``
28
+ # is the shared link text so every channel reads the same.
29
+ BRAND_ALERT_GUIDE_URL = "https://dtk.pipelab.dev/guides/reading-alerts/"
30
+ ALERT_GUIDE_LABEL = "How to read this alert"
@@ -12,7 +12,11 @@ from email.utils import formataddr
12
12
  from typing import Any
13
13
 
14
14
  from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
15
- from detectkit.alerting.channels.branding import BRAND_ICON_URL, BRAND_USERNAME
15
+ from detectkit.alerting.channels.branding import (
16
+ ALERT_GUIDE_LABEL,
17
+ BRAND_ICON_URL,
18
+ BRAND_USERNAME,
19
+ )
16
20
 
17
21
  # Brand palette as hex literals. Email clients (Outlook/Word engine) ignore CSS
18
22
  # custom properties, so the values from website/src/styles/brand.css are copied
@@ -458,11 +462,20 @@ class EmailChannel(BaseAlertChannel):
458
462
  project_html = (
459
463
  f" · {html.escape(alert_data.project_name)}" if alert_data.project_name else ""
460
464
  )
465
+ # "How to read this alert" — a clay footer link to the interpretation
466
+ # guide (empty when opted out via alert_help_url: false).
467
+ help_html = ""
468
+ if alert_data.help_url:
469
+ href = html.escape(alert_data.help_url, quote=True)
470
+ help_html = (
471
+ f' &middot; <a href="{href}" style="color:{_CLAY};text-decoration:none;">'
472
+ f"{html.escape(ALERT_GUIDE_LABEL)} &rarr;</a>"
473
+ )
461
474
  return (
462
475
  f'<tr><td bgcolor="{_SURFACE}" style="background-color:{_SURFACE};'
463
476
  f"border-top:1px solid {_BORDER};padding:14px 24px;font-family:{_SANS};"
464
477
  f'font-size:12px;color:{_FAINT};mso-line-height-rule:exactly;line-height:16px;">'
465
- f"Sent by detectkit{project_html}{cc_html}</td></tr>"
478
+ f"Sent by detectkit{project_html}{cc_html}{help_html}</td></tr>"
466
479
  )
467
480
 
468
481
  def format_mentions(self, mentions: list[str]) -> str:
@@ -219,6 +219,11 @@ class TelegramChannel(BaseAlertChannel):
219
219
  for label, url in alert_data.links.items():
220
220
  href = html.escape(url, quote=True)
221
221
  link_parts.append(f'<a href="{href}">{esc(label)}</a>')
222
+ # "How to read this alert" — always present (unless opted out), so a
223
+ # stakeholder can click through to the interpretation guide.
224
+ if ctx["help_url"]:
225
+ href = html.escape(ctx["help_url"], quote=True)
226
+ link_parts.append(f'<a href="{href}">{esc(ctx["help_label"])}</a>')
222
227
  if link_parts:
223
228
  lines.append("")
224
229
  lines.append(" · ".join(link_parts))
@@ -303,6 +303,11 @@ class WebhookChannel(BaseAlertChannel):
303
303
  if link_lines:
304
304
  full("Links", "\n".join(link_lines))
305
305
 
306
+ # "How to read this alert" — the last field, a bare URL (auto-linkified on
307
+ # both Slack and Mattermost) pointing readers at the interpretation guide.
308
+ if ctx["help_url"]:
309
+ full(ctx["help_label"], ctx["help_url"])
310
+
306
311
  # A plain-text one-liner for notification previews / unsupported clients.
307
312
  if kind == "no_data":
308
313
  fallback = f"{title} at {ctx['timestamp']}"
@@ -357,6 +362,7 @@ class WebhookChannel(BaseAlertChannel):
357
362
  "Detectors: {detector_name}\n"
358
363
  "Parameters: {detector_params}\n"
359
364
  "{dashboard_line}"
365
+ "{help_line}"
360
366
  "{mentions_line}"
361
367
  )
362
368
 
@@ -376,6 +382,7 @@ class WebhookChannel(BaseAlertChannel):
376
382
  "· Value: {value_display} | Expected: {expected_range}\n"
377
383
  "Detectors: {detector_name}\n"
378
384
  "{dashboard_line}"
385
+ "{help_line}"
379
386
  "{mentions_line}"
380
387
  )
381
388
 
@@ -386,6 +393,7 @@ class WebhookChannel(BaseAlertChannel):
386
393
  "Time: {timestamp}\n"
387
394
  "Status: query returned no datapoint for the latest interval\n"
388
395
  "{dashboard_line}"
396
+ "{help_line}"
389
397
  "{mentions_line}"
390
398
  )
391
399
 
@@ -396,6 +404,7 @@ class WebhookChannel(BaseAlertChannel):
396
404
  "Time: {timestamp}\n"
397
405
  "Error: {error_type}: {error_message}\n"
398
406
  "{dashboard_line}"
407
+ "{help_line}"
399
408
  "{mentions_line}"
400
409
  )
401
410
 
@@ -26,6 +26,7 @@ class _OrchestratorBase:
26
26
  dashboard_url: str | None = None,
27
27
  links: dict[str, str] | None = None,
28
28
  project_name: str | None = None,
29
+ help_url: str | None = None,
29
30
  ):
30
31
  self.metric_name = metric_name
31
32
  self.interval = interval
@@ -43,6 +44,10 @@ class _OrchestratorBase:
43
44
  # came from — keeps multiple projects sharing one channel distinct
44
45
  # while the bot keeps the default brand name + avatar.
45
46
  self.project_name = project_name
47
+ # Resolved "how to read this alert" link (from ProjectConfig.alert_help_url,
48
+ # defaulting to the official docs; None when opted out). Stamped onto every
49
+ # AlertData so channels render a guide link for non-operator stakeholders.
50
+ self.help_url = help_url
46
51
 
47
52
  @staticmethod
48
53
  def _group_by_timestamp(
@@ -242,6 +242,7 @@ class _DecisionMixin(_OrchestratorBase):
242
242
  dashboard_url=self.dashboard_url,
243
243
  links=self.links,
244
244
  project_name=self.project_name,
245
+ help_url=self.help_url,
245
246
  # Alert rule the message foregrounds: configured thresholds plus
246
247
  # the observed quorum size that satisfied them.
247
248
  min_detectors=self.conditions.min_detectors,
@@ -302,6 +303,7 @@ class _DecisionMixin(_OrchestratorBase):
302
303
  dashboard_url=self.dashboard_url,
303
304
  links=self.links,
304
305
  project_name=self.project_name,
306
+ help_url=self.help_url,
305
307
  )
306
308
 
307
309
  def get_last_complete_point(self, now: datetime | None = None) -> datetime:
@@ -179,6 +179,7 @@ class _RecoveryMixin(_OrchestratorBase):
179
179
  dashboard_url=self.dashboard_url,
180
180
  links=self.links,
181
181
  project_name=self.project_name,
182
+ help_url=self.help_url,
182
183
  # Echo the rule that had fired so the recovery message names the
183
184
  # same alert condition that just cleared.
184
185
  min_detectors=self.conditions.min_detectors,
@@ -151,6 +151,34 @@ gets an inline "Open dashboard" link, and email gets an "Open dashboard" button.
151
151
  `links` adds extra `label: url` entries alongside it. Both are also exposed to
152
152
  custom templates — see `{dashboard_url}` / `{dashboard_line}` below.
153
153
 
154
+ ## "How to read this alert" link
155
+
156
+ Every **default-rendered** alert (anomaly / recovery / no-data / error) on
157
+ **every** channel carries a `How to read this alert` link pointing non-operator
158
+ stakeholders to a plain-language interpretation guide. It defaults to the
159
+ official detectkit guide (`https://dtk.pipelab.dev/guides/reading-alerts/`) — no
160
+ config needed. Control it project-wide with `alert_help_url` in
161
+ `detectkit_project.yml` (tri-state, see `project.md`):
162
+
163
+ - **unset / null** → the official detectkit guide (default URL above)
164
+ - **a URL string** → your own runbook/wiki page instead
165
+ - **`false`** → hide the link entirely
166
+
167
+ Per-channel rendering (defaults only; resolved by
168
+ `ProjectConfig.resolve_alert_help_url`):
169
+
170
+ - **Slack / Mattermost / generic webhook** — a bottom full-width attachment field
171
+ titled `How to read this alert` whose value is the bare URL (auto-linkified on
172
+ both platforms).
173
+ - **Telegram** — appended to the links line (after the optional "Open dashboard"
174
+ link) as an `<a>` link reading `How to read this alert`.
175
+ - **Email** — in the footer, after `Sent by detectkit · <project>` (and any CC),
176
+ a clay-colored `How to read this alert ->` link.
177
+
178
+ Exposed to custom templates as `{help_url}` (raw URL, empty when unset/hidden)
179
+ and `{help_line}` (`How to read this alert: <url>\n`, empty when unset/hidden) —
180
+ mirrors `{dashboard_url}` / `{dashboard_line}`. See the template table below.
181
+
154
182
  ## How default messages render
155
183
 
156
184
  With no custom `template`, each channel renders a structured, branded message
@@ -243,6 +271,8 @@ referenced by path). Key variables:
243
271
  | `{mentions}` / `{mentions_line}` | formatted mentions |
244
272
  | `{dashboard_url}` | raw `dashboard_url` (empty string when unset) |
245
273
  | `{dashboard_line}` | `Dashboard: <url>\n` when set, else empty (appended to default plain-text templates) |
274
+ | `{help_url}` | raw "How to read this alert" URL (empty when unset/hidden via `alert_help_url`) |
275
+ | `{help_line}` | `How to read this alert: <url>\n` when set, else empty (mirrors `{dashboard_line}`) |
246
276
 
247
277
  > For no-data/error alerts there is no numeric value — avoid `{value:.2f}` in
248
278
  > those templates (detectkit falls back to the default template rather than
@@ -30,10 +30,31 @@ timeouts: # per-step, seconds
30
30
  detect: 7200 # detect step (default 7200)
31
31
  alert: 300 # alert step (default 300)
32
32
 
33
+ alert_help_url: null # optional, see below — "How to read this alert" link
34
+
33
35
  error_alerting: # optional, see below
34
36
  enabled: false
35
37
  ```
36
38
 
39
+ ### `alert_help_url` — "How to read this alert" link
40
+
41
+ Every default-rendered alert on every channel carries a `How to read this alert`
42
+ link for non-operator stakeholders. Tri-state, resolved by
43
+ `ProjectConfig.resolve_alert_help_url`:
44
+
45
+ - **unset / null** (default) → the official detectkit guide
46
+ (`https://dtk.pipelab.dev/guides/reading-alerts/`).
47
+ - **a URL string** → your own runbook/wiki page instead.
48
+ - **`false`** → hide the link entirely.
49
+
50
+ ```yaml
51
+ alert_help_url: https://wiki.ops/how-to-read-alerts # custom page
52
+ # alert_help_url: false # hide the link
53
+ ```
54
+
55
+ Per-channel rendering and the `{help_url}` / `{help_line}` template variables are
56
+ covered in `alerting.md` → "How to read this alert" link.
57
+
37
58
  ### `error_alerting` — project-scoped failure alerts
38
59
 
39
60
  Catches any exception from a metric's pipeline (DB down, query timeout, lock
@@ -22,6 +22,7 @@ def create_mock_alert_data(
22
22
  metric_config: MetricConfig,
23
23
  alerting_config,
24
24
  timezone_display: str = "UTC",
25
+ help_url: str | None = None,
25
26
  ) -> AlertData:
26
27
  """
27
28
  Create realistic mock AlertData for testing.
@@ -33,6 +34,8 @@ def create_mock_alert_data(
33
34
  ``metric_config.alerting`` is a list — the test command
34
35
  iterates it and passes one entry at a time.
35
36
  timezone_display: Timezone for display
37
+ help_url: Resolved "how to read this alert" link to preview (the
38
+ project's ``alert_help_url``); ``None`` renders no help link.
36
39
 
37
40
  Returns:
38
41
  AlertData with mock anomaly data
@@ -89,6 +92,7 @@ def create_mock_alert_data(
89
92
  mentions=mentions,
90
93
  dashboard_url=getattr(alerting_config, "dashboard_url", None),
91
94
  links=dict(getattr(alerting_config, "links", {}) or {}),
95
+ help_url=help_url,
92
96
  min_detectors=min_detectors,
93
97
  direction_policy=direction_policy,
94
98
  consecutive_required=consecutive_required,
@@ -121,6 +125,12 @@ def run_test_alert(metric_name: str, profile: str | None = None):
121
125
 
122
126
  metrics_dir_name = project_data.get("metrics_path", "metrics")
123
127
 
128
+ # Resolve the "how to read this alert" link so the preview matches what real
129
+ # alerts would carry (brand default, a custom URL, or hidden via false).
130
+ from detectkit.config.project_config import resolve_alert_help_url
131
+
132
+ help_url = resolve_alert_help_url(project_data.get("alert_help_url"))
133
+
124
134
  # Find metric config
125
135
  metrics_dir = project_root / metrics_dir_name
126
136
  metric_files = list(metrics_dir.glob("**/*.yml")) + list(metrics_dir.glob("**/*.yaml"))
@@ -176,7 +186,9 @@ def run_test_alert(metric_name: str, profile: str | None = None):
176
186
  print(f" Timezone: {timezone_display}")
177
187
  print(f" Channels: {', '.join(alerting_config.channels)}\n")
178
188
 
179
- alert_data = create_mock_alert_data(metric_config, alerting_config, timezone_display)
189
+ alert_data = create_mock_alert_data(
190
+ metric_config, alerting_config, timezone_display, help_url=help_url
191
+ )
180
192
 
181
193
  success_count = 0
182
194
  for channel_name in alerting_config.channels:
@@ -9,6 +9,26 @@ from pathlib import Path
9
9
  from pydantic import BaseModel, Field, field_validator
10
10
 
11
11
 
12
+ def resolve_alert_help_url(value: "str | bool | None") -> "str | None":
13
+ """Resolve a raw ``alert_help_url`` config value to a concrete URL or None.
14
+
15
+ Shared by :meth:`ProjectConfig.resolve_alert_help_url` and the ``dtk
16
+ test-alert`` preview (which reads the project YAML as a raw dict), so the
17
+ tri-state rule lives in one place:
18
+
19
+ - ``False`` → ``None`` (the link is hidden).
20
+ - a non-empty string → that URL (a custom runbook/wiki page).
21
+ - ``None`` / ``True`` / empty → the official detectkit guide.
22
+ """
23
+ from detectkit.alerting.channels.branding import BRAND_ALERT_GUIDE_URL
24
+
25
+ if value is False:
26
+ return None
27
+ if isinstance(value, str) and value.strip():
28
+ return value.strip()
29
+ return BRAND_ALERT_GUIDE_URL
30
+
31
+
12
32
  class ProjectPathsConfig(BaseModel):
13
33
  """
14
34
  Project directory paths configuration.
@@ -161,6 +181,44 @@ class ProjectConfig(BaseModel):
161
181
  default=None,
162
182
  description="Project-level error alerting (DB outages, query failures, etc.)",
163
183
  )
184
+ # "How to read this alert" link surfaced on every default-rendered alert so
185
+ # stakeholders (PMs, analysts, on-call) can click through to a plain-language
186
+ # explanation of what they're seeing. Tri-state:
187
+ # - unset / None → the official detectkit guide (brand default)
188
+ # - a URL string → your own runbook/wiki page instead
189
+ # - false → hide the link entirely
190
+ # Resolved via ``resolve_alert_help_url()`` and stamped onto ``AlertData``.
191
+ alert_help_url: str | bool | None = Field(
192
+ default=None,
193
+ description=(
194
+ "Link to a guide explaining how to read an alert, shown on every "
195
+ "alert. Defaults to the official docs; set a URL for your own page, "
196
+ "or false to hide it."
197
+ ),
198
+ )
199
+
200
+ @field_validator("alert_help_url")
201
+ @classmethod
202
+ def validate_alert_help_url(cls, v: "str | bool | None") -> "str | bool | None":
203
+ """A string override must look like an http(s) URL; ``True`` means default."""
204
+ if isinstance(v, str):
205
+ v = v.strip()
206
+ if not v:
207
+ return None # empty string behaves like "use the default"
208
+ if not (v.startswith("http://") or v.startswith("https://")):
209
+ raise ValueError(
210
+ "alert_help_url must be an http(s) URL, false (to hide), "
211
+ "or unset (to use the default)"
212
+ )
213
+ return v
214
+
215
+ def resolve_alert_help_url(self) -> str | None:
216
+ """Resolve the configured ``alert_help_url`` to a concrete URL or None.
217
+
218
+ Defaults to the official detectkit guide; a string redirects to your own
219
+ page; ``false`` hides the link. See :func:`resolve_alert_help_url`.
220
+ """
221
+ return resolve_alert_help_url(self.alert_help_url)
164
222
 
165
223
  @field_validator("name")
166
224
  @classmethod
@@ -74,6 +74,11 @@ def dispatch_project_error_alert(
74
74
  )
75
75
  return False
76
76
 
77
+ # Resolve the project-level "how to read this alert" link (brand default
78
+ # unless overridden / disabled); duck-typed for stub configs in tests.
79
+ help_resolver = getattr(project_config, "resolve_alert_help_url", None)
80
+ help_url = help_resolver() if callable(help_resolver) else None
81
+
77
82
  alert_data = AlertData(
78
83
  metric_name=metric_name,
79
84
  timestamp=np.datetime64(now_utc_naive(), "ms"),
@@ -93,6 +98,7 @@ def dispatch_project_error_alert(
93
98
  description=None,
94
99
  mentions=cfg.mentions,
95
100
  project_name=getattr(project_config, "name", None),
101
+ help_url=help_url,
96
102
  )
97
103
 
98
104
  click.echo(
@@ -48,6 +48,12 @@ class _AlertStepMixin(_TaskManagerBase):
48
48
  click.echo(" │ Checking alert conditions...")
49
49
  alert_config_id = make_alert_config_id(alerting_config)
50
50
 
51
+ # Resolve the project-level "how to read this alert" link once per
52
+ # alert config (brand default unless overridden / disabled). Duck-typed
53
+ # so a stub project_config in tests without the resolver stays safe.
54
+ help_resolver = getattr(self.project_config, "resolve_alert_help_url", None)
55
+ help_url = help_resolver() if callable(help_resolver) else None
56
+
51
57
  orchestrator = AlertOrchestrator(
52
58
  metric_name=config.name,
53
59
  interval=interval,
@@ -65,6 +71,7 @@ class _AlertStepMixin(_TaskManagerBase):
65
71
  dashboard_url=alerting_config.dashboard_url,
66
72
  links=alerting_config.links,
67
73
  project_name=getattr(self.project_config, "name", None),
74
+ help_url=help_url,
68
75
  )
69
76
 
70
77
  last_point = orchestrator.get_last_complete_point()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.15.0
3
+ Version: 0.16.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