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.
- {detectkit-0.14.0/detectkit.egg-info → detectkit-0.16.0}/PKG-INFO +1 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/__init__.py +1 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/base.py +35 -11
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/branding.py +10 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/email.py +45 -6
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/telegram.py +11 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/webhook.py +16 -2
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_base.py +11 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_decision.py +4 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_recovery.py +2 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/alerting.md +53 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/project.md +28 -3
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/test_alert.py +13 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/project_config.py +58 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/error_dispatch.py +6 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_alert_step.py +8 -0
- {detectkit-0.14.0 → detectkit-0.16.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.14.0 → detectkit-0.16.0}/LICENSE +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/MANIFEST.in +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/README.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/core/models.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/pyproject.toml +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/requirements.txt +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/setup.cfg +0 -0
- {detectkit-0.14.0 → detectkit-0.16.0}/setup.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
148
|
-
#
|
|
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
|
-
|
|
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};"> </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" · {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" · {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' · <a href="{href}" style="color:{_CLAY};text-decoration:none;">'
|
|
472
|
+
f"{html.escape(ALERT_GUIDE_LABEL)} →</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
|
-
|
|
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
|
-
|
|
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
|
|
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"`)
|
|
61
|
-
`"[<name>] "` when `name` set
|
|
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(
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md
RENAMED
|
File without changes
|
{detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md
RENAMED
|
File without changes
|
{detectkit-0.14.0 → detectkit-0.16.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|