detectkit 0.14.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.14.0/detectkit.egg-info → detectkit-0.16.0}/PKG-INFO +1 -1
  2. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/base.py +35 -11
  4. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/branding.py +10 -0
  5. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/email.py +45 -6
  6. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/telegram.py +11 -1
  7. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/webhook.py +16 -2
  8. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_base.py +11 -0
  9. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_decision.py +4 -0
  10. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_recovery.py +2 -0
  11. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/alerting.md +53 -1
  12. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/project.md +28 -3
  13. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/test_alert.py +13 -1
  14. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/project_config.py +58 -0
  15. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/error_dispatch.py +6 -0
  16. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_alert_step.py +8 -0
  17. {detectkit-0.14.0 → detectkit-0.16.0/detectkit.egg-info}/PKG-INFO +1 -1
  18. {detectkit-0.14.0 → detectkit-0.16.0}/LICENSE +0 -0
  19. {detectkit-0.14.0 → detectkit-0.16.0}/MANIFEST.in +0 -0
  20. {detectkit-0.14.0 → detectkit-0.16.0}/README.md +0 -0
  21. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/__init__.py +0 -0
  22. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/__init__.py +0 -0
  23. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/factory.py +0 -0
  24. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/mattermost.py +0 -0
  25. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/slack.py +0 -0
  26. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  27. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  28. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  29. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  30. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  31. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/__init__.py +0 -0
  32. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/_output.py +0 -0
  33. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  34. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  35. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  36. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  37. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  38. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  39. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  40. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  41. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/__init__.py +0 -0
  42. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/clean.py +0 -0
  43. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/init.py +0 -0
  44. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/init_claude.py +0 -0
  45. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/run.py +0 -0
  46. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/unlock.py +0 -0
  47. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/main.py +0 -0
  48. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/__init__.py +0 -0
  49. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/metric_config.py +0 -0
  50. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/profile.py +0 -0
  51. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/validator.py +0 -0
  52. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/__init__.py +0 -0
  53. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/interval.py +0 -0
  54. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/models.py +0 -0
  55. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/__init__.py +0 -0
  56. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/_sql_manager.py +0 -0
  57. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/clickhouse_manager.py +0 -0
  58. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/__init__.py +0 -0
  59. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  60. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_base.py +0 -0
  61. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  62. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_detections.py +0 -0
  63. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  64. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  65. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_schema.py +0 -0
  66. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  67. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/manager.py +0 -0
  68. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/manager.py +0 -0
  69. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/mysql_manager.py +0 -0
  70. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/postgres_manager.py +0 -0
  71. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/tables.py +0 -0
  72. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/__init__.py +0 -0
  73. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/base.py +0 -0
  74. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/factory.py +0 -0
  75. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/seasonality.py +0 -0
  76. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/__init__.py +0 -0
  77. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  78. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/iqr.py +0 -0
  79. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/mad.py +0 -0
  80. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  81. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/zscore.py +0 -0
  82. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/__init__.py +0 -0
  83. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/metric_loader.py +0 -0
  84. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/query_template.py +0 -0
  85. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/__init__.py +0 -0
  86. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  87. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  88. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  89. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  90. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  91. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  92. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/__init__.py +0 -0
  93. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/datetime_utils.py +0 -0
  94. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/env_interpolation.py +0 -0
  95. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/json_utils.py +0 -0
  96. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/stats.py +0 -0
  97. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/SOURCES.txt +0 -0
  98. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/dependency_links.txt +0 -0
  99. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/entry_points.txt +0 -0
  100. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/requires.txt +0 -0
  101. {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/top_level.txt +0 -0
  102. {detectkit-0.14.0 → detectkit-0.16.0}/pyproject.toml +0 -0
  103. {detectkit-0.14.0 → detectkit-0.16.0}/requirements.txt +0 -0
  104. {detectkit-0.14.0 → detectkit-0.16.0}/setup.cfg +0 -0
  105. {detectkit-0.14.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.14.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.14.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:
@@ -32,10 +34,14 @@ class AlertData:
32
34
  consecutive_count: Number of consecutive anomalies
33
35
  is_recovery: True for recovery notifications
34
36
  is_no_data: True for missing-data alerts (no_data_alert)
35
- project_name: Optional ``detectkit_project.yml`` name. Surfaces
36
- as ``{project_name}`` in templates and as a ``[name] `` prefix
37
- in the default error title. Lets multiple projects share the
38
- same alert channel without ambiguity.
37
+ project_name: Optional ``detectkit_project.yml`` name. Surfaces as
38
+ ``{project_name}`` / ``{project_name_prefix}`` in templates and, by
39
+ default, as a ``[name] `` prefix on every alert title/headline
40
+ (anomaly, recovery, no-data, error) plus a brand-paired footer
41
+ ("detectkit · name" on webhook/email). The detectkit pipeline stamps
42
+ it from the project config; direct-API callers leave it ``None`` and
43
+ render unchanged. Lets multiple projects share one alert channel —
44
+ keeping the default brand bot name + avatar — without ambiguity.
39
45
 
40
46
  Alert-rule fields (``min_detectors``, ``direction_policy``,
41
47
  ``consecutive_required``, ``detector_count``) describe *why the alert
@@ -75,6 +81,12 @@ class AlertData:
75
81
  # callers and templates render unchanged.
76
82
  dashboard_url: str | None = None
77
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
78
90
  # Alert rule (the parameters the alert fired with) — see class docstring.
79
91
  min_detectors: int | None = None
80
92
  direction_policy: str | None = None
@@ -296,6 +308,11 @@ class BaseAlertChannel(ABC):
296
308
  dashboard_url = alert_data.dashboard_url or ""
297
309
  dashboard_line = f"Dashboard: {dashboard_url}\n" if dashboard_url else ""
298
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
+
299
316
  # Project name + synth prefix for templates. Prefix is empty when
300
317
  # project_name is None so default templates render cleanly for
301
318
  # callers that don't set it.
@@ -340,6 +357,9 @@ class BaseAlertChannel(ABC):
340
357
  "description_line": description_line,
341
358
  "dashboard_url": dashboard_url,
342
359
  "dashboard_line": dashboard_line,
360
+ "help_url": help_url,
361
+ "help_line": help_line,
362
+ "help_label": ALERT_GUIDE_LABEL,
343
363
  "mentions": mentions_str,
344
364
  "mentions_line": mentions_line,
345
365
  }
@@ -450,7 +470,7 @@ class BaseAlertChannel(ABC):
450
470
  Default template string
451
471
  """
452
472
  return (
453
- "🔴 Alert: {metric_name}\n"
473
+ "🔴 {project_name_prefix}Alert: {metric_name}\n"
454
474
  "{description_line}"
455
475
  "Quorum {detector_count}/{min_detectors} · "
456
476
  "direction {direction} (policy {direction_policy}) · "
@@ -465,6 +485,7 @@ class BaseAlertChannel(ABC):
465
485
  "Detectors: {detector_name}\n"
466
486
  "Parameters: {detector_params}\n"
467
487
  "{dashboard_line}"
488
+ "{help_line}"
468
489
  "{mentions_line}"
469
490
  )
470
491
 
@@ -476,7 +497,7 @@ class BaseAlertChannel(ABC):
476
497
  Default recovery template string
477
498
  """
478
499
  return (
479
- "🟢 Alert cleared: {metric_name}\n"
500
+ "🟢 {project_name_prefix}Alert cleared: {metric_name}\n"
480
501
  "{description_line}"
481
502
  "The alert condition no longer holds — "
482
503
  "the metric is back within expected bounds.\n"
@@ -488,6 +509,7 @@ class BaseAlertChannel(ABC):
488
509
  "· Value: {value_display} | Expected: {expected_range}\n"
489
510
  "Detectors: {detector_name}\n"
490
511
  "{dashboard_line}"
512
+ "{help_line}"
491
513
  "{mentions_line}"
492
514
  )
493
515
 
@@ -500,7 +522,7 @@ class BaseAlertChannel(ABC):
500
522
  Returns:
501
523
  Default title template string
502
524
  """
503
- return "🔴 Alert: {metric_name}"
525
+ return "🔴 {project_name_prefix}Alert: {metric_name}"
504
526
 
505
527
  def get_default_recovery_title_template(self) -> str:
506
528
  """
@@ -509,7 +531,7 @@ class BaseAlertChannel(ABC):
509
531
  Returns:
510
532
  Default recovery title template string
511
533
  """
512
- return "🟢 Alert cleared: {metric_name}"
534
+ return "🟢 {project_name_prefix}Alert cleared: {metric_name}"
513
535
 
514
536
  def get_default_no_data_template(self) -> str:
515
537
  """
@@ -519,26 +541,28 @@ class BaseAlertChannel(ABC):
519
541
  has no datapoint (no row OR row with NULL/NaN value).
520
542
  """
521
543
  return (
522
- "🟡 No data for metric: {metric_name}\n"
544
+ "🟡 {project_name_prefix}No data for metric: {metric_name}\n"
523
545
  "{description_line}"
524
546
  "Time: {timestamp}\n"
525
547
  "Status: query returned no datapoint for the latest interval\n"
526
548
  "{dashboard_line}"
549
+ "{help_line}"
527
550
  "{mentions_line}"
528
551
  )
529
552
 
530
553
  def get_default_no_data_title_template(self) -> str:
531
554
  """Get default title template for no-data alerts."""
532
- return "🟡 No data: {metric_name}"
555
+ return "🟡 {project_name_prefix}No data: {metric_name}"
533
556
 
534
557
  def get_default_error_template(self) -> str:
535
558
  """Default body template for project-level error alerts."""
536
559
  return (
537
- "🔵 Pipeline failed for metric: {metric_name}\n"
560
+ "🔵 {project_name_prefix}Pipeline failed for metric: {metric_name}\n"
538
561
  "{description_line}"
539
562
  "Time: {timestamp}\n"
540
563
  "Error: {error_type}: {error_message}\n"
541
564
  "{dashboard_line}"
565
+ "{help_line}"
542
566
  "{mentions_line}"
543
567
  )
544
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
@@ -74,7 +78,7 @@ class EmailChannel(BaseAlertChannel):
74
78
  smtp_username: str | None = None,
75
79
  smtp_password: str | None = None,
76
80
  use_tls: bool = True,
77
- subject_template: str = "🔴 Alert: {metric_name}",
81
+ subject_template: str = "🔴 {project_name_prefix}Alert: {metric_name}",
78
82
  from_name: str = BRAND_USERNAME,
79
83
  template: str | None = None,
80
84
  **kwargs,
@@ -144,10 +148,18 @@ class EmailChannel(BaseAlertChannel):
144
148
  # of a bot display name. formataddr quotes the name when required.
145
149
  msg["From"] = formataddr((self.from_name, self.from_email))
146
150
  msg["To"] = ", ".join(self.to_emails)
147
- # Strip CR/LF from the metric name before it reaches the Subject header
148
- # so a metric name can never inject extra email headers.
151
+ # Strip CR/LF from the metric *and* project name before they reach the
152
+ # Subject header so neither can inject extra email headers. The project
153
+ # prefix ("[my_project] ") makes the inbox row distinguishable when
154
+ # several projects email the same address.
149
155
  subject_metric = alert_data.metric_name.replace("\r", " ").replace("\n", " ")
150
- msg["Subject"] = self.subject_template.format(metric_name=subject_metric)
156
+ project_clean = (alert_data.project_name or "").replace("\r", " ").replace("\n", " ")
157
+ project_prefix = f"[{project_clean}] " if project_clean else ""
158
+ msg["Subject"] = self.subject_template.format(
159
+ metric_name=subject_metric,
160
+ project_name_prefix=project_prefix,
161
+ project_name=project_clean,
162
+ )
151
163
 
152
164
  # Attach both parts. In multipart/alternative the LAST part is the
153
165
  # preferred one, so HTML (the branded card) is shown when supported and
@@ -247,6 +259,8 @@ class EmailChannel(BaseAlertChannel):
247
259
  f'font-size:4px;background-color:{accent};">&nbsp;</td></tr>'
248
260
  # header: logo + wordmark + status pill
249
261
  f"{self._header_html(accent, pill)}"
262
+ # project eyebrow (small label above the metric, only when set)
263
+ f"{self._eyebrow_html(ctx['project_name'])}"
250
264
  # title
251
265
  f'<tr><td style="padding:6px 24px 2px 24px;font-family:{_SANS};font-size:22px;'
252
266
  f'font-weight:bold;color:{_INK};mso-line-height-rule:exactly;line-height:28px;">'
@@ -258,6 +272,17 @@ class EmailChannel(BaseAlertChannel):
258
272
  "</table></td></tr></table></body></html>"
259
273
  )
260
274
 
275
+ def _eyebrow_html(self, project_name: str) -> str:
276
+ """Small uppercase project label above the metric title (empty if unset)."""
277
+ if not project_name:
278
+ return ""
279
+ return (
280
+ f'<tr><td style="padding:10px 24px 0 24px;font-family:{_SANS};font-size:11px;'
281
+ f"font-weight:bold;letter-spacing:0.5px;color:{_FAINT};text-transform:uppercase;"
282
+ f'mso-line-height-rule:exactly;line-height:14px;">'
283
+ f"{html.escape(project_name)}</td></tr>"
284
+ )
285
+
261
286
  def _header_html(self, accent: str, pill: str) -> str:
262
287
  """Logo + wordmark (real text) + colored status pill row."""
263
288
  return (
@@ -432,11 +457,25 @@ class EmailChannel(BaseAlertChannel):
432
457
  def _footer_html(self, alert_data: AlertData) -> str:
433
458
  cc = self.format_mentions(alert_data.mentions)
434
459
  cc_html = f" &middot; {html.escape(cc)}" if cc else ""
460
+ # Pair the brand with the project name ("Sent by detectkit · my_project")
461
+ # so the source project is clear even past the subject/eyebrow.
462
+ project_html = (
463
+ f" &middot; {html.escape(alert_data.project_name)}" if alert_data.project_name else ""
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
+ )
435
474
  return (
436
475
  f'<tr><td bgcolor="{_SURFACE}" style="background-color:{_SURFACE};'
437
476
  f"border-top:1px solid {_BORDER};padding:14px 24px;font-family:{_SANS};"
438
477
  f'font-size:12px;color:{_FAINT};mso-line-height-rule:exactly;line-height:16px;">'
439
- f"Sent by detectkit{cc_html}</td></tr>"
478
+ f"Sent by detectkit{project_html}{cc_html}{help_html}</td></tr>"
440
479
  )
441
480
 
442
481
  def format_mentions(self, mentions: list[str]) -> str:
@@ -148,7 +148,12 @@ class TelegramChannel(BaseAlertChannel):
148
148
  return html.escape(str(value))
149
149
 
150
150
  metric = esc(ctx["metric_name"])
151
- lines: list[str] = [f"{dot} <b>{word} · {metric}</b>"]
151
+ # Lead the headline with the project name (when set) so multiple
152
+ # projects sharing one chat stay distinguishable — Telegram has no
153
+ # footer/avatar override, so this prefix is the only project cue.
154
+ proj = ctx["project_name"]
155
+ head_prefix = f"[{esc(proj)}] " if proj else ""
156
+ lines: list[str] = [f"{dot} <b>{head_prefix}{word} · {metric}</b>"]
152
157
 
153
158
  if ctx["description"]:
154
159
  lines.append(f"<i>{esc(self._cap(ctx['description'], _DESC_CAP))}</i>")
@@ -214,6 +219,11 @@ class TelegramChannel(BaseAlertChannel):
214
219
  for label, url in alert_data.links.items():
215
220
  href = html.escape(url, quote=True)
216
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>')
217
227
  if link_parts:
218
228
  lines.append("")
219
229
  lines.append(" · ".join(link_parts))
@@ -189,8 +189,13 @@ class WebhookChannel(BaseAlertChannel):
189
189
  attachment["title_link"] = alert_data.dashboard_url
190
190
 
191
191
  # Brand the attachment footer (reliable on Slack even when top-level
192
- # username/icon are locked to the app install).
193
- attachment["footer"] = self.username or BRAND_USERNAME
192
+ # username/icon are locked to the app install). Pair the brand name with
193
+ # the project name when set ("detectkit · my_project") so two projects
194
+ # posting to the same channel stay distinguishable even past the title.
195
+ footer = self.username or BRAND_USERNAME
196
+ if alert_data.project_name:
197
+ footer = f"{footer} · {alert_data.project_name}"
198
+ attachment["footer"] = footer
194
199
  if self.icon_url:
195
200
  attachment["footer_icon"] = self.icon_url
196
201
  # Slack-only sugar: a real timestamp under the footer. Mattermost
@@ -298,6 +303,11 @@ class WebhookChannel(BaseAlertChannel):
298
303
  if link_lines:
299
304
  full("Links", "\n".join(link_lines))
300
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
+
301
311
  # A plain-text one-liner for notification previews / unsupported clients.
302
312
  if kind == "no_data":
303
313
  fallback = f"{title} at {ctx['timestamp']}"
@@ -352,6 +362,7 @@ class WebhookChannel(BaseAlertChannel):
352
362
  "Detectors: {detector_name}\n"
353
363
  "Parameters: {detector_params}\n"
354
364
  "{dashboard_line}"
365
+ "{help_line}"
355
366
  "{mentions_line}"
356
367
  )
357
368
 
@@ -371,6 +382,7 @@ class WebhookChannel(BaseAlertChannel):
371
382
  "· Value: {value_display} | Expected: {expected_range}\n"
372
383
  "Detectors: {detector_name}\n"
373
384
  "{dashboard_line}"
385
+ "{help_line}"
374
386
  "{mentions_line}"
375
387
  )
376
388
 
@@ -381,6 +393,7 @@ class WebhookChannel(BaseAlertChannel):
381
393
  "Time: {timestamp}\n"
382
394
  "Status: query returned no datapoint for the latest interval\n"
383
395
  "{dashboard_line}"
396
+ "{help_line}"
384
397
  "{mentions_line}"
385
398
  )
386
399
 
@@ -391,6 +404,7 @@ class WebhookChannel(BaseAlertChannel):
391
404
  "Time: {timestamp}\n"
392
405
  "Error: {error_type}: {error_message}\n"
393
406
  "{dashboard_line}"
407
+ "{help_line}"
394
408
  "{mentions_line}"
395
409
  )
396
410
 
@@ -25,6 +25,8 @@ class _OrchestratorBase:
25
25
  mentions: list[str] | None = None,
26
26
  dashboard_url: str | None = None,
27
27
  links: dict[str, str] | None = None,
28
+ project_name: str | None = None,
29
+ help_url: str | None = None,
28
30
  ):
29
31
  self.metric_name = metric_name
30
32
  self.interval = interval
@@ -37,6 +39,15 @@ class _OrchestratorBase:
37
39
  self.mentions = mentions or []
38
40
  self.dashboard_url = dashboard_url
39
41
  self.links = links or {}
42
+ # Optional project name (``detectkit_project.yml`` ``name``). Stamped
43
+ # onto every AlertData so channels can label which project an alert
44
+ # came from — keeps multiple projects sharing one channel distinct
45
+ # while the bot keeps the default brand name + avatar.
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
40
51
 
41
52
  @staticmethod
42
53
  def _group_by_timestamp(
@@ -241,6 +241,8 @@ class _DecisionMixin(_OrchestratorBase):
241
241
  mentions=self.mentions,
242
242
  dashboard_url=self.dashboard_url,
243
243
  links=self.links,
244
+ project_name=self.project_name,
245
+ help_url=self.help_url,
244
246
  # Alert rule the message foregrounds: configured thresholds plus
245
247
  # the observed quorum size that satisfied them.
246
248
  min_detectors=self.conditions.min_detectors,
@@ -300,6 +302,8 @@ class _DecisionMixin(_OrchestratorBase):
300
302
  mentions=self.mentions,
301
303
  dashboard_url=self.dashboard_url,
302
304
  links=self.links,
305
+ project_name=self.project_name,
306
+ help_url=self.help_url,
303
307
  )
304
308
 
305
309
  def get_last_complete_point(self, now: datetime | None = None) -> datetime:
@@ -178,6 +178,8 @@ class _RecoveryMixin(_OrchestratorBase):
178
178
  mentions=self.mentions,
179
179
  dashboard_url=self.dashboard_url,
180
180
  links=self.links,
181
+ project_name=self.project_name,
182
+ help_url=self.help_url,
181
183
  # Echo the rule that had fired so the recovery message names the
182
184
  # same alert condition that just cleared.
183
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
@@ -158,7 +186,9 @@ With no custom `template`, each channel renders a structured, branded message
158
186
  shared value computation lives in one place (`BaseAlertChannel.build_context`),
159
187
  so templates and native rendering stay consistent. Every alert title/headline
160
188
  leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-data,
161
- 🔵 pipeline error — so the status reads from color alone.
189
+ 🔵 pipeline error — so the status reads from color alone. It also leads with the
190
+ **project name** as a `[name] ` prefix (from `detectkit_project.yml`) — see
191
+ [Project label](#project-label-multi-project-channels) below.
162
192
 
163
193
  - **Slack / Mattermost / generic webhook** — one message *attachment* with a
164
194
  status-colored accent bar, a clickable title (the metric; links to
@@ -181,6 +211,25 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
181
211
  table, a monospace params box, an optional "Open dashboard" button, and a
182
212
  footer. The plain-text body remains the multipart fallback.
183
213
 
214
+ ## Project label (multi-project channels)
215
+
216
+ The bot keeps the **detectkit brand** name + avatar by default (so users rarely
217
+ override them). To still tell apart two projects posting to the **same** channel,
218
+ detectkit stamps the project name (`detectkit_project.yml` → `name`) onto every
219
+ alert and shows it by default — no config needed:
220
+
221
+ - **Title / headline / subject** lead with a `[name] ` prefix on every kind
222
+ (anomaly, recovery, no-data, error): `🔴 [payments] Alert: api_error_rate`.
223
+ - **Webhook (Slack/Mattermost)** also pairs it in the footer: `detectkit · payments`.
224
+ - **Telegram** carries it in the bold headline (no footer/avatar to override).
225
+ - **Email** prefixes the subject, shows a small project eyebrow above the metric,
226
+ and pairs it in the footer (`Sent by detectkit · payments`).
227
+
228
+ It is exposed to custom templates as `{project_name}` and `{project_name_prefix}`
229
+ (`"[name] "` when set, else `""`). Direct library/API callers that don't set it
230
+ render unchanged. The `name` is informational only (it does not key any `_dtk_*`
231
+ table), so renaming it is safe — spaces are allowed for a prettier label.
232
+
184
233
  ## Multiple alert configs per metric
185
234
 
186
235
  `alerting:` may be a **list** of independent blocks, each with its own channels,
@@ -210,6 +259,7 @@ referenced by path). Key variables:
210
259
  | Variable | Meaning |
211
260
  |---|---|
212
261
  | `{metric_name}`, `{description}` / `{description_line}` | identity |
262
+ | `{project_name}` / `{project_name_prefix}` | project label (`"[name] "` prefix, or `""`) |
213
263
  | `{timestamp}`, `{timezone}` | when (display tz via `alerting.timezone`, default UTC) |
214
264
  | `{value}` / `{value_display}` | metric value (`value_display` is NaN-safe) |
215
265
  | `{confidence_lower}` / `{confidence_upper}` / `{confidence_interval}` | bounds |
@@ -221,6 +271,8 @@ referenced by path). Key variables:
221
271
  | `{mentions}` / `{mentions_line}` | formatted mentions |
222
272
  | `{dashboard_url}` | raw `dashboard_url` (empty string when unset) |
223
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}`) |
224
276
 
225
277
  > For no-data/error alerts there is no numeric value — avoid `{value:.2f}` in
226
278
  > those templates (detectkit falls back to the default template rather than
@@ -9,7 +9,9 @@ errors (not empty strings).
9
9
  ## `detectkit_project.yml`
10
10
 
11
11
  ```yaml
12
- name: my_monitoring # required — project identifier (logs, error-alert titles)
12
+ name: my_monitoring # required — project identifier; also labels every
13
+ # alert ("[my_monitoring] Alert: …") so multiple
14
+ # projects on one channel stay distinct (alerting.md)
13
15
  version: "1.0" # optional (default "1.0")
14
16
  default_profile: prod # profile name from profiles.yml
15
17
 
@@ -28,10 +30,31 @@ timeouts: # per-step, seconds
28
30
  detect: 7200 # detect step (default 7200)
29
31
  alert: 300 # alert step (default 300)
30
32
 
33
+ alert_help_url: null # optional, see below — "How to read this alert" link
34
+
31
35
  error_alerting: # optional, see below
32
36
  enabled: false
33
37
  ```
34
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
+
35
58
  ### `error_alerting` — project-scoped failure alerts
36
59
 
37
60
  Catches any exception from a metric's pipeline (DB down, query timeout, lock
@@ -57,8 +80,10 @@ error_alerting:
57
80
  via cron cadence). Channel send failures are swallowed so a flaky webhook
58
81
  can't crash the run.
59
82
  - Extra template variables: `{error_type}`, `{error_message}`, `{status}`
60
- (always `"ERROR"`), `{project_name}`, `{project_name_prefix}` (=
61
- `"[<name>] "` when `name` set keeps multi-project channels distinguishable).
83
+ (always `"ERROR"`). `{project_name}` / `{project_name_prefix}` (=
84
+ `"[<name>] "` when `name` set) are available here **and in every other alert
85
+ template** — and by default lead the title/headline on all channels, keeping
86
+ multi-project channels distinguishable (see `alerting.md` → Project label).
62
87
 
63
88
  ## `profiles.yml`
64
89
 
@@ -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,
@@ -64,6 +70,8 @@ class _AlertStepMixin(_TaskManagerBase):
64
70
  mentions=alerting_config.mentions,
65
71
  dashboard_url=alerting_config.dashboard_url,
66
72
  links=alerting_config.links,
73
+ project_name=getattr(self.project_config, "name", None),
74
+ help_url=help_url,
67
75
  )
68
76
 
69
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.14.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