truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__py3-none-any.whl
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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
"""Cross-alert correlation service.
|
|
2
|
+
|
|
3
|
+
This module provides services for cross-feature integration between
|
|
4
|
+
Anomaly Detection and Drift Monitoring alerts.
|
|
5
|
+
|
|
6
|
+
When anomaly rates spike, it automatically checks for drift and vice versa.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import select, func, and_, or_
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from truthound_dashboard.db.models import (
|
|
21
|
+
AnomalyDetection,
|
|
22
|
+
DriftAlert,
|
|
23
|
+
DriftComparison,
|
|
24
|
+
Source,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# In-memory config storage (would be DB in production)
|
|
31
|
+
_global_config: dict[str, Any] = {
|
|
32
|
+
"enabled": True,
|
|
33
|
+
"trigger_drift_on_anomaly": True,
|
|
34
|
+
"trigger_anomaly_on_drift": True,
|
|
35
|
+
"thresholds": {
|
|
36
|
+
"anomaly_rate_threshold": 0.1,
|
|
37
|
+
"anomaly_count_threshold": 10,
|
|
38
|
+
"drift_percentage_threshold": 10.0,
|
|
39
|
+
"drift_columns_threshold": 2,
|
|
40
|
+
},
|
|
41
|
+
"notify_on_correlation": True,
|
|
42
|
+
"notification_channel_ids": None,
|
|
43
|
+
"cooldown_seconds": 300,
|
|
44
|
+
"last_anomaly_trigger_at": None,
|
|
45
|
+
"last_drift_trigger_at": None,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_source_configs: dict[str, dict[str, Any]] = {}
|
|
49
|
+
_correlations: list[dict[str, Any]] = []
|
|
50
|
+
_auto_trigger_events: list[dict[str, Any]] = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CrossAlertService:
|
|
54
|
+
"""Service for cross-alert correlation between anomaly and drift detection.
|
|
55
|
+
|
|
56
|
+
Provides functionality for:
|
|
57
|
+
- Finding correlated alerts between anomaly and drift detection
|
|
58
|
+
- Auto-triggering drift checks when anomalies spike
|
|
59
|
+
- Auto-triggering anomaly checks when drift is detected
|
|
60
|
+
- Managing auto-trigger configuration
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
64
|
+
"""Initialize service.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
session: Database session.
|
|
68
|
+
"""
|
|
69
|
+
self.session = session
|
|
70
|
+
|
|
71
|
+
# =========================================================================
|
|
72
|
+
# Correlation Analysis
|
|
73
|
+
# =========================================================================
|
|
74
|
+
|
|
75
|
+
async def correlate_anomaly_drift(
|
|
76
|
+
self,
|
|
77
|
+
source_id: str,
|
|
78
|
+
*,
|
|
79
|
+
time_window_hours: int = 24,
|
|
80
|
+
limit: int = 50,
|
|
81
|
+
) -> list[dict[str, Any]]:
|
|
82
|
+
"""Find correlated anomaly and drift alerts for a source.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
source_id: Data source ID.
|
|
86
|
+
time_window_hours: Time window to look for correlations.
|
|
87
|
+
limit: Maximum correlations to return.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of correlation dictionaries.
|
|
91
|
+
"""
|
|
92
|
+
from truthound_dashboard.db.models import (
|
|
93
|
+
AnomalyDetection,
|
|
94
|
+
DriftAlert,
|
|
95
|
+
DriftComparison,
|
|
96
|
+
Source,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Get source info
|
|
100
|
+
result = await self.session.execute(
|
|
101
|
+
select(Source).where(Source.id == source_id)
|
|
102
|
+
)
|
|
103
|
+
source = result.scalar_one_or_none()
|
|
104
|
+
source_name = source.name if source else None
|
|
105
|
+
|
|
106
|
+
# Time window
|
|
107
|
+
since = datetime.utcnow() - timedelta(hours=time_window_hours)
|
|
108
|
+
|
|
109
|
+
# Get recent anomaly detections
|
|
110
|
+
anomaly_result = await self.session.execute(
|
|
111
|
+
select(AnomalyDetection)
|
|
112
|
+
.where(
|
|
113
|
+
and_(
|
|
114
|
+
AnomalyDetection.source_id == source_id,
|
|
115
|
+
AnomalyDetection.created_at >= since,
|
|
116
|
+
AnomalyDetection.status == "success",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
.order_by(AnomalyDetection.created_at.desc())
|
|
120
|
+
.limit(100)
|
|
121
|
+
)
|
|
122
|
+
anomaly_detections = list(anomaly_result.scalars().all())
|
|
123
|
+
|
|
124
|
+
# Get recent drift alerts for this source
|
|
125
|
+
drift_result = await self.session.execute(
|
|
126
|
+
select(DriftAlert)
|
|
127
|
+
.where(
|
|
128
|
+
and_(
|
|
129
|
+
DriftAlert.created_at >= since,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
.order_by(DriftAlert.created_at.desc())
|
|
133
|
+
.limit(100)
|
|
134
|
+
)
|
|
135
|
+
drift_alerts = list(drift_result.scalars().all())
|
|
136
|
+
|
|
137
|
+
# Filter drift alerts related to this source
|
|
138
|
+
related_drift_alerts = []
|
|
139
|
+
for alert in drift_alerts:
|
|
140
|
+
# Get the comparison to check source IDs
|
|
141
|
+
comp_result = await self.session.execute(
|
|
142
|
+
select(DriftComparison).where(DriftComparison.id == alert.comparison_id)
|
|
143
|
+
)
|
|
144
|
+
comparison = comp_result.scalar_one_or_none()
|
|
145
|
+
if comparison and (
|
|
146
|
+
comparison.baseline_source_id == source_id
|
|
147
|
+
or comparison.current_source_id == source_id
|
|
148
|
+
):
|
|
149
|
+
related_drift_alerts.append((alert, comparison))
|
|
150
|
+
|
|
151
|
+
# Find correlations
|
|
152
|
+
correlations = []
|
|
153
|
+
for detection in anomaly_detections:
|
|
154
|
+
if detection.anomaly_rate is None or detection.anomaly_rate < 0.01:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
for alert, comparison in related_drift_alerts:
|
|
158
|
+
# Check time proximity
|
|
159
|
+
time_delta = abs(
|
|
160
|
+
(detection.created_at - alert.created_at).total_seconds()
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Only correlate if within 2 hours of each other
|
|
164
|
+
if time_delta > 7200:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Calculate correlation strength
|
|
168
|
+
strength = self._calculate_correlation_strength(
|
|
169
|
+
detection, alert, time_delta
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if strength == "none":
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Find common columns
|
|
176
|
+
anomaly_cols = detection.columns_analyzed or []
|
|
177
|
+
drift_cols = alert.drifted_columns_json or []
|
|
178
|
+
common_cols = list(set(anomaly_cols) & set(drift_cols))
|
|
179
|
+
|
|
180
|
+
correlation = {
|
|
181
|
+
"id": str(uuid.uuid4()),
|
|
182
|
+
"source_id": source_id,
|
|
183
|
+
"source_name": source_name,
|
|
184
|
+
"correlation_strength": strength,
|
|
185
|
+
"confidence_score": self._calculate_confidence(
|
|
186
|
+
detection, alert, common_cols, time_delta
|
|
187
|
+
),
|
|
188
|
+
"time_delta_seconds": int(time_delta),
|
|
189
|
+
"anomaly_alert": {
|
|
190
|
+
"alert_id": detection.id,
|
|
191
|
+
"alert_type": "anomaly",
|
|
192
|
+
"source_id": source_id,
|
|
193
|
+
"source_name": source_name,
|
|
194
|
+
"severity": self._anomaly_severity(detection.anomaly_rate),
|
|
195
|
+
"message": f"Detected {detection.anomaly_count} anomalies ({detection.anomaly_rate * 100:.1f}% rate)",
|
|
196
|
+
"created_at": detection.created_at.isoformat(),
|
|
197
|
+
"anomaly_rate": detection.anomaly_rate,
|
|
198
|
+
"anomaly_count": detection.anomaly_count,
|
|
199
|
+
"drift_percentage": None,
|
|
200
|
+
"drifted_columns": None,
|
|
201
|
+
},
|
|
202
|
+
"drift_alert": {
|
|
203
|
+
"alert_id": alert.id,
|
|
204
|
+
"alert_type": "drift",
|
|
205
|
+
"source_id": source_id,
|
|
206
|
+
"source_name": source_name,
|
|
207
|
+
"severity": alert.severity,
|
|
208
|
+
"message": alert.message,
|
|
209
|
+
"created_at": alert.created_at.isoformat(),
|
|
210
|
+
"anomaly_rate": None,
|
|
211
|
+
"anomaly_count": None,
|
|
212
|
+
"drift_percentage": alert.drift_percentage,
|
|
213
|
+
"drifted_columns": alert.drifted_columns_json,
|
|
214
|
+
},
|
|
215
|
+
"common_columns": common_cols,
|
|
216
|
+
"suggested_action": self._suggest_action(strength, common_cols),
|
|
217
|
+
"notes": None,
|
|
218
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
219
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
220
|
+
}
|
|
221
|
+
correlations.append(correlation)
|
|
222
|
+
|
|
223
|
+
# Sort by confidence score and limit
|
|
224
|
+
correlations.sort(key=lambda x: x["confidence_score"], reverse=True)
|
|
225
|
+
return correlations[:limit]
|
|
226
|
+
|
|
227
|
+
def _calculate_correlation_strength(
|
|
228
|
+
self,
|
|
229
|
+
detection: "AnomalyDetection",
|
|
230
|
+
alert: "DriftAlert",
|
|
231
|
+
time_delta: float,
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Calculate correlation strength between anomaly and drift.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
detection: Anomaly detection result.
|
|
237
|
+
alert: Drift alert.
|
|
238
|
+
time_delta: Time difference in seconds.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Correlation strength: strong, moderate, weak, or none.
|
|
242
|
+
"""
|
|
243
|
+
score = 0
|
|
244
|
+
|
|
245
|
+
# Time proximity (closer = stronger)
|
|
246
|
+
if time_delta < 600: # 10 minutes
|
|
247
|
+
score += 3
|
|
248
|
+
elif time_delta < 1800: # 30 minutes
|
|
249
|
+
score += 2
|
|
250
|
+
elif time_delta < 3600: # 1 hour
|
|
251
|
+
score += 1
|
|
252
|
+
|
|
253
|
+
# Anomaly severity
|
|
254
|
+
rate = detection.anomaly_rate or 0
|
|
255
|
+
if rate > 0.2:
|
|
256
|
+
score += 3
|
|
257
|
+
elif rate > 0.1:
|
|
258
|
+
score += 2
|
|
259
|
+
elif rate > 0.05:
|
|
260
|
+
score += 1
|
|
261
|
+
|
|
262
|
+
# Drift severity
|
|
263
|
+
if alert.severity == "critical":
|
|
264
|
+
score += 3
|
|
265
|
+
elif alert.severity == "high":
|
|
266
|
+
score += 2
|
|
267
|
+
elif alert.severity == "medium":
|
|
268
|
+
score += 1
|
|
269
|
+
|
|
270
|
+
# Drift percentage
|
|
271
|
+
drift_pct = alert.drift_percentage or 0
|
|
272
|
+
if drift_pct > 30:
|
|
273
|
+
score += 2
|
|
274
|
+
elif drift_pct > 15:
|
|
275
|
+
score += 1
|
|
276
|
+
|
|
277
|
+
# Determine strength
|
|
278
|
+
if score >= 8:
|
|
279
|
+
return "strong"
|
|
280
|
+
elif score >= 5:
|
|
281
|
+
return "moderate"
|
|
282
|
+
elif score >= 2:
|
|
283
|
+
return "weak"
|
|
284
|
+
return "none"
|
|
285
|
+
|
|
286
|
+
def _calculate_confidence(
|
|
287
|
+
self,
|
|
288
|
+
detection: "AnomalyDetection",
|
|
289
|
+
alert: "DriftAlert",
|
|
290
|
+
common_cols: list[str],
|
|
291
|
+
time_delta: float,
|
|
292
|
+
) -> float:
|
|
293
|
+
"""Calculate confidence score for correlation.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
detection: Anomaly detection result.
|
|
297
|
+
alert: Drift alert.
|
|
298
|
+
common_cols: Columns affected by both.
|
|
299
|
+
time_delta: Time difference in seconds.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Confidence score between 0 and 1.
|
|
303
|
+
"""
|
|
304
|
+
confidence = 0.5 # Base confidence
|
|
305
|
+
|
|
306
|
+
# Time proximity bonus
|
|
307
|
+
if time_delta < 300:
|
|
308
|
+
confidence += 0.2
|
|
309
|
+
elif time_delta < 1800:
|
|
310
|
+
confidence += 0.1
|
|
311
|
+
|
|
312
|
+
# Common columns bonus
|
|
313
|
+
if len(common_cols) > 3:
|
|
314
|
+
confidence += 0.2
|
|
315
|
+
elif len(common_cols) > 0:
|
|
316
|
+
confidence += 0.1
|
|
317
|
+
|
|
318
|
+
# Severity bonus
|
|
319
|
+
rate = detection.anomaly_rate or 0
|
|
320
|
+
if rate > 0.15 and alert.severity in ("critical", "high"):
|
|
321
|
+
confidence += 0.15
|
|
322
|
+
|
|
323
|
+
return min(confidence, 1.0)
|
|
324
|
+
|
|
325
|
+
def _anomaly_severity(self, rate: float | None) -> str:
|
|
326
|
+
"""Determine anomaly severity from rate."""
|
|
327
|
+
if rate is None:
|
|
328
|
+
return "low"
|
|
329
|
+
if rate > 0.2:
|
|
330
|
+
return "critical"
|
|
331
|
+
if rate > 0.1:
|
|
332
|
+
return "high"
|
|
333
|
+
if rate > 0.05:
|
|
334
|
+
return "medium"
|
|
335
|
+
return "low"
|
|
336
|
+
|
|
337
|
+
def _suggest_action(self, strength: str, common_cols: list[str]) -> str:
|
|
338
|
+
"""Suggest action based on correlation.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
strength: Correlation strength.
|
|
342
|
+
common_cols: Common affected columns.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Suggested action string.
|
|
346
|
+
"""
|
|
347
|
+
if strength == "strong":
|
|
348
|
+
if common_cols:
|
|
349
|
+
cols = ", ".join(common_cols[:3])
|
|
350
|
+
return f"Investigate upstream changes affecting columns: {cols}"
|
|
351
|
+
return "Investigate upstream data pipeline for recent changes"
|
|
352
|
+
elif strength == "moderate":
|
|
353
|
+
return "Review data quality and consider updating baseline"
|
|
354
|
+
else:
|
|
355
|
+
return "Monitor for recurring patterns"
|
|
356
|
+
|
|
357
|
+
# =========================================================================
|
|
358
|
+
# Auto-Trigger Operations
|
|
359
|
+
# =========================================================================
|
|
360
|
+
|
|
361
|
+
async def auto_trigger_drift_on_anomaly(
|
|
362
|
+
self,
|
|
363
|
+
detection_id: str,
|
|
364
|
+
) -> dict[str, Any] | None:
|
|
365
|
+
"""Auto-trigger drift check when anomaly detection shows high rate.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
detection_id: Anomaly detection ID.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Trigger event result or None if skipped.
|
|
372
|
+
"""
|
|
373
|
+
from truthound_dashboard.db.models import AnomalyDetection, DriftMonitor
|
|
374
|
+
from truthound_dashboard.core.drift_monitor import DriftMonitorService
|
|
375
|
+
|
|
376
|
+
# Get detection
|
|
377
|
+
result = await self.session.execute(
|
|
378
|
+
select(AnomalyDetection).where(AnomalyDetection.id == detection_id)
|
|
379
|
+
)
|
|
380
|
+
detection = result.scalar_one_or_none()
|
|
381
|
+
|
|
382
|
+
if not detection:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
# Get config
|
|
386
|
+
config = self.get_config(detection.source_id)
|
|
387
|
+
if not config.get("enabled") or not config.get("trigger_drift_on_anomaly"):
|
|
388
|
+
return self._create_skip_event(
|
|
389
|
+
detection.source_id,
|
|
390
|
+
"anomaly_to_drift",
|
|
391
|
+
detection_id,
|
|
392
|
+
"anomaly",
|
|
393
|
+
"Auto-trigger disabled",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Check thresholds
|
|
397
|
+
thresholds = config.get("thresholds", {})
|
|
398
|
+
rate_threshold = thresholds.get("anomaly_rate_threshold", 0.1)
|
|
399
|
+
count_threshold = thresholds.get("anomaly_count_threshold", 10)
|
|
400
|
+
|
|
401
|
+
rate = detection.anomaly_rate or 0
|
|
402
|
+
count = detection.anomaly_count or 0
|
|
403
|
+
|
|
404
|
+
if rate < rate_threshold and count < count_threshold:
|
|
405
|
+
return self._create_skip_event(
|
|
406
|
+
detection.source_id,
|
|
407
|
+
"anomaly_to_drift",
|
|
408
|
+
detection_id,
|
|
409
|
+
"anomaly",
|
|
410
|
+
f"Below thresholds (rate: {rate:.2f} < {rate_threshold}, count: {count} < {count_threshold})",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Check cooldown
|
|
414
|
+
cooldown = config.get("cooldown_seconds", 300)
|
|
415
|
+
last_trigger = config.get("last_anomaly_trigger_at")
|
|
416
|
+
if last_trigger:
|
|
417
|
+
if isinstance(last_trigger, str):
|
|
418
|
+
last_trigger = datetime.fromisoformat(last_trigger)
|
|
419
|
+
elapsed = (datetime.utcnow() - last_trigger).total_seconds()
|
|
420
|
+
if elapsed < cooldown:
|
|
421
|
+
return self._create_skip_event(
|
|
422
|
+
detection.source_id,
|
|
423
|
+
"anomaly_to_drift",
|
|
424
|
+
detection_id,
|
|
425
|
+
"anomaly",
|
|
426
|
+
f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Find a drift monitor for this source
|
|
430
|
+
monitor_result = await self.session.execute(
|
|
431
|
+
select(DriftMonitor)
|
|
432
|
+
.where(
|
|
433
|
+
or_(
|
|
434
|
+
DriftMonitor.baseline_source_id == detection.source_id,
|
|
435
|
+
DriftMonitor.current_source_id == detection.source_id,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
.limit(1)
|
|
439
|
+
)
|
|
440
|
+
monitor = monitor_result.scalar_one_or_none()
|
|
441
|
+
|
|
442
|
+
event = {
|
|
443
|
+
"id": str(uuid.uuid4()),
|
|
444
|
+
"source_id": detection.source_id,
|
|
445
|
+
"trigger_type": "anomaly_to_drift",
|
|
446
|
+
"trigger_alert_id": detection_id,
|
|
447
|
+
"trigger_alert_type": "anomaly",
|
|
448
|
+
"result_id": None,
|
|
449
|
+
"correlation_found": False,
|
|
450
|
+
"correlation_id": None,
|
|
451
|
+
"status": "pending",
|
|
452
|
+
"error_message": None,
|
|
453
|
+
"skipped_reason": None,
|
|
454
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
455
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if not monitor:
|
|
459
|
+
event["status"] = "skipped"
|
|
460
|
+
event["skipped_reason"] = "No drift monitor configured for this source"
|
|
461
|
+
_auto_trigger_events.append(event)
|
|
462
|
+
return event
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
# Run the drift monitor
|
|
466
|
+
event["status"] = "running"
|
|
467
|
+
drift_service = DriftMonitorService(self.session)
|
|
468
|
+
comparison = await drift_service.run_monitor(monitor.id)
|
|
469
|
+
|
|
470
|
+
if comparison:
|
|
471
|
+
event["status"] = "completed"
|
|
472
|
+
event["result_id"] = comparison.id
|
|
473
|
+
|
|
474
|
+
# Check for correlation
|
|
475
|
+
if comparison.has_drift:
|
|
476
|
+
correlations = await self.correlate_anomaly_drift(
|
|
477
|
+
detection.source_id, time_window_hours=1
|
|
478
|
+
)
|
|
479
|
+
if correlations:
|
|
480
|
+
event["correlation_found"] = True
|
|
481
|
+
event["correlation_id"] = correlations[0]["id"]
|
|
482
|
+
else:
|
|
483
|
+
event["status"] = "failed"
|
|
484
|
+
event["error_message"] = "Drift monitor run failed"
|
|
485
|
+
|
|
486
|
+
except Exception as e:
|
|
487
|
+
event["status"] = "failed"
|
|
488
|
+
event["error_message"] = str(e)
|
|
489
|
+
logger.error(f"Auto-trigger drift check failed: {e}")
|
|
490
|
+
|
|
491
|
+
# Update last trigger time
|
|
492
|
+
self._update_config(
|
|
493
|
+
detection.source_id,
|
|
494
|
+
{"last_anomaly_trigger_at": datetime.utcnow().isoformat()},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
_auto_trigger_events.append(event)
|
|
498
|
+
return event
|
|
499
|
+
|
|
500
|
+
async def auto_trigger_anomaly_on_drift(
|
|
501
|
+
self,
|
|
502
|
+
monitor_id: str,
|
|
503
|
+
) -> dict[str, Any] | None:
|
|
504
|
+
"""Auto-trigger anomaly check when drift is detected.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
monitor_id: Drift monitor ID.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Trigger event result or None if skipped.
|
|
511
|
+
"""
|
|
512
|
+
from truthound_dashboard.db.models import DriftMonitor, DriftAlert
|
|
513
|
+
from truthound_dashboard.core.anomaly import AnomalyDetectionService
|
|
514
|
+
|
|
515
|
+
# Get monitor
|
|
516
|
+
result = await self.session.execute(
|
|
517
|
+
select(DriftMonitor).where(DriftMonitor.id == monitor_id)
|
|
518
|
+
)
|
|
519
|
+
monitor = result.scalar_one_or_none()
|
|
520
|
+
|
|
521
|
+
if not monitor:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
source_id = monitor.current_source_id
|
|
525
|
+
|
|
526
|
+
# Get latest alert for this monitor
|
|
527
|
+
alert_result = await self.session.execute(
|
|
528
|
+
select(DriftAlert)
|
|
529
|
+
.where(DriftAlert.monitor_id == monitor_id)
|
|
530
|
+
.order_by(DriftAlert.created_at.desc())
|
|
531
|
+
.limit(1)
|
|
532
|
+
)
|
|
533
|
+
alert = alert_result.scalar_one_or_none()
|
|
534
|
+
|
|
535
|
+
if not alert:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
# Get config
|
|
539
|
+
config = self.get_config(source_id)
|
|
540
|
+
if not config.get("enabled") or not config.get("trigger_anomaly_on_drift"):
|
|
541
|
+
return self._create_skip_event(
|
|
542
|
+
source_id,
|
|
543
|
+
"drift_to_anomaly",
|
|
544
|
+
alert.id,
|
|
545
|
+
"drift",
|
|
546
|
+
"Auto-trigger disabled",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Check thresholds
|
|
550
|
+
thresholds = config.get("thresholds", {})
|
|
551
|
+
drift_threshold = thresholds.get("drift_percentage_threshold", 10.0)
|
|
552
|
+
cols_threshold = thresholds.get("drift_columns_threshold", 2)
|
|
553
|
+
|
|
554
|
+
drift_pct = alert.drift_percentage or 0
|
|
555
|
+
cols_count = len(alert.drifted_columns_json or [])
|
|
556
|
+
|
|
557
|
+
if drift_pct < drift_threshold and cols_count < cols_threshold:
|
|
558
|
+
return self._create_skip_event(
|
|
559
|
+
source_id,
|
|
560
|
+
"drift_to_anomaly",
|
|
561
|
+
alert.id,
|
|
562
|
+
"drift",
|
|
563
|
+
f"Below thresholds (drift: {drift_pct:.1f}% < {drift_threshold}%, cols: {cols_count} < {cols_threshold})",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Check cooldown
|
|
567
|
+
cooldown = config.get("cooldown_seconds", 300)
|
|
568
|
+
last_trigger = config.get("last_drift_trigger_at")
|
|
569
|
+
if last_trigger:
|
|
570
|
+
if isinstance(last_trigger, str):
|
|
571
|
+
last_trigger = datetime.fromisoformat(last_trigger)
|
|
572
|
+
elapsed = (datetime.utcnow() - last_trigger).total_seconds()
|
|
573
|
+
if elapsed < cooldown:
|
|
574
|
+
return self._create_skip_event(
|
|
575
|
+
source_id,
|
|
576
|
+
"drift_to_anomaly",
|
|
577
|
+
alert.id,
|
|
578
|
+
"drift",
|
|
579
|
+
f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
event = {
|
|
583
|
+
"id": str(uuid.uuid4()),
|
|
584
|
+
"source_id": source_id,
|
|
585
|
+
"trigger_type": "drift_to_anomaly",
|
|
586
|
+
"trigger_alert_id": alert.id,
|
|
587
|
+
"trigger_alert_type": "drift",
|
|
588
|
+
"result_id": None,
|
|
589
|
+
"correlation_found": False,
|
|
590
|
+
"correlation_id": None,
|
|
591
|
+
"status": "pending",
|
|
592
|
+
"error_message": None,
|
|
593
|
+
"skipped_reason": None,
|
|
594
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
595
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
# Run anomaly detection
|
|
600
|
+
event["status"] = "running"
|
|
601
|
+
anomaly_service = AnomalyDetectionService(self.session)
|
|
602
|
+
|
|
603
|
+
detection = await anomaly_service.create_detection(
|
|
604
|
+
source_id=source_id,
|
|
605
|
+
algorithm="isolation_forest",
|
|
606
|
+
columns=alert.drifted_columns_json,
|
|
607
|
+
)
|
|
608
|
+
detection = await anomaly_service.run_detection(detection.id)
|
|
609
|
+
|
|
610
|
+
event["status"] = "completed"
|
|
611
|
+
event["result_id"] = detection.id
|
|
612
|
+
|
|
613
|
+
# Check for correlation
|
|
614
|
+
if detection.anomaly_count and detection.anomaly_count > 0:
|
|
615
|
+
correlations = await self.correlate_anomaly_drift(
|
|
616
|
+
source_id, time_window_hours=1
|
|
617
|
+
)
|
|
618
|
+
if correlations:
|
|
619
|
+
event["correlation_found"] = True
|
|
620
|
+
event["correlation_id"] = correlations[0]["id"]
|
|
621
|
+
|
|
622
|
+
except Exception as e:
|
|
623
|
+
event["status"] = "failed"
|
|
624
|
+
event["error_message"] = str(e)
|
|
625
|
+
logger.error(f"Auto-trigger anomaly check failed: {e}")
|
|
626
|
+
|
|
627
|
+
# Update last trigger time
|
|
628
|
+
self._update_config(
|
|
629
|
+
source_id,
|
|
630
|
+
{"last_drift_trigger_at": datetime.utcnow().isoformat()},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
_auto_trigger_events.append(event)
|
|
634
|
+
return event
|
|
635
|
+
|
|
636
|
+
def _create_skip_event(
|
|
637
|
+
self,
|
|
638
|
+
source_id: str,
|
|
639
|
+
trigger_type: str,
|
|
640
|
+
alert_id: str,
|
|
641
|
+
alert_type: str,
|
|
642
|
+
reason: str,
|
|
643
|
+
) -> dict[str, Any]:
|
|
644
|
+
"""Create a skipped trigger event."""
|
|
645
|
+
event = {
|
|
646
|
+
"id": str(uuid.uuid4()),
|
|
647
|
+
"source_id": source_id,
|
|
648
|
+
"trigger_type": trigger_type,
|
|
649
|
+
"trigger_alert_id": alert_id,
|
|
650
|
+
"trigger_alert_type": alert_type,
|
|
651
|
+
"result_id": None,
|
|
652
|
+
"correlation_found": False,
|
|
653
|
+
"correlation_id": None,
|
|
654
|
+
"status": "skipped",
|
|
655
|
+
"error_message": None,
|
|
656
|
+
"skipped_reason": reason,
|
|
657
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
658
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
659
|
+
}
|
|
660
|
+
_auto_trigger_events.append(event)
|
|
661
|
+
return event
|
|
662
|
+
|
|
663
|
+
# =========================================================================
|
|
664
|
+
# Configuration Management
|
|
665
|
+
# =========================================================================
|
|
666
|
+
|
|
667
|
+
def get_config(self, source_id: str | None = None) -> dict[str, Any]:
|
|
668
|
+
"""Get auto-trigger configuration.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
source_id: Source ID for source-specific config, None for global.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Configuration dictionary.
|
|
675
|
+
"""
|
|
676
|
+
if source_id and source_id in _source_configs:
|
|
677
|
+
# Merge source config with global defaults
|
|
678
|
+
config = _global_config.copy()
|
|
679
|
+
config.update(_source_configs[source_id])
|
|
680
|
+
return config
|
|
681
|
+
return _global_config.copy()
|
|
682
|
+
|
|
683
|
+
def update_config(
|
|
684
|
+
self,
|
|
685
|
+
source_id: str | None = None,
|
|
686
|
+
**kwargs,
|
|
687
|
+
) -> dict[str, Any]:
|
|
688
|
+
"""Update auto-trigger configuration.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
source_id: Source ID for source-specific config, None for global.
|
|
692
|
+
**kwargs: Configuration fields to update.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Updated configuration.
|
|
696
|
+
"""
|
|
697
|
+
return self._update_config(source_id, kwargs)
|
|
698
|
+
|
|
699
|
+
def _update_config(
|
|
700
|
+
self,
|
|
701
|
+
source_id: str | None,
|
|
702
|
+
updates: dict[str, Any],
|
|
703
|
+
) -> dict[str, Any]:
|
|
704
|
+
"""Internal method to update config."""
|
|
705
|
+
if source_id:
|
|
706
|
+
if source_id not in _source_configs:
|
|
707
|
+
_source_configs[source_id] = {}
|
|
708
|
+
for key, value in updates.items():
|
|
709
|
+
if value is not None:
|
|
710
|
+
_source_configs[source_id][key] = value
|
|
711
|
+
return self.get_config(source_id)
|
|
712
|
+
else:
|
|
713
|
+
for key, value in updates.items():
|
|
714
|
+
if value is not None:
|
|
715
|
+
_global_config[key] = value
|
|
716
|
+
return _global_config.copy()
|
|
717
|
+
|
|
718
|
+
# =========================================================================
|
|
719
|
+
# Query Operations
|
|
720
|
+
# =========================================================================
|
|
721
|
+
|
|
722
|
+
async def get_correlations(
|
|
723
|
+
self,
|
|
724
|
+
source_id: str | None = None,
|
|
725
|
+
*,
|
|
726
|
+
limit: int = 50,
|
|
727
|
+
offset: int = 0,
|
|
728
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
729
|
+
"""Get correlation records.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
source_id: Filter by source ID.
|
|
733
|
+
limit: Maximum to return.
|
|
734
|
+
offset: Number to skip.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
Tuple of (correlations, total_count).
|
|
738
|
+
"""
|
|
739
|
+
if source_id:
|
|
740
|
+
filtered = [c for c in _correlations if c.get("source_id") == source_id]
|
|
741
|
+
else:
|
|
742
|
+
filtered = _correlations.copy()
|
|
743
|
+
|
|
744
|
+
total = len(filtered)
|
|
745
|
+
paginated = filtered[offset : offset + limit]
|
|
746
|
+
return paginated, total
|
|
747
|
+
|
|
748
|
+
async def get_auto_trigger_events(
|
|
749
|
+
self,
|
|
750
|
+
source_id: str | None = None,
|
|
751
|
+
*,
|
|
752
|
+
limit: int = 50,
|
|
753
|
+
offset: int = 0,
|
|
754
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
755
|
+
"""Get auto-trigger event records.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
source_id: Filter by source ID.
|
|
759
|
+
limit: Maximum to return.
|
|
760
|
+
offset: Number to skip.
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
Tuple of (events, total_count).
|
|
764
|
+
"""
|
|
765
|
+
if source_id:
|
|
766
|
+
filtered = [
|
|
767
|
+
e for e in _auto_trigger_events if e.get("source_id") == source_id
|
|
768
|
+
]
|
|
769
|
+
else:
|
|
770
|
+
filtered = _auto_trigger_events.copy()
|
|
771
|
+
|
|
772
|
+
# Sort by created_at desc
|
|
773
|
+
filtered.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
774
|
+
|
|
775
|
+
total = len(filtered)
|
|
776
|
+
paginated = filtered[offset : offset + limit]
|
|
777
|
+
return paginated, total
|
|
778
|
+
|
|
779
|
+
async def get_summary(self) -> dict[str, Any]:
|
|
780
|
+
"""Get cross-alert summary statistics.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Summary dictionary.
|
|
784
|
+
"""
|
|
785
|
+
now = datetime.utcnow()
|
|
786
|
+
last_24h = now - timedelta(hours=24)
|
|
787
|
+
|
|
788
|
+
# Count correlations by strength
|
|
789
|
+
strong = sum(1 for c in _correlations if c.get("correlation_strength") == "strong")
|
|
790
|
+
moderate = sum(1 for c in _correlations if c.get("correlation_strength") == "moderate")
|
|
791
|
+
weak = sum(1 for c in _correlations if c.get("correlation_strength") == "weak")
|
|
792
|
+
|
|
793
|
+
# Recent activity
|
|
794
|
+
recent_correlations = sum(
|
|
795
|
+
1 for c in _correlations
|
|
796
|
+
if c.get("created_at") and datetime.fromisoformat(c["created_at"]) >= last_24h
|
|
797
|
+
)
|
|
798
|
+
recent_triggers = sum(
|
|
799
|
+
1 for e in _auto_trigger_events
|
|
800
|
+
if e.get("created_at") and datetime.fromisoformat(e["created_at"]) >= last_24h
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# Trigger counts by type
|
|
804
|
+
anomaly_to_drift = sum(
|
|
805
|
+
1 for e in _auto_trigger_events
|
|
806
|
+
if e.get("trigger_type") == "anomaly_to_drift"
|
|
807
|
+
)
|
|
808
|
+
drift_to_anomaly = sum(
|
|
809
|
+
1 for e in _auto_trigger_events
|
|
810
|
+
if e.get("trigger_type") == "drift_to_anomaly"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# Top affected sources
|
|
814
|
+
source_counts: dict[str, int] = {}
|
|
815
|
+
for c in _correlations:
|
|
816
|
+
sid = c.get("source_id")
|
|
817
|
+
if sid:
|
|
818
|
+
source_counts[sid] = source_counts.get(sid, 0) + 1
|
|
819
|
+
|
|
820
|
+
top_sources = [
|
|
821
|
+
{"source_id": sid, "source_name": c.get("source_name"), "count": cnt}
|
|
822
|
+
for sid, cnt in sorted(source_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
823
|
+
for c in _correlations if c.get("source_id") == sid
|
|
824
|
+
][:5]
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
"total_correlations": len(_correlations),
|
|
828
|
+
"strong_correlations": strong,
|
|
829
|
+
"moderate_correlations": moderate,
|
|
830
|
+
"weak_correlations": weak,
|
|
831
|
+
"recent_correlations_24h": recent_correlations,
|
|
832
|
+
"recent_auto_triggers_24h": recent_triggers,
|
|
833
|
+
"top_affected_sources": top_sources,
|
|
834
|
+
"auto_trigger_enabled": _global_config.get("enabled", True),
|
|
835
|
+
"anomaly_to_drift_triggers": anomaly_to_drift,
|
|
836
|
+
"drift_to_anomaly_triggers": drift_to_anomaly,
|
|
837
|
+
}
|