detectkit 0.3.13__tar.gz → 0.3.15__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 (60) hide show
  1. {detectkit-0.3.13/detectkit.egg-info → detectkit-0.3.15}/PKG-INFO +1 -1
  2. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/__init__.py +1 -1
  3. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/base.py +6 -3
  4. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/orchestrator.py +130 -30
  5. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/database/internal_tables.py +4 -0
  6. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/orchestration/task_manager.py +15 -10
  7. {detectkit-0.3.13 → detectkit-0.3.15/detectkit.egg-info}/PKG-INFO +1 -1
  8. {detectkit-0.3.13 → detectkit-0.3.15}/pyproject.toml +1 -1
  9. {detectkit-0.3.13 → detectkit-0.3.15}/LICENSE +0 -0
  10. {detectkit-0.3.13 → detectkit-0.3.15}/MANIFEST.in +0 -0
  11. {detectkit-0.3.13 → detectkit-0.3.15}/README.md +0 -0
  12. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/__init__.py +0 -0
  13. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/__init__.py +0 -0
  14. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/email.py +0 -0
  15. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/factory.py +0 -0
  16. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/mattermost.py +0 -0
  17. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/slack.py +0 -0
  18. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/telegram.py +0 -0
  19. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/alerting/channels/webhook.py +0 -0
  20. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/__init__.py +0 -0
  21. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/commands/__init__.py +0 -0
  22. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/commands/init.py +0 -0
  23. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/commands/run.py +0 -0
  24. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/commands/test_alert.py +0 -0
  25. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/cli/main.py +0 -0
  26. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/config/__init__.py +0 -0
  27. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/config/metric_config.py +0 -0
  28. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/config/profile.py +0 -0
  29. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/config/project_config.py +0 -0
  30. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/config/validator.py +0 -0
  31. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/core/__init__.py +0 -0
  32. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/core/interval.py +0 -0
  33. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/core/models.py +0 -0
  34. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/database/__init__.py +0 -0
  35. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/database/clickhouse_manager.py +0 -0
  36. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/database/manager.py +0 -0
  37. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/database/tables.py +0 -0
  38. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/__init__.py +0 -0
  39. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/base.py +0 -0
  40. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/factory.py +0 -0
  41. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/statistical/__init__.py +0 -0
  42. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/statistical/iqr.py +0 -0
  43. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/statistical/mad.py +0 -0
  44. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  45. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/detectors/statistical/zscore.py +0 -0
  46. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/loaders/__init__.py +0 -0
  47. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/loaders/metric_loader.py +0 -0
  48. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/loaders/query_template.py +0 -0
  49. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/orchestration/__init__.py +0 -0
  50. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/utils/__init__.py +0 -0
  51. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/utils/datetime_utils.py +0 -0
  52. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit/utils/stats.py +0 -0
  53. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit.egg-info/SOURCES.txt +0 -0
  54. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit.egg-info/dependency_links.txt +0 -0
  55. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit.egg-info/entry_points.txt +0 -0
  56. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit.egg-info/requires.txt +0 -0
  57. {detectkit-0.3.13 → detectkit-0.3.15}/detectkit.egg-info/top_level.txt +0 -0
  58. {detectkit-0.3.13 → detectkit-0.3.15}/requirements.txt +0 -0
  59. {detectkit-0.3.13 → detectkit-0.3.15}/setup.cfg +0 -0
  60. {detectkit-0.3.13 → detectkit-0.3.15}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.3.13
3
+ Version: 0.3.15
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.3.13"
7
+ __version__ = "0.3.14"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -144,10 +144,13 @@ class BaseAlertChannel(ABC):
144
144
  if isinstance(ts, np.datetime64):
145
145
  ts = ts.astype(datetime)
146
146
 
147
- # Format timestamp with timezone
148
- ts_str = ts.strftime("%Y-%m-%d %H:%M:%S")
147
+ # Convert naive UTC timestamp to target timezone if specified
149
148
  if alert_data.timezone:
150
- ts_str = f"{ts_str} ({alert_data.timezone})"
149
+ from zoneinfo import ZoneInfo
150
+ ts = ts.replace(tzinfo=ZoneInfo("UTC")).astimezone(ZoneInfo(alert_data.timezone))
151
+ ts_str = f"{ts.strftime('%Y-%m-%d %H:%M:%S')} ({alert_data.timezone})"
152
+ else:
153
+ ts_str = ts.strftime("%Y-%m-%d %H:%M:%S")
151
154
 
152
155
  # Format confidence interval
153
156
  if alert_data.confidence_lower is not None and alert_data.confidence_upper is not None:
@@ -9,9 +9,10 @@ Handles:
9
9
  - Coordinating alert sending
10
10
  """
11
11
 
12
+ import json
12
13
  from dataclasses import dataclass
13
14
  from datetime import datetime, timezone
14
- from typing import Dict, List, Optional
15
+ from typing import Any, Dict, List, Optional
15
16
  from detectkit.utils.datetime_utils import now_utc, now_utc_naive, to_naive_utc, to_aware_utc
16
17
 
17
18
  import numpy as np
@@ -20,6 +21,48 @@ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
20
21
  from detectkit.core.interval import Interval
21
22
 
22
23
 
24
+ def _parse_detection_metadata(metadata: Any) -> Dict:
25
+ """Parse detection_metadata stored as dict or JSON string into a dict."""
26
+ if metadata is None:
27
+ return {}
28
+ if isinstance(metadata, dict):
29
+ return metadata
30
+ if isinstance(metadata, (bytes, bytearray)):
31
+ try:
32
+ metadata = metadata.decode("utf-8")
33
+ except Exception:
34
+ return {}
35
+ if isinstance(metadata, str):
36
+ if not metadata:
37
+ return {}
38
+ try:
39
+ parsed = json.loads(metadata)
40
+ return parsed if isinstance(parsed, dict) else {}
41
+ except (ValueError, TypeError):
42
+ return {}
43
+ return {}
44
+
45
+
46
+ def _direction_from_metadata(metadata: Any, is_anomaly: bool) -> str:
47
+ """
48
+ Resolve alert direction ("up"/"down"/"none") from detector metadata.
49
+
50
+ Detectors authoritatively write direction as "below"/"above" in
51
+ detection_metadata. This is the source of truth — confidence-bound
52
+ reconstruction does not work for one-sided detectors (e.g. ManualBounds
53
+ with only upper_bound set).
54
+ """
55
+ if not is_anomaly:
56
+ return "none"
57
+ parsed = _parse_detection_metadata(metadata)
58
+ raw = parsed.get("direction")
59
+ if raw == "below":
60
+ return "down"
61
+ if raw == "above":
62
+ return "up"
63
+ return "none"
64
+
65
+
23
66
  @dataclass
24
67
  class AlertConditions:
25
68
  """Alert conditions configuration."""
@@ -488,26 +531,19 @@ class AlertOrchestrator:
488
531
  # Convert to DetectionRecord format
489
532
  detection_records = []
490
533
  for det in recent_detections:
534
+ metadata_list = det.get("detection_metadata_list") or [None] * len(det["detector_ids"])
491
535
  # Group has multiple detectors per timestamp
492
536
  for i in range(len(det["detector_ids"])):
493
- # Parse detection metadata
494
- try:
495
- import json
496
- metadata = json.loads(det["detector_params_list"][i])
497
- except:
498
- metadata = {}
499
-
500
- # Determine direction
501
537
  value = det["value"]
502
538
  conf_lower = det["confidence_lowers"][i]
503
539
  conf_upper = det["confidence_uppers"][i]
540
+ is_anomaly = det["is_anomaly_flags"][i]
504
541
 
505
- if value < conf_lower:
506
- direction = "down"
507
- elif value > conf_upper:
508
- direction = "up"
509
- else:
510
- direction = "none"
542
+ # Use detector-authoritative direction from detection_metadata
543
+ # (works for one-sided detectors like ManualBounds where
544
+ # confidence_lower/upper may be None).
545
+ metadata = _parse_detection_metadata(metadata_list[i])
546
+ direction = _direction_from_metadata(metadata, is_anomaly)
511
547
 
512
548
  record = DetectionRecord(
513
549
  timestamp=np.datetime64(det["timestamp"]),
@@ -515,12 +551,12 @@ class AlertOrchestrator:
515
551
  detector_id=det["detector_ids"][i],
516
552
  detector_params=det["detector_params_list"][i],
517
553
  value=value,
518
- is_anomaly=det["is_anomaly_flags"][i],
554
+ is_anomaly=is_anomaly,
519
555
  confidence_lower=conf_lower,
520
556
  confidence_upper=conf_upper,
521
557
  direction=direction,
522
558
  severity=0.0, # Not used for recovery check
523
- detection_metadata=metadata
559
+ detection_metadata=metadata,
524
560
  )
525
561
  detection_records.append(record)
526
562
 
@@ -528,18 +564,64 @@ class AlertOrchestrator:
528
564
  detections_by_time = self._group_by_timestamp(detection_records)
529
565
  timestamps_sorted = sorted(detections_by_time.keys(), reverse=True)
530
566
 
531
- # Check that latest post-alert point is NOT anomalous by ANY detector.
532
- # Recovery = zero detectors flag the latest point as anomalous.
533
- # Using > 0 (not >= min_detectors) prevents false recovery when
534
- # some but not all detectors still flag the metric as anomalous.
567
+ # Direction-aware recovery: only block recovery on anomalies that
568
+ # match the alert's direction condition. For a "down"-only alert an
569
+ # "up" anomaly already means the alert condition no longer holds, so
570
+ # it should count as recovery.
571
+ direction_condition = self.conditions.direction
535
572
  latest_ts = timestamps_sorted[0]
536
573
  latest_detections = detections_by_time[latest_ts]
537
574
  latest_anomalies = [d for d in latest_detections if d.is_anomaly]
538
- if len(latest_anomalies) > 0:
539
- # At least one detector still considers this point anomalous — no recovery
540
- return False
541
575
 
542
- return True
576
+ if direction_condition == "down":
577
+ blocking = [d for d in latest_anomalies if d.direction == "down"]
578
+ elif direction_condition == "up":
579
+ blocking = [d for d in latest_anomalies if d.direction == "up"]
580
+ elif direction_condition == "same":
581
+ trigger_direction = self._get_alert_trigger_direction(last_alert_timestamp)
582
+ if trigger_direction is None:
583
+ # Unknown trigger direction → fall back to conservative behavior
584
+ blocking = latest_anomalies
585
+ else:
586
+ blocking = [d for d in latest_anomalies if d.direction == trigger_direction]
587
+ else: # "any" and unknown — preserve historical behavior
588
+ blocking = latest_anomalies
589
+
590
+ return len(blocking) == 0
591
+
592
+ def _get_alert_trigger_direction(
593
+ self, last_alert_timestamp: datetime
594
+ ) -> Optional[str]:
595
+ """
596
+ Resolve the direction of the anomaly that triggered the last alert.
597
+
598
+ Used by direction="same" recovery logic. Loads the detection record
599
+ at last_alert_timestamp and returns the dominant anomaly direction
600
+ from detection_metadata.
601
+
602
+ Returns:
603
+ "up", "down", or None if not resolvable.
604
+ """
605
+ if not self.internal:
606
+ return None
607
+
608
+ trigger_detections = self.internal.get_recent_detections(
609
+ metric_name=self.metric_name,
610
+ last_point=last_alert_timestamp,
611
+ num_points=1,
612
+ )
613
+ if not trigger_detections:
614
+ return None
615
+
616
+ det = trigger_detections[0]
617
+ metadata_list = det.get("detection_metadata_list") or [None] * len(det["detector_ids"])
618
+ for i in range(len(det["detector_ids"])):
619
+ if not det["is_anomaly_flags"][i]:
620
+ continue
621
+ direction = _direction_from_metadata(metadata_list[i], True)
622
+ if direction in ("up", "down"):
623
+ return direction
624
+ return None
543
625
 
544
626
  def should_send_recovery(
545
627
  self,
@@ -601,19 +683,37 @@ class AlertOrchestrator:
601
683
  if not detections:
602
684
  return None
603
685
 
604
- # Use the latest (newest) detection point for recovery info.
686
+ # Use the latest (newest) detection point for recovery timestamp/value.
605
687
  # detections are sorted oldest→newest by _load_recent_detections.
606
688
  latest = detections[-1]
607
689
 
690
+ # Use detector info and CI from the last anomalous detection
691
+ # (the one that triggered the alert), since normal points have
692
+ # detector_name="unknown" and confidence=None.
693
+ last_anomalous = next(
694
+ (d for d in reversed(detections) if d.is_anomaly),
695
+ None,
696
+ )
697
+ if last_anomalous:
698
+ recovery_detector_name = last_anomalous.detector_name
699
+ recovery_detector_params = last_anomalous.detector_params
700
+ recovery_ci_lower = last_anomalous.confidence_lower
701
+ recovery_ci_upper = last_anomalous.confidence_upper
702
+ else:
703
+ recovery_detector_name = latest.detector_name
704
+ recovery_detector_params = latest.detector_params
705
+ recovery_ci_lower = latest.confidence_lower
706
+ recovery_ci_upper = latest.confidence_upper
707
+
608
708
  return AlertData(
609
709
  metric_name=self.metric_name,
610
710
  timestamp=latest.timestamp,
611
711
  timezone=self.timezone_display,
612
712
  value=latest.value,
613
- confidence_lower=latest.confidence_lower,
614
- confidence_upper=latest.confidence_upper,
615
- detector_name=latest.detector_name,
616
- detector_params=latest.detector_params,
713
+ confidence_lower=recovery_ci_lower,
714
+ confidence_upper=recovery_ci_upper,
715
+ detector_name=recovery_detector_name,
716
+ detector_params=recovery_detector_params,
617
717
  direction="none",
618
718
  severity=0.0,
619
719
  detection_metadata={},
@@ -467,6 +467,7 @@ class InternalTablesManager:
467
467
  - detector_ids: List of detector IDs for this timestamp
468
468
  - detector_names: List of detector names
469
469
  - detector_params_list: List of detector params (JSON strings)
470
+ - detection_metadata_list: List of detection metadata (JSON strings)
470
471
  - is_anomaly_flags: List of is_anomaly bools
471
472
  - confidence_lowers: List of lower confidence bounds
472
473
  - confidence_uppers: List of upper confidence bounds
@@ -531,6 +532,7 @@ class InternalTablesManager:
531
532
  detector_id,
532
533
  detector_name,
533
534
  detector_params,
535
+ detection_metadata,
534
536
  is_anomaly,
535
537
  confidence_lower,
536
538
  confidence_upper,
@@ -570,6 +572,7 @@ class InternalTablesManager:
570
572
  "detector_ids": [],
571
573
  "detector_names": [],
572
574
  "detector_params_list": [],
575
+ "detection_metadata_list": [],
573
576
  "is_anomaly_flags": [],
574
577
  "confidence_lowers": [],
575
578
  "confidence_uppers": [],
@@ -579,6 +582,7 @@ class InternalTablesManager:
579
582
  grouped[ts_key]["detector_ids"].append(row["detector_id"])
580
583
  grouped[ts_key]["detector_names"].append(row["detector_name"])
581
584
  grouped[ts_key]["detector_params_list"].append(row["detector_params"])
585
+ grouped[ts_key]["detection_metadata_list"].append(row.get("detection_metadata"))
582
586
  grouped[ts_key]["is_anomaly_flags"].append(row["is_anomaly"])
583
587
  grouped[ts_key]["confidence_lowers"].append(row["confidence_lower"])
584
588
  grouped[ts_key]["confidence_uppers"].append(row["confidence_upper"])
@@ -23,6 +23,8 @@ from detectkit.alerting.orchestrator import (
23
23
  AlertConditions,
24
24
  AlertOrchestrator,
25
25
  DetectionRecord,
26
+ _direction_from_metadata,
27
+ _parse_detection_metadata,
26
28
  )
27
29
  from detectkit.config.metric_config import MetricConfig
28
30
  from detectkit.core.interval import Interval
@@ -737,7 +739,9 @@ class TaskManager:
737
739
  if flag
738
740
  ]
739
741
 
740
- # Determine direction and severity for the most severe detector
742
+ # Determine direction and severity for the most severe detector.
743
+ # Direction is read from detector metadata (authoritative for all
744
+ # detectors including ManualBounds where confidence bounds may be None).
741
745
  direction = "none"
742
746
  severity = 0.0
743
747
  confidence_lower = None
@@ -745,6 +749,9 @@ class TaskManager:
745
749
  detector_name = "unknown"
746
750
  detector_id = "unknown"
747
751
  detector_params = "{}"
752
+ metadata: dict = {}
753
+
754
+ metadata_list = row.get("detection_metadata_list") or [None] * len(row["detector_ids"])
748
755
 
749
756
  if is_anomaly and anomaly_indices:
750
757
  # Get data from first anomalous detector
@@ -755,14 +762,12 @@ class TaskManager:
755
762
  confidence_lower = row["confidence_lowers"][first_idx]
756
763
  confidence_upper = row["confidence_uppers"][first_idx]
757
764
 
758
- # Determine direction
759
- value = row["value"]
760
- if value < confidence_lower:
761
- direction = "down"
762
- severity = (confidence_lower - value) / max(abs(confidence_lower), 1e-10)
763
- elif value > confidence_upper:
764
- direction = "up"
765
- severity = (value - confidence_upper) / max(abs(confidence_upper), 1e-10)
765
+ metadata = _parse_detection_metadata(metadata_list[first_idx])
766
+ direction = _direction_from_metadata(metadata, True)
767
+ try:
768
+ severity = float(metadata.get("severity", 0.0) or 0.0)
769
+ except (TypeError, ValueError):
770
+ severity = 0.0
766
771
 
767
772
  records.append(
768
773
  DetectionRecord(
@@ -776,7 +781,7 @@ class TaskManager:
776
781
  confidence_upper=confidence_upper,
777
782
  direction=direction,
778
783
  severity=severity,
779
- detection_metadata={},
784
+ detection_metadata=metadata,
780
785
  )
781
786
  )
782
787
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.3.13
3
+ Version: 0.3.15
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "detectkit"
7
- version = "0.3.13"
7
+ version = "0.3.15"
8
8
  description = "Metric monitoring with automatic anomaly detection"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes