detectkit 0.11.0__tar.gz → 0.13.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 (107) hide show
  1. {detectkit-0.11.0/detectkit.egg-info → detectkit-0.13.0}/PKG-INFO +1 -1
  2. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/channels/base.py +143 -61
  4. detectkit-0.13.0/detectkit/alerting/channels/branding.py +20 -0
  5. detectkit-0.13.0/detectkit/alerting/channels/email.py +464 -0
  6. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/channels/mattermost.py +12 -4
  7. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/channels/slack.py +12 -4
  8. detectkit-0.13.0/detectkit/alerting/channels/telegram.py +273 -0
  9. detectkit-0.13.0/detectkit/alerting/channels/webhook.py +403 -0
  10. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_base.py +4 -0
  11. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_decision.py +4 -0
  12. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_recovery.py +2 -0
  13. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/alerting.md +50 -3
  14. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/detectors.md +5 -5
  15. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/project.md +17 -3
  16. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +3 -0
  17. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +9 -4
  18. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/init.py +7 -5
  19. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/test_alert.py +2 -0
  20. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/config/metric_config.py +30 -0
  21. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/_alert_step.py +2 -0
  22. {detectkit-0.11.0 → detectkit-0.13.0/detectkit.egg-info}/PKG-INFO +1 -1
  23. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit.egg-info/SOURCES.txt +1 -0
  24. detectkit-0.11.0/detectkit/alerting/channels/email.py +0 -170
  25. detectkit-0.11.0/detectkit/alerting/channels/telegram.py +0 -141
  26. detectkit-0.11.0/detectkit/alerting/channels/webhook.py +0 -217
  27. {detectkit-0.11.0 → detectkit-0.13.0}/LICENSE +0 -0
  28. {detectkit-0.11.0 → detectkit-0.13.0}/MANIFEST.in +0 -0
  29. {detectkit-0.11.0 → detectkit-0.13.0}/README.md +0 -0
  30. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/__init__.py +0 -0
  31. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/channels/__init__.py +0 -0
  32. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/channels/factory.py +0 -0
  33. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  34. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  35. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  36. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  37. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  38. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/__init__.py +0 -0
  39. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/_output.py +0 -0
  40. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  41. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  42. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  43. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  44. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/__init__.py +0 -0
  45. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/clean.py +0 -0
  46. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/init_claude.py +0 -0
  47. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/run.py +0 -0
  48. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/commands/unlock.py +0 -0
  49. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/cli/main.py +0 -0
  50. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/config/__init__.py +0 -0
  51. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/config/profile.py +0 -0
  52. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/config/project_config.py +0 -0
  53. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/config/validator.py +0 -0
  54. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/core/__init__.py +0 -0
  55. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/core/interval.py +0 -0
  56. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/core/models.py +0 -0
  57. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/__init__.py +0 -0
  58. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/_sql_manager.py +0 -0
  59. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/clickhouse_manager.py +0 -0
  60. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/__init__.py +0 -0
  61. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  62. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_base.py +0 -0
  63. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  64. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_detections.py +0 -0
  65. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  66. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  67. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_schema.py +0 -0
  68. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  69. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/internal_tables/manager.py +0 -0
  70. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/manager.py +0 -0
  71. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/mysql_manager.py +0 -0
  72. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/postgres_manager.py +0 -0
  73. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/database/tables.py +0 -0
  74. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/__init__.py +0 -0
  75. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/base.py +0 -0
  76. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/factory.py +0 -0
  77. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/seasonality.py +0 -0
  78. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/__init__.py +0 -0
  79. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  80. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/iqr.py +0 -0
  81. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/mad.py +0 -0
  82. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  83. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/detectors/statistical/zscore.py +0 -0
  84. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/loaders/__init__.py +0 -0
  85. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/loaders/metric_loader.py +0 -0
  86. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/loaders/query_template.py +0 -0
  87. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/__init__.py +0 -0
  88. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/error_dispatch.py +0 -0
  89. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  90. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  91. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  92. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  93. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  94. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  95. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/utils/__init__.py +0 -0
  96. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/utils/datetime_utils.py +0 -0
  97. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/utils/env_interpolation.py +0 -0
  98. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/utils/json_utils.py +0 -0
  99. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit/utils/stats.py +0 -0
  100. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit.egg-info/dependency_links.txt +0 -0
  101. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit.egg-info/entry_points.txt +0 -0
  102. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit.egg-info/requires.txt +0 -0
  103. {detectkit-0.11.0 → detectkit-0.13.0}/detectkit.egg-info/top_level.txt +0 -0
  104. {detectkit-0.11.0 → detectkit-0.13.0}/pyproject.toml +0 -0
  105. {detectkit-0.11.0 → detectkit-0.13.0}/requirements.txt +0 -0
  106. {detectkit-0.11.0 → detectkit-0.13.0}/setup.cfg +0 -0
  107. {detectkit-0.11.0 → detectkit-0.13.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.11.0
3
+ Version: 0.13.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.11.0"
7
+ __version__ = "0.13.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -67,6 +67,14 @@ class AlertData:
67
67
  description: str | None = None
68
68
  mentions: list[str] = field(default_factory=list)
69
69
  project_name: str | None = None
70
+ # Optional actionable links surfaced in the message. ``dashboard_url`` is the
71
+ # headline link (rendered natively as a clickable title/link on
72
+ # Slack/Mattermost, an ``<a>`` on Telegram, a button in email, and exposed as
73
+ # the ``{dashboard_url}`` template variable). ``links`` adds further
74
+ # ``label -> url`` pairs for advanced use. Both default to empty so existing
75
+ # callers and templates render unchanged.
76
+ dashboard_url: str | None = None
77
+ links: dict[str, str] = field(default_factory=dict)
70
78
  # Alert rule (the parameters the alert fired with) — see class docstring.
71
79
  min_detectors: int | None = None
72
80
  direction_policy: str | None = None
@@ -171,12 +179,47 @@ class BaseAlertChannel(ABC):
171
179
  else:
172
180
  template = self.get_default_template()
173
181
 
174
- # Format timestamp to string
182
+ ctx = self.build_context(alert_data)
183
+
184
+ try:
185
+ message = template.format(**ctx)
186
+ except (KeyError, ValueError, TypeError):
187
+ # Template has an unknown variable or a format spec that doesn't fit
188
+ # the actual value (e.g. ``{value:.2f}`` in a no-data template where
189
+ # value is a string). Fall back to the kind-appropriate default.
190
+ if alert_data.is_error:
191
+ fallback = self.get_default_error_template()
192
+ elif alert_data.is_no_data:
193
+ fallback = self.get_default_no_data_template()
194
+ elif alert_data.is_recovery:
195
+ fallback = self.get_default_recovery_template()
196
+ else:
197
+ fallback = self.get_default_template()
198
+ if template == fallback:
199
+ # Already on the default — re-raise instead of recursing.
200
+ raise
201
+ message = self.format_message(alert_data, fallback)
202
+
203
+ return message
204
+
205
+ def build_context(self, alert_data: AlertData) -> dict[str, Any]:
206
+ """Compute the display-ready variables for *alert_data*.
207
+
208
+ This is the **single source** of the values injected into message
209
+ templates *and* consumed by channels that render natively (the webhook
210
+ attachment fields, the Telegram HTML message, the email HTML card), so
211
+ every surface stays consistent. It does no escaping — each channel
212
+ applies its own (HTML for Telegram/email, markdown for webhook).
213
+
214
+ Returns a dict whose keys are exactly the ``{placeholders}`` the default
215
+ templates use, plus a few extras (``dashboard_url``, ``dashboard_line``).
216
+ """
175
217
  import math
176
218
  from datetime import datetime
177
219
 
178
220
  import numpy as np
179
221
 
222
+ # Format timestamp to string
180
223
  ts = alert_data.timestamp
181
224
  if isinstance(ts, np.datetime64):
182
225
  ts = ts.astype(datetime)
@@ -248,13 +291,18 @@ class BaseAlertChannel(ABC):
248
291
  mentions_str = self.format_mentions(alert_data.mentions)
249
292
  mentions_line = f"\n{mentions_str}" if mentions_str else ""
250
293
 
294
+ # Optional dashboard link surfaced both as a raw placeholder and a ready
295
+ # "Dashboard: <url>" line (empty when unset so templates stay clean).
296
+ dashboard_url = alert_data.dashboard_url or ""
297
+ dashboard_line = f"Dashboard: {dashboard_url}\n" if dashboard_url else ""
298
+
251
299
  # Project name + synth prefix for templates. Prefix is empty when
252
300
  # project_name is None so default templates render cleanly for
253
301
  # callers that don't set it.
254
302
  project_name = alert_data.project_name or ""
255
303
  project_name_prefix = f"[{alert_data.project_name}] " if alert_data.project_name else ""
256
304
 
257
- # Format message
305
+ # Status keyword
258
306
  if alert_data.is_error:
259
307
  status = "ERROR"
260
308
  elif alert_data.is_no_data:
@@ -264,54 +312,84 @@ class BaseAlertChannel(ABC):
264
312
  else:
265
313
  status = "ANOMALY"
266
314
 
267
- try:
268
- message = template.format(
269
- metric_name=alert_data.metric_name,
270
- project_name=project_name,
271
- project_name_prefix=project_name_prefix,
272
- timestamp=ts_str,
273
- timezone=alert_data.timezone,
274
- value=value_for_template,
275
- value_display=value_display,
276
- confidence_lower=alert_data.confidence_lower,
277
- confidence_upper=alert_data.confidence_upper,
278
- confidence_interval=confidence_str,
279
- expected_range=expected_range,
280
- detector_name=alert_data.detector_name,
281
- detector_count=detector_count,
282
- detector_params=alert_data.detector_params,
283
- direction=alert_data.direction,
284
- direction_policy=direction_policy,
285
- min_detectors=min_detectors,
286
- severity=alert_data.severity,
287
- consecutive_count=alert_data.consecutive_count,
288
- consecutive_required=consecutive_required,
289
- status=status,
290
- error_type=alert_data.error_type or "",
291
- error_message=alert_data.error_message or "",
292
- description=alert_data.description or "",
293
- description_line=description_line,
294
- mentions=mentions_str,
295
- mentions_line=mentions_line,
296
- )
297
- except (KeyError, ValueError, TypeError):
298
- # Template has an unknown variable or a format spec that doesn't fit
299
- # the actual value (e.g. ``{value:.2f}`` in a no-data template where
300
- # value is a string). Fall back to the kind-appropriate default.
301
- if alert_data.is_error:
302
- fallback = self.get_default_error_template()
303
- elif alert_data.is_no_data:
304
- fallback = self.get_default_no_data_template()
305
- elif alert_data.is_recovery:
306
- fallback = self.get_default_recovery_template()
307
- else:
308
- fallback = self.get_default_template()
309
- if template == fallback:
310
- # Already on the default — re-raise instead of recursing.
311
- raise
312
- message = self.format_message(alert_data, fallback)
315
+ return {
316
+ "metric_name": alert_data.metric_name,
317
+ "project_name": project_name,
318
+ "project_name_prefix": project_name_prefix,
319
+ "timestamp": ts_str,
320
+ "timezone": alert_data.timezone,
321
+ "value": value_for_template,
322
+ "value_display": value_display,
323
+ "confidence_lower": alert_data.confidence_lower,
324
+ "confidence_upper": alert_data.confidence_upper,
325
+ "confidence_interval": confidence_str,
326
+ "expected_range": expected_range,
327
+ "detector_name": alert_data.detector_name,
328
+ "detector_count": detector_count,
329
+ "detector_params": alert_data.detector_params,
330
+ "direction": alert_data.direction,
331
+ "direction_policy": direction_policy,
332
+ "min_detectors": min_detectors,
333
+ "severity": alert_data.severity,
334
+ "consecutive_count": alert_data.consecutive_count,
335
+ "consecutive_required": consecutive_required,
336
+ "status": status,
337
+ "error_type": alert_data.error_type or "",
338
+ "error_message": alert_data.error_message or "",
339
+ "description": alert_data.description or "",
340
+ "description_line": description_line,
341
+ "dashboard_url": dashboard_url,
342
+ "dashboard_line": dashboard_line,
343
+ "mentions": mentions_str,
344
+ "mentions_line": mentions_line,
345
+ }
346
+
347
+ # ---- Status presentation (shared accents across all channels) ----
348
+ # Kept in sync with the brand status tokens (.claude/rules/design.md) and the
349
+ # website status colors so chat, email and dashboards read the same way.
350
+ _STATUS_COLORS = {
351
+ "anomaly": "#D63232",
352
+ "recovery": "#36A64F",
353
+ "no_data": "#F0AD4E",
354
+ "error": "#5A7A8C",
355
+ }
356
+ _STATUS_WORDS = {
357
+ "anomaly": "Anomaly",
358
+ "recovery": "Recovered",
359
+ "no_data": "No data",
360
+ "error": "Pipeline error",
361
+ }
362
+ # Colored status dots — the at-a-glance status cue that leads every alert
363
+ # title/headline (and the only color cue on Telegram, which has no bar).
364
+ _STATUS_EMOJI = {
365
+ "anomaly": "\U0001f534", # red circle
366
+ "recovery": "\U0001f7e2", # green circle
367
+ "no_data": "\U0001f7e1", # yellow circle
368
+ "error": "\U0001f535", # blue circle
369
+ }
370
+
371
+ @staticmethod
372
+ def status_kind(alert_data: AlertData) -> str:
373
+ """Return the alert kind: ``anomaly`` / ``recovery`` / ``no_data`` / ``error``."""
374
+ if alert_data.is_error:
375
+ return "error"
376
+ if alert_data.is_no_data:
377
+ return "no_data"
378
+ if alert_data.is_recovery:
379
+ return "recovery"
380
+ return "anomaly"
313
381
 
314
- return message
382
+ def status_color(self, alert_data: AlertData) -> str:
383
+ """Accent color for this alert kind (hex)."""
384
+ return self._STATUS_COLORS[self.status_kind(alert_data)]
385
+
386
+ def status_word(self, alert_data: AlertData) -> str:
387
+ """Human-readable status word for this alert kind."""
388
+ return self._STATUS_WORDS[self.status_kind(alert_data)]
389
+
390
+ def status_emoji(self, alert_data: AlertData) -> str:
391
+ """Colored status dot for channels without a native color bar."""
392
+ return self._STATUS_EMOJI[self.status_kind(alert_data)]
315
393
 
316
394
  def format_mentions(self, mentions: list[str]) -> str:
317
395
  """
@@ -372,7 +450,7 @@ class BaseAlertChannel(ABC):
372
450
  Default template string
373
451
  """
374
452
  return (
375
- " Alert: {metric_name}\n"
453
+ "🔴 Alert: {metric_name}\n"
376
454
  "{description_line}"
377
455
  "Quorum {detector_count}/{min_detectors} · "
378
456
  "direction {direction} (policy {direction_policy}) · "
@@ -385,7 +463,8 @@ class BaseAlertChannel(ABC):
385
463
  "· Value: {value_display} | Expected: {expected_range}\n"
386
464
  "· Severity: {severity:.2f}\n"
387
465
  "Detectors: {detector_name}\n"
388
- "Parameters: {detector_params}"
466
+ "Parameters: {detector_params}\n"
467
+ "{dashboard_line}"
389
468
  "{mentions_line}"
390
469
  )
391
470
 
@@ -397,7 +476,7 @@ class BaseAlertChannel(ABC):
397
476
  Default recovery template string
398
477
  """
399
478
  return (
400
- " Alert cleared: {metric_name}\n"
479
+ "🟢 Alert cleared: {metric_name}\n"
401
480
  "{description_line}"
402
481
  "The alert condition no longer holds — "
403
482
  "the metric is back within expected bounds.\n"
@@ -407,7 +486,8 @@ class BaseAlertChannel(ABC):
407
486
  "Latest point:\n"
408
487
  "· Time: {timestamp}\n"
409
488
  "· Value: {value_display} | Expected: {expected_range}\n"
410
- "Detectors: {detector_name}"
489
+ "Detectors: {detector_name}\n"
490
+ "{dashboard_line}"
411
491
  "{mentions_line}"
412
492
  )
413
493
 
@@ -420,7 +500,7 @@ class BaseAlertChannel(ABC):
420
500
  Returns:
421
501
  Default title template string
422
502
  """
423
- return " Alert: {metric_name}"
503
+ return "🔴 Alert: {metric_name}"
424
504
 
425
505
  def get_default_recovery_title_template(self) -> str:
426
506
  """
@@ -429,7 +509,7 @@ class BaseAlertChannel(ABC):
429
509
  Returns:
430
510
  Default recovery title template string
431
511
  """
432
- return " Alert cleared: {metric_name}"
512
+ return "🟢 Alert cleared: {metric_name}"
433
513
 
434
514
  def get_default_no_data_template(self) -> str:
435
515
  """
@@ -439,24 +519,26 @@ class BaseAlertChannel(ABC):
439
519
  has no datapoint (no row OR row with NULL/NaN value).
440
520
  """
441
521
  return (
442
- "No data for metric: {metric_name}\n"
522
+ "🟡 No data for metric: {metric_name}\n"
443
523
  "{description_line}"
444
524
  "Time: {timestamp}\n"
445
- "Status: query returned no datapoint for the latest interval"
525
+ "Status: query returned no datapoint for the latest interval\n"
526
+ "{dashboard_line}"
446
527
  "{mentions_line}"
447
528
  )
448
529
 
449
530
  def get_default_no_data_title_template(self) -> str:
450
531
  """Get default title template for no-data alerts."""
451
- return "No data: {metric_name}"
532
+ return "🟡 No data: {metric_name}"
452
533
 
453
534
  def get_default_error_template(self) -> str:
454
535
  """Default body template for project-level error alerts."""
455
536
  return (
456
- "Pipeline failed for metric: {metric_name}\n"
537
+ "🔵 Pipeline failed for metric: {metric_name}\n"
457
538
  "{description_line}"
458
539
  "Time: {timestamp}\n"
459
- "Error: {error_type}: {error_message}"
540
+ "Error: {error_type}: {error_message}\n"
541
+ "{dashboard_line}"
460
542
  "{mentions_line}"
461
543
  )
462
544
 
@@ -468,7 +550,7 @@ class BaseAlertChannel(ABC):
468
550
  stay distinguishable. The prefix collapses to an empty string
469
551
  when ``AlertData.project_name`` is None.
470
552
  """
471
- return "{project_name_prefix}Pipeline error: {metric_name}"
553
+ return "🔵 {project_name_prefix}Pipeline error: {metric_name}"
472
554
 
473
555
  def __repr__(self) -> str:
474
556
  """String representation of channel."""
@@ -0,0 +1,20 @@
1
+ """
2
+ Default detectkit branding for alert channels.
3
+
4
+ Centralizes the bot identity — display name and avatar — so every channel
5
+ defaults to the **detectkit brand**. These are only the fallbacks: each channel
6
+ still accepts per-channel overrides (``username`` / ``from_name`` for the name,
7
+ ``icon_url`` / ``icon_emoji`` for the avatar). Keeping them in one place means
8
+ the brand avatar URL and name have a single source of truth.
9
+ """
10
+
11
+ # Bot display name shown as the message sender across channels (webhook-family
12
+ # ``username`` and the email ``From`` display name).
13
+ BRAND_USERNAME = "detectkit"
14
+
15
+ # Brand avatar served from the docs site. Slack/Mattermost render the webhook
16
+ # bot icon from a **raster** URL (an SVG is not rendered as a bot avatar), so
17
+ # this points at a PNG. Override per channel with ``icon_url`` (a custom image)
18
+ # or opt out of the avatar entirely with ``icon_emoji``. The PNG is generated by
19
+ # ``website/scripts/make-bot-icon.mjs`` and served from ``website/public/``.
20
+ BRAND_ICON_URL = "https://dtk.pipelab.dev/bot-icon.png"