detectkit 0.8.2__tar.gz → 0.9.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 (90) hide show
  1. {detectkit-0.8.2/detectkit.egg-info → detectkit-0.9.0}/PKG-INFO +1 -1
  2. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/base.py +86 -14
  4. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/email.py +1 -1
  5. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/webhook.py +20 -8
  6. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_decision.py +24 -1
  7. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_recovery.py +5 -0
  8. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/test_alert.py +23 -2
  9. {detectkit-0.8.2 → detectkit-0.9.0/detectkit.egg-info}/PKG-INFO +1 -1
  10. {detectkit-0.8.2 → detectkit-0.9.0}/LICENSE +0 -0
  11. {detectkit-0.8.2 → detectkit-0.9.0}/MANIFEST.in +0 -0
  12. {detectkit-0.8.2 → detectkit-0.9.0}/README.md +0 -0
  13. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/__init__.py +0 -0
  14. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/__init__.py +0 -0
  15. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/factory.py +0 -0
  16. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/mattermost.py +0 -0
  17. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/slack.py +0 -0
  18. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/channels/telegram.py +0 -0
  19. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  20. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  21. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  22. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  23. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  24. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  25. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/__init__.py +0 -0
  26. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/_output.py +0 -0
  27. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/__init__.py +0 -0
  28. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/clean.py +0 -0
  29. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/init.py +0 -0
  30. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/run.py +0 -0
  31. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/commands/unlock.py +0 -0
  32. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/cli/main.py +0 -0
  33. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/config/__init__.py +0 -0
  34. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/config/metric_config.py +0 -0
  35. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/config/profile.py +0 -0
  36. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/config/project_config.py +0 -0
  37. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/config/validator.py +0 -0
  38. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/core/__init__.py +0 -0
  39. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/core/interval.py +0 -0
  40. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/core/models.py +0 -0
  41. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/__init__.py +0 -0
  42. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/clickhouse_manager.py +0 -0
  43. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/__init__.py +0 -0
  44. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  45. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_base.py +0 -0
  46. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  47. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_detections.py +0 -0
  48. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  49. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  50. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_schema.py +0 -0
  51. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  52. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/internal_tables/manager.py +0 -0
  53. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/manager.py +0 -0
  54. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/database/tables.py +0 -0
  55. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/__init__.py +0 -0
  56. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/base.py +0 -0
  57. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/factory.py +0 -0
  58. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/seasonality.py +0 -0
  59. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/__init__.py +0 -0
  60. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  61. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/iqr.py +0 -0
  62. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/mad.py +0 -0
  63. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  64. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/detectors/statistical/zscore.py +0 -0
  65. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/loaders/__init__.py +0 -0
  66. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/loaders/metric_loader.py +0 -0
  67. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/loaders/query_template.py +0 -0
  68. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/__init__.py +0 -0
  69. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/error_dispatch.py +0 -0
  70. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  71. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  72. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  73. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  74. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  75. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  76. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  77. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/utils/__init__.py +0 -0
  78. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/utils/datetime_utils.py +0 -0
  79. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/utils/env_interpolation.py +0 -0
  80. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/utils/json_utils.py +0 -0
  81. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit/utils/stats.py +0 -0
  82. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit.egg-info/SOURCES.txt +0 -0
  83. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit.egg-info/dependency_links.txt +0 -0
  84. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit.egg-info/entry_points.txt +0 -0
  85. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit.egg-info/requires.txt +0 -0
  86. {detectkit-0.8.2 → detectkit-0.9.0}/detectkit.egg-info/top_level.txt +0 -0
  87. {detectkit-0.8.2 → detectkit-0.9.0}/pyproject.toml +0 -0
  88. {detectkit-0.8.2 → detectkit-0.9.0}/requirements.txt +0 -0
  89. {detectkit-0.8.2 → detectkit-0.9.0}/setup.cfg +0 -0
  90. {detectkit-0.8.2 → detectkit-0.9.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.8.2
3
+ Version: 0.9.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.8.2"
7
+ __version__ = "0.9.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -36,6 +36,15 @@ class AlertData:
36
36
  as ``{project_name}`` in templates and as a ``[name] `` prefix
37
37
  in the default error title. Lets multiple projects share the
38
38
  same alert channel without ambiguity.
39
+
40
+ Alert-rule fields (``min_detectors``, ``direction_policy``,
41
+ ``consecutive_required``, ``detector_count``) describe *why the alert
42
+ fired* — the configured quorum/direction/consecutive thresholds plus
43
+ the observed number of agreeing detectors. They are filled by the
44
+ orchestrator from :class:`AlertConditions` and are deliberately kept
45
+ distinct from the observed ``direction``/``consecutive_count`` above so
46
+ templates can contrast "required vs actual". They default to ``None``
47
+ so direct-API callers (and non-anomaly alerts) still render cleanly.
39
48
  """
40
49
 
41
50
  metric_name: str
@@ -58,6 +67,11 @@ class AlertData:
58
67
  description: str | None = None
59
68
  mentions: list[str] = field(default_factory=list)
60
69
  project_name: str | None = None
70
+ # Alert rule (the parameters the alert fired with) — see class docstring.
71
+ min_detectors: int | None = None
72
+ direction_policy: str | None = None
73
+ consecutive_required: int | None = None
74
+ detector_count: int = 1
61
75
 
62
76
 
63
77
  class BaseAlertChannel(ABC):
@@ -123,10 +137,17 @@ class BaseAlertChannel(ABC):
123
137
  - {value} / {value_display}
124
138
  - {confidence_lower}
125
139
  - {confidence_upper}
140
+ - {confidence_interval} — "[lower, upper]" or "N/A"
141
+ - {expected_range} — one-sided aware: ">= lo", "<= hi",
142
+ "[lo, hi]" or "N/A" (renders one-sided detector bounds cleanly)
126
143
  - {detector_name}
127
- - {direction}
144
+ - {detector_count} — observed detectors that agreed (the quorum)
145
+ - {direction} — observed/locked direction of the anomaly
146
+ - {direction_policy} — configured direction rule ("same"/"any"/...)
147
+ - {min_detectors} — configured quorum threshold (the rule)
148
+ - {consecutive_count} — observed consecutive points
149
+ - {consecutive_required} — configured consecutive threshold (rule)
128
150
  - {severity}
129
- - {consecutive_count}
130
151
  - {status}
131
152
 
132
153
  Args:
@@ -177,6 +198,40 @@ class BaseAlertChannel(ABC):
177
198
  else:
178
199
  confidence_str = "N/A"
179
200
 
201
+ # One-sided-aware expected range. A NaN/inf bound means "no bound on
202
+ # that side" (e.g. ManualBounds with only ``lower_bound`` set), so we
203
+ # render ">= lo" / "<= hi" instead of the confusing "[7.00, nan]".
204
+ def _bounded(b: Any) -> bool:
205
+ return b is not None and not (isinstance(b, float) and (math.isnan(b) or math.isinf(b)))
206
+
207
+ lo_ok = _bounded(alert_data.confidence_lower)
208
+ hi_ok = _bounded(alert_data.confidence_upper)
209
+ if lo_ok and hi_ok:
210
+ expected_range = (
211
+ f"[{alert_data.confidence_lower:.2f}, {alert_data.confidence_upper:.2f}]"
212
+ )
213
+ elif lo_ok:
214
+ expected_range = f">= {alert_data.confidence_lower:.2f}"
215
+ elif hi_ok:
216
+ expected_range = f"<= {alert_data.confidence_upper:.2f}"
217
+ else:
218
+ expected_range = "N/A"
219
+
220
+ # Alert-rule display values. The orchestrator fills these from the
221
+ # configured AlertConditions; for direct-API/non-anomaly callers that
222
+ # leave them unset we fall back to the observed counts so the default
223
+ # templates never render a bare "None".
224
+ detector_count = alert_data.detector_count
225
+ min_detectors = (
226
+ alert_data.min_detectors if alert_data.min_detectors is not None else detector_count
227
+ )
228
+ consecutive_required = (
229
+ alert_data.consecutive_required
230
+ if alert_data.consecutive_required is not None
231
+ else alert_data.consecutive_count
232
+ )
233
+ direction_policy = alert_data.direction_policy or alert_data.direction
234
+
180
235
  # Display-safe value: stays usable even when value is None/NaN (no-data).
181
236
  raw_value = alert_data.value
182
237
  if raw_value is None or (isinstance(raw_value, float) and math.isnan(raw_value)):
@@ -221,11 +276,16 @@ class BaseAlertChannel(ABC):
221
276
  confidence_lower=alert_data.confidence_lower,
222
277
  confidence_upper=alert_data.confidence_upper,
223
278
  confidence_interval=confidence_str,
279
+ expected_range=expected_range,
224
280
  detector_name=alert_data.detector_name,
281
+ detector_count=detector_count,
225
282
  detector_params=alert_data.detector_params,
226
283
  direction=alert_data.direction,
284
+ direction_policy=direction_policy,
285
+ min_detectors=min_detectors,
227
286
  severity=alert_data.severity,
228
287
  consecutive_count=alert_data.consecutive_count,
288
+ consecutive_required=consecutive_required,
229
289
  status=status,
230
290
  error_type=alert_data.error_type or "",
231
291
  error_message=alert_data.error_message or "",
@@ -312,12 +372,19 @@ class BaseAlertChannel(ABC):
312
372
  Default template string
313
373
  """
314
374
  return (
315
- "Anomaly detected in metric: {metric_name}\n"
375
+ " Alert: {metric_name}\n"
316
376
  "{description_line}"
317
- "Time: {timestamp}\n"
318
- "Value: {value} | CI: {confidence_interval}\n"
319
- "Direction: {direction} | Severity: {severity:.2f} | Consecutive: {consecutive_count}\n"
320
- "Detector: {detector_name}\n"
377
+ "Quorum {detector_count}/{min_detectors} · "
378
+ "direction {direction} (policy {direction_policy}) · "
379
+ "consecutive {consecutive_count}/{consecutive_required}\n"
380
+ "Rule: min_detectors={min_detectors} · "
381
+ "direction={direction_policy} · consecutive={consecutive_required}\n"
382
+ "\n"
383
+ "Latest point (evidence):\n"
384
+ "· Time: {timestamp}\n"
385
+ "· Value: {value_display} | Expected: {expected_range}\n"
386
+ "· Severity: {severity:.2f}\n"
387
+ "Detectors: {detector_name}\n"
321
388
  "Parameters: {detector_params}"
322
389
  "{mentions_line}"
323
390
  )
@@ -330,12 +397,17 @@ class BaseAlertChannel(ABC):
330
397
  Default recovery template string
331
398
  """
332
399
  return (
333
- "Metric recovered: {metric_name}\n"
400
+ " Alert cleared: {metric_name}\n"
334
401
  "{description_line}"
335
- "Time: {timestamp}\n"
336
- "Value: {value} | CI: {confidence_interval}\n"
337
- "Detector: {detector_name}\n"
338
- "Status: metric returned to normal"
402
+ "The alert condition no longer holds — "
403
+ "the metric is back within expected bounds.\n"
404
+ "Rule: min_detectors={min_detectors} · "
405
+ "direction={direction_policy} · consecutive={consecutive_required}\n"
406
+ "\n"
407
+ "Latest point:\n"
408
+ "· Time: {timestamp}\n"
409
+ "· Value: {value_display} | Expected: {expected_range}\n"
410
+ "Detectors: {detector_name}"
339
411
  "{mentions_line}"
340
412
  )
341
413
 
@@ -348,7 +420,7 @@ class BaseAlertChannel(ABC):
348
420
  Returns:
349
421
  Default title template string
350
422
  """
351
- return "Anomaly detected: {metric_name}"
423
+ return " Alert: {metric_name}"
352
424
 
353
425
  def get_default_recovery_title_template(self) -> str:
354
426
  """
@@ -357,7 +429,7 @@ class BaseAlertChannel(ABC):
357
429
  Returns:
358
430
  Default recovery title template string
359
431
  """
360
- return "Metric recovered: {metric_name}"
432
+ return " Alert cleared: {metric_name}"
361
433
 
362
434
  def get_default_no_data_template(self) -> str:
363
435
  """
@@ -54,7 +54,7 @@ class EmailChannel(BaseAlertChannel):
54
54
  smtp_username: str | None = None,
55
55
  smtp_password: str | None = None,
56
56
  use_tls: bool = True,
57
- subject_template: str = "Anomaly Alert: {metric_name}",
57
+ subject_template: str = " Alert: {metric_name}",
58
58
  template: str | None = None,
59
59
  **kwargs,
60
60
  ):
@@ -155,10 +155,17 @@ class WebhookChannel(BaseAlertChannel):
155
155
  """
156
156
  return (
157
157
  "{description_line}"
158
- "Time: {timestamp}\n"
159
- "Value: {value} | CI: {confidence_interval}\n"
160
- "Direction: {direction} | Severity: {severity:.2f} | Consecutive: {consecutive_count}\n"
161
- "Detector: {detector_name}\n"
158
+ "Quorum {detector_count}/{min_detectors} · "
159
+ "direction {direction} (policy {direction_policy}) · "
160
+ "consecutive {consecutive_count}/{consecutive_required}\n"
161
+ "Rule: min_detectors={min_detectors} · "
162
+ "direction={direction_policy} · consecutive={consecutive_required}\n"
163
+ "\n"
164
+ "Latest point (evidence):\n"
165
+ "· Time: {timestamp}\n"
166
+ "· Value: {value_display} | Expected: {expected_range}\n"
167
+ "· Severity: {severity:.2f}\n"
168
+ "Detectors: {detector_name}\n"
162
169
  "Parameters: {detector_params}"
163
170
  "{mentions_line}"
164
171
  )
@@ -171,10 +178,15 @@ class WebhookChannel(BaseAlertChannel):
171
178
  """
172
179
  return (
173
180
  "{description_line}"
174
- "Time: {timestamp}\n"
175
- "Value: {value} | CI: {confidence_interval}\n"
176
- "Detector: {detector_name}\n"
177
- "Status: metric returned to normal"
181
+ "The alert condition no longer holds — "
182
+ "the metric is back within expected bounds.\n"
183
+ "Rule: min_detectors={min_detectors} · "
184
+ "direction={direction_policy} · consecutive={consecutive_required}\n"
185
+ "\n"
186
+ "Latest point:\n"
187
+ "· Time: {timestamp}\n"
188
+ "· Value: {value_display} | Expected: {expected_range}\n"
189
+ "Detectors: {detector_name}"
178
190
  "{mentions_line}"
179
191
  )
180
192
 
@@ -207,6 +207,23 @@ class _DecisionMixin(_OrchestratorBase):
207
207
  detector_params = primary.detector_params
208
208
  combined_metadata = primary.detection_metadata
209
209
 
210
+ # Observed direction shown in the message. For "same"/"up"/"down" the
211
+ # caller passes the locked/policy direction. For "any" it passes None
212
+ # because the quorum may combine directions — collapse to the shared
213
+ # side only when every quorum member agrees, otherwise label it
214
+ # "mixed" so the message never claims an agreement that did not happen
215
+ # (e.g. one up + one down satisfying min_detectors=2).
216
+ if direction:
217
+ observed_direction = direction
218
+ else:
219
+ quorum_dirs = {d.direction for d in anomalies if d.direction in ("up", "down")}
220
+ if len(quorum_dirs) == 1:
221
+ observed_direction = next(iter(quorum_dirs))
222
+ elif len(quorum_dirs) >= 2:
223
+ observed_direction = "mixed"
224
+ else:
225
+ observed_direction = primary.direction
226
+
210
227
  return AlertData(
211
228
  metric_name=self.metric_name,
212
229
  timestamp=primary.timestamp,
@@ -216,12 +233,18 @@ class _DecisionMixin(_OrchestratorBase):
216
233
  confidence_upper=primary.confidence_upper,
217
234
  detector_name=detector_name,
218
235
  detector_params=detector_params,
219
- direction=direction or primary.direction,
236
+ direction=observed_direction,
220
237
  severity=max_severity,
221
238
  detection_metadata=combined_metadata,
222
239
  consecutive_count=consecutive_count,
223
240
  description=self.description,
224
241
  mentions=self.mentions,
242
+ # Alert rule the message foregrounds: configured thresholds plus
243
+ # the observed quorum size that satisfied them.
244
+ min_detectors=self.conditions.min_detectors,
245
+ direction_policy=self.conditions.direction,
246
+ consecutive_required=self.conditions.consecutive_anomalies,
247
+ detector_count=len(anomalies),
225
248
  )
226
249
 
227
250
  def should_alert_no_data(
@@ -176,4 +176,9 @@ class _RecoveryMixin(_OrchestratorBase):
176
176
  is_recovery=True,
177
177
  description=self.description,
178
178
  mentions=self.mentions,
179
+ # Echo the rule that had fired so the recovery message names the
180
+ # same alert condition that just cleared.
181
+ min_detectors=self.conditions.min_detectors,
182
+ direction_policy=self.conditions.direction,
183
+ consecutive_required=self.conditions.consecutive_anomalies,
179
184
  )
@@ -44,6 +44,23 @@ def create_mock_alert_data(
44
44
  # different teams). Pull them from the specific config we're testing.
45
45
  mentions = list(alerting_config.mentions) if alerting_config else []
46
46
 
47
+ # Preview the alert with the rule it would actually fire on (min_detectors
48
+ # / direction / consecutive) so the test message matches the alert-centric
49
+ # default layout. Observed counts are set to satisfy the rule, as a real
50
+ # firing would.
51
+ min_detectors = getattr(alerting_config, "min_detectors", 1) or 1
52
+ direction_policy = getattr(alerting_config, "direction", "same") or "same"
53
+ consecutive_required = getattr(alerting_config, "consecutive_anomalies", 1) or 1
54
+ # Observed direction for the preview: a concrete side for up/down/same; for
55
+ # an "any" quorum of 2+ detectors show "mixed" (its whole point is that
56
+ # cross-direction anomalies combine), mirroring the real engine output.
57
+ if direction_policy in ("up", "down"):
58
+ observed_direction = direction_policy
59
+ elif direction_policy == "any" and min_detectors >= 2:
60
+ observed_direction = "mixed"
61
+ else:
62
+ observed_direction = "up"
63
+
47
64
  # Create realistic mock data
48
65
  return AlertData(
49
66
  metric_name=metric_config.name,
@@ -54,7 +71,7 @@ def create_mock_alert_data(
54
71
  confidence_upper=0.6234,
55
72
  detector_name="MADDetector:threshold=3.0",
56
73
  detector_params='{"threshold": 3.0, "window_size": 8640}',
57
- direction="above",
74
+ direction=observed_direction,
58
75
  severity=4.52,
59
76
  detection_metadata={
60
77
  "global_median": 0.5123,
@@ -68,8 +85,12 @@ def create_mock_alert_data(
68
85
  }
69
86
  ],
70
87
  },
71
- consecutive_count=3,
88
+ consecutive_count=consecutive_required,
72
89
  mentions=mentions,
90
+ min_detectors=min_detectors,
91
+ direction_policy=direction_policy,
92
+ consecutive_required=consecutive_required,
93
+ detector_count=min_detectors,
73
94
  )
74
95
 
75
96
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.8.2
3
+ Version: 0.9.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