detectkit 0.58.0__tar.gz → 0.59.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 (160) hide show
  1. {detectkit-0.58.0/detectkit.egg-info → detectkit-0.59.0}/PKG-INFO +2 -2
  2. {detectkit-0.58.0 → detectkit-0.59.0}/README.md +1 -1
  3. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/__init__.py +1 -1
  4. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/__init__.py +8 -0
  5. detectkit-0.59.0/detectkit/alerting/channels/discord.py +426 -0
  6. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/factory.py +8 -0
  7. detectkit-0.59.0/detectkit/alerting/channels/googlechat.py +392 -0
  8. detectkit-0.59.0/detectkit/alerting/channels/ntfy.py +305 -0
  9. detectkit-0.59.0/detectkit/alerting/channels/teams.py +329 -0
  10. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/alerting.md +72 -6
  11. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/project.md +85 -1
  12. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/init.py +36 -1
  13. {detectkit-0.58.0 → detectkit-0.59.0/detectkit.egg-info}/PKG-INFO +2 -2
  14. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/SOURCES.txt +4 -0
  15. {detectkit-0.58.0 → detectkit-0.59.0}/LICENSE +0 -0
  16. {detectkit-0.58.0 → detectkit-0.59.0}/MANIFEST.in +0 -0
  17. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/__init__.py +0 -0
  18. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/base.py +0 -0
  19. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/branding.py +0 -0
  20. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/email.py +0 -0
  21. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/mattermost.py +0 -0
  22. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/slack.py +0 -0
  23. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/telegram.py +0 -0
  24. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/webhook.py +0 -0
  25. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  26. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  27. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  28. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  29. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  30. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  31. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  32. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  33. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  34. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/__init__.py +0 -0
  35. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/_base.py +0 -0
  36. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/_types.py +0 -0
  37. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/autotuner.py +0 -0
  38. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/axis_spec.py +0 -0
  39. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/config_emitter.py +0 -0
  40. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/crossval.py +0 -0
  41. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/detector_select.py +0 -0
  42. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/distribution.py +0 -0
  43. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/grid_search.py +0 -0
  44. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/labels.py +0 -0
  45. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/result.py +0 -0
  46. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/runner.py +0 -0
  47. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/scoring.py +0 -0
  48. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/seasonality_search.py +0 -0
  49. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/settings.py +0 -0
  50. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/window_select.py +0 -0
  51. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/__init__.py +0 -0
  52. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/_output.py +0 -0
  53. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  54. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  55. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  56. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  57. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  58. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  59. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  60. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  61. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  62. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  63. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  64. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/__init__.py +0 -0
  65. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/autotune.py +0 -0
  66. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/clean.py +0 -0
  67. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/init_claude.py +0 -0
  68. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/osi.py +0 -0
  69. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/run.py +0 -0
  70. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/test_alert.py +0 -0
  71. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/tune.py +0 -0
  72. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/ui.py +0 -0
  73. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/unlock.py +0 -0
  74. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/main.py +0 -0
  75. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/__init__.py +0 -0
  76. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/metric_config.py +0 -0
  77. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/metric_io.py +0 -0
  78. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/profile.py +0 -0
  79. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/project_config.py +0 -0
  80. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/validator.py +0 -0
  81. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/__init__.py +0 -0
  82. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/interval.py +0 -0
  83. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/models.py +0 -0
  84. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/__init__.py +0 -0
  85. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/_sql_manager.py +0 -0
  86. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/clickhouse_manager.py +0 -0
  87. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/__init__.py +0 -0
  88. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  89. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  90. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_base.py +0 -0
  91. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  92. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_detections.py +0 -0
  93. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  94. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  95. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_schema.py +0 -0
  96. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  97. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/manager.py +0 -0
  98. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/manager.py +0 -0
  99. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/mysql_manager.py +0 -0
  100. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/postgres_manager.py +0 -0
  101. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/tables.py +0 -0
  102. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/__init__.py +0 -0
  103. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/base.py +0 -0
  104. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/factory.py +0 -0
  105. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/seasonality.py +0 -0
  106. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/__init__.py +0 -0
  107. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  108. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  109. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/iqr.py +0 -0
  110. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/mad.py +0 -0
  111. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  112. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/zscore.py +0 -0
  113. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/__init__.py +0 -0
  114. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/metric_loader.py +0 -0
  115. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/query_template.py +0 -0
  116. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/__init__.py +0 -0
  117. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/error_dispatch.py +0 -0
  118. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  119. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  120. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  121. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  122. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  123. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  124. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  125. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/__init__.py +0 -0
  126. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/assets/report.js +0 -0
  127. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/builder.py +0 -0
  128. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/html_report.py +0 -0
  129. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/__init__.py +0 -0
  130. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/errors.py +0 -0
  131. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/exporter.py +0 -0
  132. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/importer.py +0 -0
  133. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/osi_model.py +0 -0
  134. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/query_gen.py +0 -0
  135. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/__init__.py +0 -0
  136. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/assets/tune.js +0 -0
  137. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/config_writer.py +0 -0
  138. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/html.py +0 -0
  139. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/payload.py +0 -0
  140. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/server.py +0 -0
  141. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/__init__.py +0 -0
  142. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/assets/ui.js +0 -0
  143. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/html.py +0 -0
  144. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/jobs.py +0 -0
  145. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/metric_files.py +0 -0
  146. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/overview.py +0 -0
  147. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/server.py +0 -0
  148. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/__init__.py +0 -0
  149. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/datetime_utils.py +0 -0
  150. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/env_interpolation.py +0 -0
  151. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/json_utils.py +0 -0
  152. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/stats.py +0 -0
  153. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/dependency_links.txt +0 -0
  154. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/entry_points.txt +0 -0
  155. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/requires.txt +0 -0
  156. {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/top_level.txt +0 -0
  157. {detectkit-0.58.0 → detectkit-0.59.0}/pyproject.toml +0 -0
  158. {detectkit-0.58.0 → detectkit-0.59.0}/requirements.txt +0 -0
  159. {detectkit-0.58.0 → detectkit-0.59.0}/setup.cfg +0 -0
  160. {detectkit-0.58.0 → detectkit-0.59.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.58.0
3
+ Version: 0.59.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -95,7 +95,7 @@ Dynamic: license-file
95
95
  - **Pure numpy arrays** — no pandas dependency in core logic
96
96
  - **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds, and a prediction-based Autoreg (AR) detector for fast-moving, non-seasonal metrics
97
97
  - **Trend & seasonality handling** — seasonality grouping, recency weighting (`half_life`), robust linear detrending for slowly drifting metrics
98
- - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
98
+ - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Discord, Microsoft Teams, Google Chat, ntfy, Webhook (incl. a Rocket.Chat recipe)
99
99
  - **@mentions** — tag users/groups in alerts, each channel formats natively
100
100
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
101
101
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
@@ -20,7 +20,7 @@
20
20
  - **Pure numpy arrays** — no pandas dependency in core logic
21
21
  - **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds, and a prediction-based Autoreg (AR) detector for fast-moving, non-seasonal metrics
22
22
  - **Trend & seasonality handling** — seasonality grouping, recency weighting (`half_life`), robust linear detrending for slowly drifting metrics
23
- - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
23
+ - **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Discord, Microsoft Teams, Google Chat, ntfy, Webhook (incl. a Rocket.Chat recipe)
24
24
  - **@mentions** — tag users/groups in alerts, each channel formats natively
25
25
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
26
26
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
@@ -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.58.0"
7
+ __version__ = "0.59.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -1,10 +1,14 @@
1
1
  """Alert channels for external notifications."""
2
2
 
3
3
  from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
4
+ from detectkit.alerting.channels.discord import DiscordChannel
4
5
  from detectkit.alerting.channels.email import EmailChannel
5
6
  from detectkit.alerting.channels.factory import AlertChannelFactory
7
+ from detectkit.alerting.channels.googlechat import GoogleChatChannel
6
8
  from detectkit.alerting.channels.mattermost import MattermostChannel
9
+ from detectkit.alerting.channels.ntfy import NtfyChannel
7
10
  from detectkit.alerting.channels.slack import SlackChannel
11
+ from detectkit.alerting.channels.teams import TeamsChannel
8
12
  from detectkit.alerting.channels.telegram import TelegramChannel
9
13
  from detectkit.alerting.channels.webhook import WebhookChannel
10
14
 
@@ -16,5 +20,9 @@ __all__ = [
16
20
  "SlackChannel",
17
21
  "TelegramChannel",
18
22
  "EmailChannel",
23
+ "DiscordChannel",
24
+ "TeamsChannel",
25
+ "GoogleChatChannel",
26
+ "NtfyChannel",
19
27
  "AlertChannelFactory",
20
28
  ]
@@ -0,0 +1,426 @@
1
+ """
2
+ Discord incoming-webhook alert channel.
3
+
4
+ Sends alerts to a Discord channel via an "Execute Webhook" incoming webhook
5
+ URL (``https://discord.com/api/webhooks/<id>/<token>``).
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import requests
13
+
14
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
15
+ from detectkit.alerting.channels.branding import BRAND_ICON_URL, BRAND_USERNAME
16
+
17
+ # Discord embed limits (https://discord.com/developers/docs/resources/message#embed-object).
18
+ # Enforced defensively so a verbose alert never triggers a 400 from Discord.
19
+ _TITLE_CAP = 256
20
+ _DESCRIPTION_CAP = 4096
21
+ _FIELD_NAME_CAP = 256
22
+ _FIELD_VALUE_CAP = 1024
23
+ _FOOTER_CAP = 2048
24
+ # Detector params are appended to the description as a fenced block; capped on
25
+ # their own, and the whole block is dropped if it would push the description
26
+ # past this budget (well under the hard 4096 cap, leaving headroom for the
27
+ # lead/rule/value/links sections that precede it).
28
+ _PARAMS_CAP = 900
29
+ _PARAMS_DESC_BUDGET = 3500
30
+ # Top-level message content (the mentions line) has its own 2000-char limit,
31
+ # and the SUM of title + description + field names/values + footer text across
32
+ # the embed must stay under 6000 or Discord rejects the whole message.
33
+ _CONTENT_CAP = 2000
34
+ _EMBED_TOTAL_CAP = 6000
35
+
36
+
37
+ class DiscordChannel(BaseAlertChannel):
38
+ """
39
+ Discord alert channel using an incoming webhook.
40
+
41
+ Sends one **embed** per alert via Discord's "Execute Webhook" endpoint.
42
+ Discord embeds have no "Show more" fold (unlike Slack/Mattermost
43
+ attachments), so the verbose evidence that ``WebhookChannel`` collapses
44
+ behind a fold instead rides in a compact inline **field grid** — Quorum /
45
+ Severity / the anomalous span / Detectors for an anomaly, the incident
46
+ timeline for a recovery. No-data and error alerts stay short (no fields).
47
+
48
+ The default (no custom ``template``) embed body mirrors
49
+ ``WebhookChannel._build_rich_attachments``' section order: the lead
50
+ sentence + a **Rule** chip, then **Value** / **Expected** plus a compact
51
+ **Links** line (Dashboard, any extra links, then the "how to read this
52
+ alert" guide — clickable labels, never raw URLs). Detector parameters
53
+ (anomaly only) are appended as a fenced code block, capped and dropped
54
+ entirely if they would blow the description budget.
55
+
56
+ Mentions ride in the top-level ``content`` field — Discord never delivers
57
+ a ping placed inside an embed — paired with an ``allowed_mentions`` object
58
+ so ``@everyone``/``@here``/role mentions actually notify.
59
+
60
+ Parameters:
61
+ webhook_url (str): Discord webhook URL
62
+ (``https://discord.com/api/webhooks/<id>/<token>``).
63
+ username (str): Bot username override (default: the detectkit brand
64
+ name).
65
+ avatar_url (str): Bot avatar image URL override (default: the
66
+ detectkit brand avatar).
67
+ timeout (int): Request timeout in seconds (default: 10).
68
+
69
+ Example:
70
+ >>> channel = DiscordChannel(
71
+ ... webhook_url="https://discord.com/api/webhooks/123/abc"
72
+ ... )
73
+ >>> channel.send(alert_data)
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ webhook_url: str,
79
+ username: str = BRAND_USERNAME,
80
+ avatar_url: str | None = None,
81
+ timeout: int = 10,
82
+ ) -> None:
83
+ """Initialize Discord channel."""
84
+ if not webhook_url:
85
+ raise ValueError("webhook_url is required")
86
+
87
+ self.webhook_url = webhook_url
88
+ self.username = username
89
+ # Default to the detectkit brand avatar; an explicit avatar_url opts out.
90
+ self.avatar_url = avatar_url if avatar_url is not None else BRAND_ICON_URL
91
+ self.timeout = timeout
92
+
93
+ def send(
94
+ self,
95
+ alert_data: AlertData,
96
+ template: str | None = None,
97
+ ) -> bool:
98
+ """
99
+ Send alert to Discord.
100
+
101
+ Args:
102
+ alert_data: Alert data to send.
103
+ template: Optional custom message template. Renders as a single
104
+ plain embed (color/title/footer/timestamp kept, no field
105
+ grid) with the formatted template as the description.
106
+
107
+ Returns:
108
+ True if sent successfully, False otherwise.
109
+
110
+ Example:
111
+ >>> channel = DiscordChannel(webhook_url="https://...")
112
+ >>> success = channel.send(alert_data)
113
+ """
114
+ if template is not None:
115
+ embed = self._build_template_embed(alert_data, template)
116
+ else:
117
+ embed = self._build_embed(alert_data)
118
+
119
+ payload: dict[str, Any] = {
120
+ "username": self.username,
121
+ "avatar_url": self.avatar_url,
122
+ "embeds": [embed],
123
+ }
124
+
125
+ # Mentions inside an embed never ping on Discord — they must ride in
126
+ # the top-level message content, paired with allowed_mentions so the
127
+ # platform actually delivers the ping instead of just rendering text.
128
+ mentions = self.format_mentions(alert_data.mentions)
129
+ if mentions:
130
+ payload["content"] = self._cap(mentions, _CONTENT_CAP)
131
+ payload["allowed_mentions"] = {"parse": ["everyone", "users", "roles"]}
132
+
133
+ try:
134
+ response = requests.post(self.webhook_url, json=payload, timeout=self.timeout)
135
+ response.raise_for_status()
136
+ return True
137
+ except requests.RequestException as e:
138
+ print(f"Failed to send Discord alert: {e}")
139
+ return False
140
+
141
+ # ------------------------------------------------------------------
142
+ # Embed construction
143
+ # ------------------------------------------------------------------
144
+ def _build_embed(self, alert_data: AlertData) -> dict[str, Any]:
145
+ """Build the default rich embed for *alert_data*."""
146
+ ctx = self.build_context(alert_data)
147
+ kind = self.status_kind(alert_data)
148
+
149
+ description = self._build_description(alert_data, ctx, kind)
150
+ description = self._append_params(description, ctx, kind)
151
+ description = self._cap_block(description, _DESCRIPTION_CAP)
152
+
153
+ title = self._cap(self.format_title(alert_data), _TITLE_CAP)
154
+ footer = self._footer(alert_data)
155
+ fields = self._fields(alert_data, ctx, kind)
156
+
157
+ # Discord also enforces a 6000-char cap on the SUM of title +
158
+ # description + all field names/values + footer text; the description
159
+ # is the biggest block, so it absorbs any overflow.
160
+ overhead = (
161
+ len(title) + len(footer["text"]) + sum(len(f["name"]) + len(f["value"]) for f in fields)
162
+ )
163
+ if overhead + len(description) > _EMBED_TOTAL_CAP:
164
+ description = self._cap_block(description, max(_EMBED_TOTAL_CAP - overhead, 64))
165
+
166
+ embed: dict[str, Any] = {
167
+ "color": self._color_int(alert_data),
168
+ "title": title,
169
+ "description": description,
170
+ "footer": footer,
171
+ }
172
+ if alert_data.dashboard_url:
173
+ embed["url"] = alert_data.dashboard_url
174
+ timestamp = self._iso_utc(alert_data.timestamp)
175
+ if timestamp is not None:
176
+ embed["timestamp"] = timestamp
177
+
178
+ if fields:
179
+ embed["fields"] = fields
180
+
181
+ return embed
182
+
183
+ def _build_template_embed(self, alert_data: AlertData, template: str) -> dict[str, Any]:
184
+ """Build the plain embed used for a custom ``template`` (no fields)."""
185
+ title = self._cap(self.format_title(alert_data), _TITLE_CAP)
186
+ footer = self._footer(alert_data)
187
+ # Same 6000-char embed-total guard as the rich embed (title + footer
188
+ # alone can leave less than the 4096 description cap).
189
+ budget = min(_DESCRIPTION_CAP, _EMBED_TOTAL_CAP - len(title) - len(footer["text"]))
190
+ embed: dict[str, Any] = {
191
+ "color": self._color_int(alert_data),
192
+ "title": title,
193
+ "description": self._cap_block(self.format_message(alert_data, template), budget),
194
+ "footer": footer,
195
+ }
196
+ if alert_data.dashboard_url:
197
+ embed["url"] = alert_data.dashboard_url
198
+ timestamp = self._iso_utc(alert_data.timestamp)
199
+ if timestamp is not None:
200
+ embed["timestamp"] = timestamp
201
+ return embed
202
+
203
+ def _build_description(
204
+ self,
205
+ alert_data: AlertData,
206
+ ctx: dict[str, Any],
207
+ kind: str,
208
+ ) -> str:
209
+ """The CommonMark description body, blank-line-separated sections
210
+ mirroring ``WebhookChannel._build_rich_attachments``'s section order:
211
+ (1) the kind lead + the **Rule** chip; (2) Value/Expected (anomaly and
212
+ recovery only) + the compact Links line. The verbose evidence tail
213
+ that the webhook attachment folds moves to :meth:`_fields` instead —
214
+ Discord embeds have no fold.
215
+ """
216
+
217
+ def bold_code(label: str, value: str) -> str:
218
+ return f"**{label}** `{value}`" if value else ""
219
+
220
+ links_line = self._links_line(alert_data, ctx)
221
+
222
+ sections: list[list[str]] = []
223
+ if kind == "anomaly":
224
+ sections.append([ctx["anomaly_lead"], f"**Rule** `{ctx['rule_display']}`"])
225
+ sections.append(
226
+ [
227
+ f"{bold_code('Value', ctx['value_display'])} · "
228
+ f"{bold_code('Expected', ctx['expected_range'])}",
229
+ links_line,
230
+ ]
231
+ )
232
+ elif kind == "recovery":
233
+ sections.append([ctx["recovery_lead"], f"**Rule** `{ctx['rule_display']}`"])
234
+ sections.append(
235
+ [
236
+ f"{bold_code('Value', ctx['value_display'])} · "
237
+ f"{bold_code('Expected', ctx['expected_range'])}",
238
+ links_line,
239
+ ]
240
+ )
241
+ elif kind == "no_data":
242
+ sections.append(["Query returned no datapoint for the latest expected interval."])
243
+ sections.append(
244
+ [
245
+ bold_code("Expected at", ctx["timestamp"]),
246
+ bold_code("Expected", ctx["expected_range"]),
247
+ links_line,
248
+ ]
249
+ )
250
+ else: # error
251
+ err = f"{ctx['error_type']}: {ctx['error_message']}".strip(": ")
252
+ sections.append(["The detectkit pipeline failed for this metric."])
253
+ sections.append(
254
+ [
255
+ bold_code("Detected at", ctx["timestamp"]),
256
+ bold_code("Error", err),
257
+ links_line,
258
+ ]
259
+ )
260
+
261
+ return "\n\n".join(
262
+ "\n".join(entry for entry in section if entry)
263
+ for section in sections
264
+ if any(entry for entry in section)
265
+ )
266
+
267
+ @staticmethod
268
+ def _links_line(alert_data: AlertData, ctx: dict[str, Any]) -> str:
269
+ """ "**Links** [Dashboard](url) · [label](url) · [How to read this
270
+ alert](help)" — clickable labels, never raw URLs (empty when none)."""
271
+ parts: list[str] = []
272
+ if alert_data.dashboard_url:
273
+ parts.append(f"[Dashboard]({alert_data.dashboard_url})")
274
+ for label, url in alert_data.links.items():
275
+ parts.append(f"[{label}]({url})")
276
+ if ctx["help_url"]:
277
+ parts.append(f"[{ctx['help_label']}]({ctx['help_url']})")
278
+ if not parts:
279
+ return ""
280
+ return "**Links** " + " · ".join(parts)
281
+
282
+ def _append_params(self, description: str, ctx: dict[str, Any], kind: str) -> str:
283
+ """Append the fenced detector-params block (anomaly only), dropping it
284
+ entirely rather than truncating the description mid-JSON if it would
285
+ push the body past the budget."""
286
+ if kind != "anomaly" or not ctx["detector_params"]:
287
+ return description
288
+ params = self._cap(ctx["detector_params"], _PARAMS_CAP)
289
+ candidate = f"{description}\n\n**Parameters**\n```\n{params}\n```"
290
+ if len(candidate) <= _PARAMS_DESC_BUDGET:
291
+ return candidate
292
+ return description
293
+
294
+ def _fields(
295
+ self,
296
+ alert_data: AlertData,
297
+ ctx: dict[str, Any],
298
+ kind: str,
299
+ ) -> list[dict[str, Any]]:
300
+ """The inline field grid carrying the verbose tail — empty for
301
+ no_data/error (their short body has no separate tail to move)."""
302
+
303
+ def field(name: str, value: str) -> dict[str, Any]:
304
+ return {
305
+ "name": self._cap(name, _FIELD_NAME_CAP),
306
+ "value": self._cap(value, _FIELD_VALUE_CAP),
307
+ "inline": True,
308
+ }
309
+
310
+ fields: list[dict[str, Any]] = []
311
+ if kind == "anomaly":
312
+ fields.append(
313
+ field(
314
+ "Quorum",
315
+ f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
316
+ )
317
+ )
318
+ fields.append(field("Severity", f"{alert_data.severity:.2f}"))
319
+ if ctx["started_display"]:
320
+ fields.append(field("Anomaly began", ctx["started_display"]))
321
+ fields.append(field("Latest reading", ctx["timestamp"]))
322
+ else:
323
+ fields.append(field("Detected at", ctx["timestamp"]))
324
+ fields.append(field("Detectors", ctx["detector_name"]))
325
+ elif kind == "recovery":
326
+ if ctx["started_display"]:
327
+ fields.append(field("Anomaly began", ctx["started_display"]))
328
+ if ctx["fired_display"]:
329
+ fields.append(field("Alert fired", ctx["fired_display"]))
330
+ fields.append(field("Recovered", ctx["timestamp"]))
331
+ else:
332
+ fields.append(field("Cleared at", ctx["timestamp"]))
333
+ fields.append(field("Detectors", ctx["detector_name"]))
334
+ return fields
335
+
336
+ def _footer(self, alert_data: AlertData) -> dict[str, Any]:
337
+ """Branded footer, paired with the project name when set (mirrors
338
+ ``WebhookChannel``'s "detectkit · <project>" footer)."""
339
+ text = self.username or BRAND_USERNAME
340
+ if alert_data.project_name:
341
+ text = f"{text} · {alert_data.project_name}"
342
+ footer: dict[str, Any] = {"text": self._cap(text, _FOOTER_CAP)}
343
+ if self.avatar_url:
344
+ footer["icon_url"] = self.avatar_url
345
+ return footer
346
+
347
+ def _color_int(self, alert_data: AlertData) -> int:
348
+ """The status accent color as the integer Discord embeds expect."""
349
+ return int(self.status_color(alert_data).lstrip("#"), 16)
350
+
351
+ @staticmethod
352
+ def _cap(value: str, limit: int) -> str:
353
+ """Truncate *value* to *limit* chars with an ellipsis."""
354
+ if len(value) <= limit:
355
+ return value
356
+ return value[: limit - 1] + "…"
357
+
358
+ @classmethod
359
+ def _cap_block(cls, value: str, limit: int) -> str:
360
+ """Truncate a multi-line block to *limit* chars, cutting at the last
361
+ line break before the limit when there is one — so a markdown link
362
+ (``[label](https://…)``) is dropped whole, never sliced mid-URL into
363
+ broken markup."""
364
+ if len(value) <= limit:
365
+ return value
366
+ cut = value.rfind("\n", 0, limit - 2)
367
+ if cut > 0:
368
+ return value[:cut].rstrip() + "\n…"
369
+ return cls._cap(value, limit)
370
+
371
+ @staticmethod
372
+ def _iso_utc(ts: Any) -> str | None:
373
+ """ISO-8601 UTC string (``2026-07-11T10:30:00Z``) for a naive-UTC
374
+ timestamp, or ``None`` when unset/unparseable. Mirrors
375
+ ``WebhookChannel._iso_utc``."""
376
+ if ts is None:
377
+ return None
378
+ try:
379
+ if isinstance(ts, np.datetime64):
380
+ ts = ts.astype("datetime64[s]").astype(datetime)
381
+ if isinstance(ts, datetime):
382
+ if ts.tzinfo is not None:
383
+ ts = ts.astimezone(timezone.utc).replace(tzinfo=None)
384
+ return str(ts.strftime("%Y-%m-%dT%H:%M:%SZ"))
385
+ except (ValueError, TypeError, OverflowError):
386
+ return None
387
+ return None
388
+
389
+ def format_mentions(self, mentions: list[str]) -> str:
390
+ """
391
+ Format mentions into Discord's native ping syntax.
392
+
393
+ ``"all"``/``"everyone"``/``"channel"`` map to ``@everyone``,
394
+ ``"here"`` to ``@here``. A value already shaped like a real Discord
395
+ mention (``<@user_id>``/``<@&role_id>``) passes through verbatim.
396
+ Anything else renders as a bare ``@name`` — bare names render but
397
+ don't actually ping; a real user/role ping needs the ``<@id>`` form,
398
+ which can be put directly in the ``mentions`` list.
399
+
400
+ Args:
401
+ mentions: List of usernames, role/user mentions, or the special
402
+ keywords ``"channel"``/``"all"``/``"everyone"``/``"here"``.
403
+
404
+ Returns:
405
+ Space-joined Discord mention string.
406
+ """
407
+ if not mentions:
408
+ return ""
409
+ parts: list[str] = []
410
+ for m in mentions:
411
+ if m in ("all", "everyone", "channel"):
412
+ parts.append("@everyone")
413
+ elif m == "here":
414
+ parts.append("@here")
415
+ elif m.startswith("<@") and m.endswith(">"):
416
+ parts.append(m)
417
+ else:
418
+ parts.append(f"@{m}")
419
+ return " ".join(parts)
420
+
421
+ def __repr__(self) -> str:
422
+ """String representation."""
423
+ url_preview = (
424
+ self.webhook_url[:30] + "..." if len(self.webhook_url) > 30 else self.webhook_url
425
+ )
426
+ return f"DiscordChannel(url='{url_preview}', username='{self.username}')"
@@ -3,9 +3,13 @@ Alert channel factory for creating channel instances from configuration.
3
3
  """
4
4
 
5
5
  from detectkit.alerting.channels.base import BaseAlertChannel
6
+ from detectkit.alerting.channels.discord import DiscordChannel
6
7
  from detectkit.alerting.channels.email import EmailChannel
8
+ from detectkit.alerting.channels.googlechat import GoogleChatChannel
7
9
  from detectkit.alerting.channels.mattermost import MattermostChannel
10
+ from detectkit.alerting.channels.ntfy import NtfyChannel
8
11
  from detectkit.alerting.channels.slack import SlackChannel
12
+ from detectkit.alerting.channels.teams import TeamsChannel
9
13
  from detectkit.alerting.channels.telegram import TelegramChannel
10
14
  from detectkit.alerting.channels.webhook import WebhookChannel
11
15
  from detectkit.utils.env_interpolation import interpolate_env_vars
@@ -31,6 +35,10 @@ class AlertChannelFactory:
31
35
  "slack": SlackChannel,
32
36
  "telegram": TelegramChannel,
33
37
  "email": EmailChannel,
38
+ "discord": DiscordChannel,
39
+ "teams": TeamsChannel,
40
+ "googlechat": GoogleChatChannel,
41
+ "ntfy": NtfyChannel,
34
42
  }
35
43
 
36
44
  @classmethod