detectkit 0.34.0__tar.gz → 0.35.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 (138) hide show
  1. {detectkit-0.34.0/detectkit.egg-info → detectkit-0.35.0}/PKG-INFO +1 -1
  2. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/base.py +39 -8
  4. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/email.py +6 -4
  5. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/telegram.py +10 -5
  6. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/webhook.py +8 -6
  7. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/alerting.md +15 -10
  8. {detectkit-0.34.0 → detectkit-0.35.0/detectkit.egg-info}/PKG-INFO +1 -1
  9. {detectkit-0.34.0 → detectkit-0.35.0}/LICENSE +0 -0
  10. {detectkit-0.34.0 → detectkit-0.35.0}/MANIFEST.in +0 -0
  11. {detectkit-0.34.0 → detectkit-0.35.0}/README.md +0 -0
  12. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/__init__.py +0 -0
  13. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/__init__.py +0 -0
  14. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/branding.py +0 -0
  15. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/factory.py +0 -0
  16. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/mattermost.py +0 -0
  17. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/channels/slack.py +0 -0
  18. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  19. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  20. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  21. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  22. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  23. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  24. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  25. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  26. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  27. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/__init__.py +0 -0
  28. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/_base.py +0 -0
  29. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/_types.py +0 -0
  30. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/autotuner.py +0 -0
  31. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/config_emitter.py +0 -0
  32. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/crossval.py +0 -0
  33. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/detector_select.py +0 -0
  34. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/distribution.py +0 -0
  35. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/grid_search.py +0 -0
  36. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/html_labeler.py +0 -0
  37. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/label_server.py +0 -0
  38. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/labels.py +0 -0
  39. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/result.py +0 -0
  40. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/scoring.py +0 -0
  41. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/seasonality_search.py +0 -0
  42. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/settings.py +0 -0
  43. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/autotune/window_select.py +0 -0
  44. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/__init__.py +0 -0
  45. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/_output.py +0 -0
  46. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  47. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  48. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  49. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  50. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  51. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  52. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  53. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  54. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  55. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  56. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  57. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/__init__.py +0 -0
  58. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/autotune.py +0 -0
  59. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/clean.py +0 -0
  60. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/init.py +0 -0
  61. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/init_claude.py +0 -0
  62. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/run.py +0 -0
  63. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/test_alert.py +0 -0
  64. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/tune.py +0 -0
  65. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/commands/unlock.py +0 -0
  66. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/cli/main.py +0 -0
  67. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/config/__init__.py +0 -0
  68. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/config/metric_config.py +0 -0
  69. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/config/profile.py +0 -0
  70. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/config/project_config.py +0 -0
  71. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/config/validator.py +0 -0
  72. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/core/__init__.py +0 -0
  73. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/core/interval.py +0 -0
  74. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/core/models.py +0 -0
  75. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/__init__.py +0 -0
  76. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/_sql_manager.py +0 -0
  77. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/clickhouse_manager.py +0 -0
  78. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/__init__.py +0 -0
  79. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  80. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  81. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  83. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_detections.py +0 -0
  84. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  85. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  86. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_schema.py +0 -0
  87. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  88. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/internal_tables/manager.py +0 -0
  89. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/manager.py +0 -0
  90. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/mysql_manager.py +0 -0
  91. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/postgres_manager.py +0 -0
  92. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/database/tables.py +0 -0
  93. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/__init__.py +0 -0
  94. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/base.py +0 -0
  95. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/factory.py +0 -0
  96. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/seasonality.py +0 -0
  97. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/__init__.py +0 -0
  98. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  99. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/iqr.py +0 -0
  100. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/mad.py +0 -0
  101. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  102. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/detectors/statistical/zscore.py +0 -0
  103. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/loaders/__init__.py +0 -0
  104. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/loaders/metric_loader.py +0 -0
  105. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/loaders/query_template.py +0 -0
  106. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/__init__.py +0 -0
  107. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/error_dispatch.py +0 -0
  108. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  109. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  110. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  111. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  112. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  113. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  114. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  115. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/reporting/__init__.py +0 -0
  116. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/reporting/assets/report.js +0 -0
  117. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/reporting/builder.py +0 -0
  118. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/reporting/html_report.py +0 -0
  119. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/__init__.py +0 -0
  120. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/assets/tune.js +0 -0
  121. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/config_writer.py +0 -0
  122. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/html.py +0 -0
  123. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/payload.py +0 -0
  124. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/tuning/server.py +0 -0
  125. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/utils/__init__.py +0 -0
  126. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/utils/datetime_utils.py +0 -0
  127. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/utils/env_interpolation.py +0 -0
  128. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/utils/json_utils.py +0 -0
  129. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit/utils/stats.py +0 -0
  130. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit.egg-info/SOURCES.txt +0 -0
  131. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit.egg-info/dependency_links.txt +0 -0
  132. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit.egg-info/entry_points.txt +0 -0
  133. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit.egg-info/requires.txt +0 -0
  134. {detectkit-0.34.0 → detectkit-0.35.0}/detectkit.egg-info/top_level.txt +0 -0
  135. {detectkit-0.34.0 → detectkit-0.35.0}/pyproject.toml +0 -0
  136. {detectkit-0.34.0 → detectkit-0.35.0}/requirements.txt +0 -0
  137. {detectkit-0.34.0 → detectkit-0.35.0}/setup.cfg +0 -0
  138. {detectkit-0.34.0 → detectkit-0.35.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.34.0
3
+ Version: 0.35.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.34.0"
7
+ __version__ = "0.35.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -183,12 +183,16 @@ class BaseAlertChannel(ABC):
183
183
  - {interval_display} — metric interval as a string (e.g. "10min")
184
184
  - {duration_display} — how long the streak/incident lasted
185
185
  (e.g. "2h 30m"; "over …" when it predates the lookback window)
186
- - {onset_display} / {started_display} — first timestamp of the run
187
- ({started_display} adds "or earlier" when the run is capped)
186
+ - {onset_display} / {started_display} — first anomalous timestamp of
187
+ the run ({started_display} adds "or earlier" when the run is capped)
188
+ - {fired_display} — on-grid moment the alert first fired
189
+ (onset + (consecutive_required − 1) × interval); empty when the run
190
+ is capped or timing isn't wired in
188
191
  - {anomaly_lead} / {recovery_lead} — the ready-made plain-language
189
192
  lead sentence ("Anomalous for …" / "… Incident lasted …")
190
- - {window_line} — "Started: … | Latest/Cleared: …" (or a single
191
- "Detected at: …" line when the onset is unknown)
193
+ - {window_line} — "Anomaly began: … | Latest reading: …" (anomaly) /
194
+ "Anomaly began: … | Alert fired: | Recovered: …" (recovery), or a
195
+ single "Detected at: …" line when the onset is unknown
192
196
  - {severity}
193
197
  - {status}
194
198
 
@@ -249,7 +253,7 @@ class BaseAlertChannel(ABC):
249
253
  templates use, plus a few extras (``dashboard_url``, ``dashboard_line``).
250
254
  """
251
255
  import math
252
- from datetime import datetime
256
+ from datetime import datetime, timedelta
253
257
  from zoneinfo import ZoneInfo
254
258
 
255
259
  import numpy as np
@@ -269,6 +273,14 @@ class BaseAlertChannel(ABC):
269
273
  return f"{t.strftime('%Y-%m-%d %H:%M:%S')} ({alert_data.timezone})"
270
274
  return t.strftime("%Y-%m-%d %H:%M:%S")
271
275
 
276
+ def _shift_ts(value: Any, seconds: int) -> Any:
277
+ """Shift a timestamp forward by *seconds*, preserving its type."""
278
+ if isinstance(value, np.datetime64):
279
+ return value + np.timedelta64(int(seconds), "s")
280
+ if isinstance(value, datetime):
281
+ return value + timedelta(seconds=int(seconds))
282
+ return value
283
+
272
284
  ts_str = _fmt_ts(alert_data.timestamp)
273
285
  onset_str = _fmt_ts(alert_data.onset_timestamp)
274
286
 
@@ -359,13 +371,31 @@ class BaseAlertChannel(ABC):
359
371
  "expected bounds."
360
372
  )
361
373
 
374
+ # "Alert fired" — the on-grid moment the rule's consecutive threshold was
375
+ # first met: onset + (consecutive_required - 1) * interval. Recovery
376
+ # messages render it as the middle of the "anomaly began → alert fired →
377
+ # recovered" timeline so a stakeholder can tell the onset apart from when
378
+ # detectkit actually notified. Skipped when the run is capped (onset is
379
+ # only a lower bound, so the fire time is unknown) or timing isn't wired
380
+ # in (direct-API callers). The firing message doesn't show it: there it
381
+ # coincides with the latest point the alert is firing on.
382
+ fired_display = ""
383
+ if alert_data.onset_timestamp is not None and interval_seconds and not capped:
384
+ req = (
385
+ consecutive_required if (consecutive_required and consecutive_required >= 1) else 1
386
+ )
387
+ fired_ts = _shift_ts(alert_data.onset_timestamp, (req - 1) * interval_seconds)
388
+ fired_display = _fmt_ts(fired_ts)
389
+
362
390
  # Kind-aware "window" line for the plain-text templates: the anomalous
363
- # span (onset → latest/cleared) when known, else the single point.
391
+ # span (began → latest, or began → fired → recovered) when known, else
392
+ # the single point.
364
393
  kind = self.status_kind(alert_data)
365
394
  if started_display and kind == "anomaly":
366
- window_line = f"Started: {started_display} | Latest: {ts_str}\n"
395
+ window_line = f"Anomaly began: {started_display} | Latest reading: {ts_str}\n"
367
396
  elif started_display and kind == "recovery":
368
- window_line = f"Started: {started_display} | Cleared: {ts_str}\n"
397
+ fired_part = f"Alert fired: {fired_display} | " if fired_display else ""
398
+ window_line = f"Anomaly began: {started_display} | {fired_part}Recovered: {ts_str}\n"
369
399
  else:
370
400
  window_label = {
371
401
  "recovery": "Cleared at",
@@ -443,6 +473,7 @@ class BaseAlertChannel(ABC):
443
473
  "streak_capped": capped,
444
474
  "onset_display": onset_str,
445
475
  "started_display": started_display,
476
+ "fired_display": fired_display,
446
477
  "anomaly_lead": anomaly_lead,
447
478
  "recovery_lead": recovery_lead,
448
479
  "window_line": window_line,
@@ -326,8 +326,8 @@ class EmailChannel(BaseAlertChannel):
326
326
  ("Quorum", f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}"),
327
327
  ]
328
328
  if ctx["started_display"]:
329
- stats.append(("Started", ctx["started_display"]))
330
- stats.append(("Latest", ctx["timestamp"]))
329
+ stats.append(("Anomaly began", ctx["started_display"]))
330
+ stats.append(("Latest reading", ctx["timestamp"]))
331
331
  else:
332
332
  stats.append(("Detected at", ctx["timestamp"]))
333
333
  parts.append(self._stat_grid(stats))
@@ -341,8 +341,10 @@ class EmailChannel(BaseAlertChannel):
341
341
  ("Expected", ctx["expected_range"]),
342
342
  ]
343
343
  if ctx["started_display"]:
344
- stats.append(("Started", ctx["started_display"]))
345
- stats.append(("Cleared", ctx["timestamp"]))
344
+ stats.append(("Anomaly began", ctx["started_display"]))
345
+ if ctx["fired_display"]:
346
+ stats.append(("Alert fired", ctx["fired_display"]))
347
+ stats.append(("Recovered", ctx["timestamp"]))
346
348
  else:
347
349
  stats.append(("Cleared at", ctx["timestamp"]))
348
350
  stats.append(("Detector", ctx["detector_name"]))
@@ -182,8 +182,8 @@ class TelegramChannel(BaseAlertChannel):
182
182
  lines.append(f"• Severity: <code>{alert_data.severity:.2f}</code>")
183
183
  if ctx["started_display"]:
184
184
  lines.append(
185
- f"• Started: <code>{esc(ctx['started_display'])}</code> · "
186
- f"Latest: <code>{esc(ctx['timestamp'])}</code>"
185
+ f"• Anomaly began: <code>{esc(ctx['started_display'])}</code> · "
186
+ f"Latest reading: <code>{esc(ctx['timestamp'])}</code>"
187
187
  )
188
188
  else:
189
189
  lines.append(f"• Time: <code>{esc(ctx['timestamp'])}</code>")
@@ -204,12 +204,17 @@ class TelegramChannel(BaseAlertChannel):
204
204
  f"Expected: <code>{esc(ctx['expected_range'])}</code>"
205
205
  )
206
206
  if ctx["started_display"]:
207
+ fired = (
208
+ f"Alert fired: <code>{esc(ctx['fired_display'])}</code> · "
209
+ if ctx["fired_display"]
210
+ else ""
211
+ )
207
212
  lines.append(
208
- f"• Started: <code>{esc(ctx['started_display'])}</code> · "
209
- f"Cleared: <code>{esc(ctx['timestamp'])}</code>"
213
+ f"• Anomaly began: <code>{esc(ctx['started_display'])}</code> · "
214
+ f"{fired}Recovered: <code>{esc(ctx['timestamp'])}</code>"
210
215
  )
211
216
  else:
212
- lines.append(f"• Cleared: <code>{esc(ctx['timestamp'])}</code>")
217
+ lines.append(f"• Recovered: <code>{esc(ctx['timestamp'])}</code>")
213
218
  lines.append(f"• Detector: <code>{esc(ctx['detector_name'])}</code>")
214
219
  elif kind == "no_data":
215
220
  lines.append("Query returned no datapoint for the latest expected interval.")
@@ -276,10 +276,10 @@ class WebhookChannel(BaseAlertChannel):
276
276
  short("Expected", code(ctx["expected_range"]))
277
277
  short("Quorum", f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}")
278
278
  short("Severity", f"{alert_data.severity:.2f}")
279
- # The problematic span: when it started and the latest point in it.
279
+ # The problematic span: when the anomaly began and its latest point.
280
280
  if ctx["started_display"]:
281
- short("Started", ctx["started_display"])
282
- short("Latest", ctx["timestamp"])
281
+ short("Anomaly began", ctx["started_display"])
282
+ short("Latest reading", ctx["timestamp"])
283
283
  else:
284
284
  full("Detected at", ctx["timestamp"])
285
285
  full("Detectors", code(ctx["detector_name"]))
@@ -289,10 +289,12 @@ class WebhookChannel(BaseAlertChannel):
289
289
  lead = f"{ctx['recovery_lead']}\n{rule_chip}"
290
290
  short("Value", code(ctx["value_display"]))
291
291
  short("Expected", code(ctx["expected_range"]))
292
- # The incident span: when it started and when it cleared.
292
+ # The incident timeline: anomaly onset when the alert fired →
293
+ # when it recovered. ``short`` skips the fired field when unknown.
293
294
  if ctx["started_display"]:
294
- short("Started", ctx["started_display"])
295
- short("Cleared", ctx["timestamp"])
295
+ short("Anomaly began", ctx["started_display"])
296
+ short("Alert fired", ctx["fired_display"])
297
+ short("Recovered", ctx["timestamp"])
296
298
  else:
297
299
  full("Cleared at", ctx["timestamp"])
298
300
  full("Detectors", code(ctx["detector_name"]))
@@ -196,15 +196,15 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
196
196
  status-colored accent bar, a clickable title (the metric; links to
197
197
  `dashboard_url` when set), a short markdown lead (the duration sentence — see
198
198
  "Incident timing" below) with the **Rule** chip beneath it, and a compact
199
- fields grid: short fields Value / Expected / Quorum / Severity / Started /
200
- Latest (Started / Cleared on recovery), then full-width Detectors / Parameters,
199
+ fields grid: short fields Value / Expected / Quorum / Severity / Anomaly began /
200
+ Latest reading (Anomaly began / Alert fired / Recovered on recovery), then full-width Detectors / Parameters,
201
201
  plus a branded footer + footer icon. @mentions ride in the **top-level**
202
202
  message text so they notify. A custom `template` instead renders as a plain
203
203
  text-only attachment (color/title/ branding kept, no fields grid).
204
204
  - **Telegram** — default `parse_mode` is now **HTML**. The default message is
205
205
  structured and HTML-escaped: a colored status dot (red anomaly / green
206
206
  recovery / yellow no-data / blue error), a bold headline, the lead + rule, then
207
- evidence in `<code>` (value/expected/quorum/severity/started → latest/detector/
207
+ evidence in `<code>` (value/expected/quorum/severity/began → latest/detector/
208
208
  params), an inline "Open dashboard" link, then mentions. This fixes the old
209
209
  Markdown mode raising "can't parse entities" on params JSON containing
210
210
  underscores (e.g. `window_size`). Custom templates are sent verbatim under the
@@ -212,7 +212,7 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
212
212
  old behavior.
213
213
  - **Email** — a branded HTML card (inline-CSS, table-based, Outlook-safe):
214
214
  colored accent + status pill, the metric, the lead + Rule chip, a 2-col stat
215
- grid (value/expected/severity/quorum/started/latest), a monospace params box,
215
+ grid (value/expected/severity/quorum/anomaly began/latest reading; began/alert fired/recovered on recovery), a monospace params box,
216
216
  an optional "Open dashboard" button, and a footer. The plain-text body remains
217
217
  the multipart fallback.
218
218
 
@@ -226,15 +226,20 @@ identical everywhere.
226
226
 
227
227
  **Incident timing — "how long has this been going on".** Each default anomaly
228
228
  leads with `Anomalous for 2h 30m — 15 consecutive 10min intervals.` (metric
229
- interval + true streak length + wall-clock duration); Started/Latest bound the
230
- span. Recovery is symmetric (`Incident lasted …`, Started / Cleared). The true
229
+ interval + true streak length + wall-clock duration); the **Anomaly began /
230
+ Latest reading** fields bound the span. Labels are self-describing so the onset
231
+ isn't misread as the alert-fire moment: **Anomaly began** is the resolved onset,
232
+ **not** when the alert fired. Recovery shows the fuller **began → fired →
233
+ recovered** timeline (`Incident lasted …`), where **Alert fired** =
234
+ `onset + (consecutive_required − 1) × interval` (computed in `build_context`,
235
+ exposed as `{fired_display}`, omitted when the run is capped). The true
231
236
  streak/onset is resolved only when an alert fires/clears (a bounded lookback over
232
237
  the detection history; a run older than the window shows `over …`), so the hot
233
238
  no-alert path stays cheap. Exposed to templates as `{anomaly_lead}` /
234
239
  `{recovery_lead}` / `{duration_display}` / `{interval_display}` /
235
- `{started_display}` / `{window_line}` and `{consecutive_count}` now carries the
236
- *true* streak length. Custom templates and the plain-text fallbacks follow the
237
- same order.
240
+ `{started_display}` / `{fired_display}` / `{window_line}` and
241
+ `{consecutive_count}` now carries the *true* streak length. Custom templates and
242
+ the plain-text fallbacks follow the same order.
238
243
 
239
244
  ## Project label (multi-project channels)
240
245
 
@@ -294,7 +299,7 @@ referenced by path). Key variables:
294
299
  | `{direction}`, `{severity}` | observed values |
295
300
  | `{consecutive_count}` | **true** streak length (resolved at fire time, not capped at the rule) |
296
301
  | `{anomaly_lead}` / `{recovery_lead}` | ready-made "how long" lead sentence |
297
- | `{interval_display}` / `{duration_display}` / `{started_display}` / `{window_line}` | incident-timing bits (interval, duration, onset, `Started… \| Latest…` line) |
302
+ | `{interval_display}` / `{duration_display}` / `{started_display}` / `{fired_display}` / `{window_line}` | incident-timing bits (interval, duration, onset, alert-fire moment, `Anomaly began… \| Latest reading…` line) |
298
303
  | `{status}` | `ANOMALY` / `RECOVERED` / `NO_DATA` / `ERROR` |
299
304
  | `{mentions}` / `{mentions_line}` | formatted mentions |
300
305
  | `{dashboard_url}` | raw `dashboard_url` (empty string when unset) |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.34.0
3
+ Version: 0.35.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes