truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.0__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 +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,6 +4,9 @@ This module provides services for cross-feature integration between
|
|
|
4
4
|
Anomaly Detection and Drift Monitoring alerts.
|
|
5
5
|
|
|
6
6
|
When anomaly rates spike, it automatically checks for drift and vice versa.
|
|
7
|
+
|
|
8
|
+
NOTE: This module has been updated to use persistent DB storage instead of
|
|
9
|
+
in-memory storage for configurations, correlations, and trigger events.
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
from __future__ import annotations
|
|
@@ -13,12 +16,15 @@ import uuid
|
|
|
13
16
|
from datetime import datetime, timedelta
|
|
14
17
|
from typing import TYPE_CHECKING, Any
|
|
15
18
|
|
|
16
|
-
from sqlalchemy import select, func, and_, or_
|
|
19
|
+
from sqlalchemy import select, func, and_, or_, delete
|
|
17
20
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
21
|
|
|
19
22
|
if TYPE_CHECKING:
|
|
20
23
|
from truthound_dashboard.db.models import (
|
|
21
24
|
AnomalyDetection,
|
|
25
|
+
CrossAlertConfig,
|
|
26
|
+
CrossAlertCorrelation,
|
|
27
|
+
CrossAlertTriggerEvent,
|
|
22
28
|
DriftAlert,
|
|
23
29
|
DriftComparison,
|
|
24
30
|
Source,
|
|
@@ -27,28 +33,14 @@ if TYPE_CHECKING:
|
|
|
27
33
|
logger = logging.getLogger(__name__)
|
|
28
34
|
|
|
29
35
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
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,
|
|
36
|
+
# Default thresholds for new configs
|
|
37
|
+
DEFAULT_THRESHOLDS = {
|
|
38
|
+
"anomaly_rate_threshold": 0.1,
|
|
39
|
+
"anomaly_count_threshold": 10,
|
|
40
|
+
"drift_percentage_threshold": 10.0,
|
|
41
|
+
"drift_columns_threshold": 2,
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
_source_configs: dict[str, dict[str, Any]] = {}
|
|
49
|
-
_correlations: list[dict[str, Any]] = []
|
|
50
|
-
_auto_trigger_events: list[dict[str, Any]] = []
|
|
51
|
-
|
|
52
44
|
|
|
53
45
|
class CrossAlertService:
|
|
54
46
|
"""Service for cross-alert correlation between anomaly and drift detection.
|
|
@@ -58,6 +50,9 @@ class CrossAlertService:
|
|
|
58
50
|
- Auto-triggering drift checks when anomalies spike
|
|
59
51
|
- Auto-triggering anomaly checks when drift is detected
|
|
60
52
|
- Managing auto-trigger configuration
|
|
53
|
+
|
|
54
|
+
All data is persisted to database using CrossAlertConfig,
|
|
55
|
+
CrossAlertCorrelation, and CrossAlertTriggerEvent models.
|
|
61
56
|
"""
|
|
62
57
|
|
|
63
58
|
def __init__(self, session: AsyncSession) -> None:
|
|
@@ -68,6 +63,172 @@ class CrossAlertService:
|
|
|
68
63
|
"""
|
|
69
64
|
self.session = session
|
|
70
65
|
|
|
66
|
+
# =========================================================================
|
|
67
|
+
# Configuration Management (DB-backed)
|
|
68
|
+
# =========================================================================
|
|
69
|
+
|
|
70
|
+
async def get_config(self, source_id: str | None = None) -> dict[str, Any]:
|
|
71
|
+
"""Get auto-trigger configuration from database.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
source_id: Source ID for source-specific config, None for global.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Configuration dictionary.
|
|
78
|
+
"""
|
|
79
|
+
from truthound_dashboard.db.models import CrossAlertConfig
|
|
80
|
+
|
|
81
|
+
# Try to get source-specific config
|
|
82
|
+
if source_id:
|
|
83
|
+
result = await self.session.execute(
|
|
84
|
+
select(CrossAlertConfig).where(
|
|
85
|
+
CrossAlertConfig.source_id == source_id
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
config = result.scalar_one_or_none()
|
|
89
|
+
if config:
|
|
90
|
+
return self._config_to_dict(config)
|
|
91
|
+
|
|
92
|
+
# Fall back to global config (source_id is None)
|
|
93
|
+
result = await self.session.execute(
|
|
94
|
+
select(CrossAlertConfig).where(CrossAlertConfig.source_id.is_(None))
|
|
95
|
+
)
|
|
96
|
+
config = result.scalar_one_or_none()
|
|
97
|
+
|
|
98
|
+
if config:
|
|
99
|
+
return self._config_to_dict(config)
|
|
100
|
+
|
|
101
|
+
# Return defaults if no config exists
|
|
102
|
+
now = datetime.utcnow()
|
|
103
|
+
return {
|
|
104
|
+
"id": str(uuid.uuid4()),
|
|
105
|
+
"source_id": source_id,
|
|
106
|
+
"enabled": True,
|
|
107
|
+
"trigger_drift_on_anomaly": True,
|
|
108
|
+
"trigger_anomaly_on_drift": True,
|
|
109
|
+
"thresholds": DEFAULT_THRESHOLDS.copy(),
|
|
110
|
+
"notify_on_correlation": True,
|
|
111
|
+
"notification_channel_ids": None,
|
|
112
|
+
"cooldown_seconds": 300,
|
|
113
|
+
"last_anomaly_trigger_at": None,
|
|
114
|
+
"last_drift_trigger_at": None,
|
|
115
|
+
"created_at": now,
|
|
116
|
+
"updated_at": now,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def _config_to_dict(self, config: "CrossAlertConfig") -> dict[str, Any]:
|
|
120
|
+
"""Convert CrossAlertConfig model to dictionary."""
|
|
121
|
+
return {
|
|
122
|
+
"id": config.id,
|
|
123
|
+
"source_id": config.source_id,
|
|
124
|
+
"enabled": config.enabled,
|
|
125
|
+
"trigger_drift_on_anomaly": config.trigger_drift_on_anomaly,
|
|
126
|
+
"trigger_anomaly_on_drift": config.trigger_anomaly_on_drift,
|
|
127
|
+
"thresholds": config.thresholds or DEFAULT_THRESHOLDS.copy(),
|
|
128
|
+
"notify_on_correlation": config.notify_on_correlation,
|
|
129
|
+
"notification_channel_ids": config.notification_channel_ids,
|
|
130
|
+
"cooldown_seconds": config.cooldown_seconds,
|
|
131
|
+
"last_anomaly_trigger_at": (
|
|
132
|
+
config.last_anomaly_trigger_at.isoformat()
|
|
133
|
+
if config.last_anomaly_trigger_at
|
|
134
|
+
else None
|
|
135
|
+
),
|
|
136
|
+
"last_drift_trigger_at": (
|
|
137
|
+
config.last_drift_trigger_at.isoformat()
|
|
138
|
+
if config.last_drift_trigger_at
|
|
139
|
+
else None
|
|
140
|
+
),
|
|
141
|
+
"created_at": config.created_at.isoformat() if config.created_at else None,
|
|
142
|
+
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async def update_config(
|
|
146
|
+
self,
|
|
147
|
+
source_id: str | None = None,
|
|
148
|
+
**kwargs,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Update auto-trigger configuration in database.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
source_id: Source ID for source-specific config, None for global.
|
|
154
|
+
**kwargs: Configuration fields to update.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Updated configuration dictionary.
|
|
158
|
+
"""
|
|
159
|
+
from truthound_dashboard.db.models import CrossAlertConfig
|
|
160
|
+
|
|
161
|
+
# Try to find existing config
|
|
162
|
+
if source_id:
|
|
163
|
+
result = await self.session.execute(
|
|
164
|
+
select(CrossAlertConfig).where(
|
|
165
|
+
CrossAlertConfig.source_id == source_id
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
result = await self.session.execute(
|
|
170
|
+
select(CrossAlertConfig).where(CrossAlertConfig.source_id.is_(None))
|
|
171
|
+
)
|
|
172
|
+
config = result.scalar_one_or_none()
|
|
173
|
+
|
|
174
|
+
if config:
|
|
175
|
+
# Update existing config
|
|
176
|
+
for key, value in kwargs.items():
|
|
177
|
+
if value is not None and hasattr(config, key):
|
|
178
|
+
setattr(config, key, value)
|
|
179
|
+
config.updated_at = datetime.utcnow()
|
|
180
|
+
else:
|
|
181
|
+
# Create new config
|
|
182
|
+
config = CrossAlertConfig(
|
|
183
|
+
source_id=source_id,
|
|
184
|
+
enabled=kwargs.get("enabled", True),
|
|
185
|
+
trigger_drift_on_anomaly=kwargs.get("trigger_drift_on_anomaly", True),
|
|
186
|
+
trigger_anomaly_on_drift=kwargs.get("trigger_anomaly_on_drift", True),
|
|
187
|
+
thresholds=kwargs.get("thresholds", DEFAULT_THRESHOLDS.copy()),
|
|
188
|
+
notify_on_correlation=kwargs.get("notify_on_correlation", True),
|
|
189
|
+
notification_channel_ids=kwargs.get("notification_channel_ids"),
|
|
190
|
+
cooldown_seconds=kwargs.get("cooldown_seconds", 300),
|
|
191
|
+
)
|
|
192
|
+
self.session.add(config)
|
|
193
|
+
|
|
194
|
+
await self.session.flush()
|
|
195
|
+
return self._config_to_dict(config)
|
|
196
|
+
|
|
197
|
+
async def _update_last_trigger_time(
|
|
198
|
+
self,
|
|
199
|
+
source_id: str,
|
|
200
|
+
trigger_type: str,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Update the last trigger time for a source.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
source_id: Source ID.
|
|
206
|
+
trigger_type: Either 'anomaly' or 'drift'.
|
|
207
|
+
"""
|
|
208
|
+
from truthound_dashboard.db.models import CrossAlertConfig
|
|
209
|
+
|
|
210
|
+
result = await self.session.execute(
|
|
211
|
+
select(CrossAlertConfig).where(
|
|
212
|
+
CrossAlertConfig.source_id == source_id
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
config = result.scalar_one_or_none()
|
|
216
|
+
|
|
217
|
+
if not config:
|
|
218
|
+
# Create source-specific config with just the trigger time
|
|
219
|
+
config = CrossAlertConfig(
|
|
220
|
+
source_id=source_id,
|
|
221
|
+
thresholds=DEFAULT_THRESHOLDS.copy(),
|
|
222
|
+
)
|
|
223
|
+
self.session.add(config)
|
|
224
|
+
|
|
225
|
+
if trigger_type == "anomaly":
|
|
226
|
+
config.last_anomaly_trigger_at = datetime.utcnow()
|
|
227
|
+
else:
|
|
228
|
+
config.last_drift_trigger_at = datetime.utcnow()
|
|
229
|
+
|
|
230
|
+
await self.session.flush()
|
|
231
|
+
|
|
71
232
|
# =========================================================================
|
|
72
233
|
# Correlation Analysis
|
|
73
234
|
# =========================================================================
|
|
@@ -78,6 +239,7 @@ class CrossAlertService:
|
|
|
78
239
|
*,
|
|
79
240
|
time_window_hours: int = 24,
|
|
80
241
|
limit: int = 50,
|
|
242
|
+
save_correlations: bool = True,
|
|
81
243
|
) -> list[dict[str, Any]]:
|
|
82
244
|
"""Find correlated anomaly and drift alerts for a source.
|
|
83
245
|
|
|
@@ -85,12 +247,14 @@ class CrossAlertService:
|
|
|
85
247
|
source_id: Data source ID.
|
|
86
248
|
time_window_hours: Time window to look for correlations.
|
|
87
249
|
limit: Maximum correlations to return.
|
|
250
|
+
save_correlations: Whether to save found correlations to DB.
|
|
88
251
|
|
|
89
252
|
Returns:
|
|
90
253
|
List of correlation dictionaries.
|
|
91
254
|
"""
|
|
92
255
|
from truthound_dashboard.db.models import (
|
|
93
256
|
AnomalyDetection,
|
|
257
|
+
CrossAlertCorrelation,
|
|
94
258
|
DriftAlert,
|
|
95
259
|
DriftComparison,
|
|
96
260
|
Source,
|
|
@@ -121,32 +285,37 @@ class CrossAlertService:
|
|
|
121
285
|
)
|
|
122
286
|
anomaly_detections = list(anomaly_result.scalars().all())
|
|
123
287
|
|
|
124
|
-
# Get recent drift alerts for this source
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
288
|
+
# Get recent drift alerts for this source via DriftMonitor
|
|
289
|
+
from truthound_dashboard.db.models import DriftMonitor
|
|
290
|
+
|
|
291
|
+
# Find monitors related to this source
|
|
292
|
+
monitor_result = await self.session.execute(
|
|
293
|
+
select(DriftMonitor).where(
|
|
294
|
+
or_(
|
|
295
|
+
DriftMonitor.baseline_source_id == source_id,
|
|
296
|
+
DriftMonitor.current_source_id == source_id,
|
|
130
297
|
)
|
|
131
298
|
)
|
|
132
|
-
.order_by(DriftAlert.created_at.desc())
|
|
133
|
-
.limit(100)
|
|
134
299
|
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
300
|
+
monitors = {m.id: m for m in monitor_result.scalars().all()}
|
|
301
|
+
|
|
302
|
+
related_drift_alerts: list[tuple[Any, Any]] = []
|
|
303
|
+
if monitors:
|
|
304
|
+
drift_result = await self.session.execute(
|
|
305
|
+
select(DriftAlert)
|
|
306
|
+
.where(
|
|
307
|
+
and_(
|
|
308
|
+
DriftAlert.monitor_id.in_(list(monitors.keys())),
|
|
309
|
+
DriftAlert.created_at >= since,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
.order_by(DriftAlert.created_at.desc())
|
|
313
|
+
.limit(100)
|
|
143
314
|
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
):
|
|
149
|
-
related_drift_alerts.append((alert, comparison))
|
|
315
|
+
for alert in drift_result.scalars().all():
|
|
316
|
+
monitor = monitors.get(alert.monitor_id)
|
|
317
|
+
if monitor:
|
|
318
|
+
related_drift_alerts.append((alert, monitor))
|
|
150
319
|
|
|
151
320
|
# Find correlations
|
|
152
321
|
correlations = []
|
|
@@ -174,52 +343,76 @@ class CrossAlertService:
|
|
|
174
343
|
|
|
175
344
|
# Find common columns
|
|
176
345
|
anomaly_cols = detection.columns_analyzed or []
|
|
177
|
-
drift_cols = alert.
|
|
346
|
+
drift_cols = alert.affected_columns or []
|
|
178
347
|
common_cols = list(set(anomaly_cols) & set(drift_cols))
|
|
179
348
|
|
|
349
|
+
correlation_id = str(uuid.uuid4())
|
|
350
|
+
confidence = self._calculate_confidence(
|
|
351
|
+
detection, alert, common_cols, time_delta
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
anomaly_data = {
|
|
355
|
+
"alert_id": detection.id,
|
|
356
|
+
"alert_type": "anomaly",
|
|
357
|
+
"source_id": source_id,
|
|
358
|
+
"source_name": source_name,
|
|
359
|
+
"severity": self._anomaly_severity(detection.anomaly_rate),
|
|
360
|
+
"message": f"Detected {detection.anomaly_count} anomalies ({detection.anomaly_rate * 100:.1f}% rate)",
|
|
361
|
+
"created_at": detection.created_at.isoformat(),
|
|
362
|
+
"anomaly_rate": detection.anomaly_rate,
|
|
363
|
+
"anomaly_count": detection.anomaly_count,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
drift_data = {
|
|
367
|
+
"alert_id": alert.id,
|
|
368
|
+
"alert_type": "drift",
|
|
369
|
+
"source_id": source_id,
|
|
370
|
+
"source_name": source_name,
|
|
371
|
+
"severity": alert.severity,
|
|
372
|
+
"message": alert.message,
|
|
373
|
+
"created_at": alert.created_at.isoformat(),
|
|
374
|
+
"drift_percentage": alert.drift_score,
|
|
375
|
+
"drifted_columns": alert.affected_columns or [],
|
|
376
|
+
}
|
|
377
|
+
|
|
180
378
|
correlation = {
|
|
181
|
-
"id":
|
|
379
|
+
"id": correlation_id,
|
|
182
380
|
"source_id": source_id,
|
|
183
381
|
"source_name": source_name,
|
|
184
382
|
"correlation_strength": strength,
|
|
185
|
-
"confidence_score":
|
|
186
|
-
detection, alert, common_cols, time_delta
|
|
187
|
-
),
|
|
383
|
+
"confidence_score": confidence,
|
|
188
384
|
"time_delta_seconds": int(time_delta),
|
|
189
|
-
"anomaly_alert":
|
|
190
|
-
|
|
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
|
-
},
|
|
385
|
+
"anomaly_alert": anomaly_data,
|
|
386
|
+
"drift_alert": drift_data,
|
|
215
387
|
"common_columns": common_cols,
|
|
216
388
|
"suggested_action": self._suggest_action(strength, common_cols),
|
|
217
389
|
"notes": None,
|
|
218
390
|
"created_at": datetime.utcnow().isoformat(),
|
|
219
391
|
"updated_at": datetime.utcnow().isoformat(),
|
|
220
392
|
}
|
|
393
|
+
|
|
394
|
+
# Save to database
|
|
395
|
+
if save_correlations:
|
|
396
|
+
db_correlation = CrossAlertCorrelation(
|
|
397
|
+
id=correlation_id,
|
|
398
|
+
source_id=source_id,
|
|
399
|
+
correlation_strength=strength,
|
|
400
|
+
confidence_score=confidence,
|
|
401
|
+
time_delta_seconds=int(time_delta),
|
|
402
|
+
anomaly_alert_id=detection.id,
|
|
403
|
+
drift_alert_id=alert.id,
|
|
404
|
+
anomaly_data=anomaly_data,
|
|
405
|
+
drift_data=drift_data,
|
|
406
|
+
common_columns=common_cols,
|
|
407
|
+
suggested_action=self._suggest_action(strength, common_cols),
|
|
408
|
+
)
|
|
409
|
+
self.session.add(db_correlation)
|
|
410
|
+
|
|
221
411
|
correlations.append(correlation)
|
|
222
412
|
|
|
413
|
+
if save_correlations and correlations:
|
|
414
|
+
await self.session.flush()
|
|
415
|
+
|
|
223
416
|
# Sort by confidence score and limit
|
|
224
417
|
correlations.sort(key=lambda x: x["confidence_score"], reverse=True)
|
|
225
418
|
return correlations[:limit]
|
|
@@ -230,16 +423,7 @@ class CrossAlertService:
|
|
|
230
423
|
alert: "DriftAlert",
|
|
231
424
|
time_delta: float,
|
|
232
425
|
) -> 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
|
-
"""
|
|
426
|
+
"""Calculate correlation strength between anomaly and drift."""
|
|
243
427
|
score = 0
|
|
244
428
|
|
|
245
429
|
# Time proximity (closer = stronger)
|
|
@@ -290,17 +474,7 @@ class CrossAlertService:
|
|
|
290
474
|
common_cols: list[str],
|
|
291
475
|
time_delta: float,
|
|
292
476
|
) -> 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
|
-
"""
|
|
477
|
+
"""Calculate confidence score for correlation."""
|
|
304
478
|
confidence = 0.5 # Base confidence
|
|
305
479
|
|
|
306
480
|
# Time proximity bonus
|
|
@@ -335,15 +509,7 @@ class CrossAlertService:
|
|
|
335
509
|
return "low"
|
|
336
510
|
|
|
337
511
|
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
|
-
"""
|
|
512
|
+
"""Suggest action based on correlation."""
|
|
347
513
|
if strength == "strong":
|
|
348
514
|
if common_cols:
|
|
349
515
|
cols = ", ".join(common_cols[:3])
|
|
@@ -362,15 +528,12 @@ class CrossAlertService:
|
|
|
362
528
|
self,
|
|
363
529
|
detection_id: str,
|
|
364
530
|
) -> dict[str, Any] | None:
|
|
365
|
-
"""Auto-trigger drift check when anomaly detection shows high rate.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
Trigger event result or None if skipped.
|
|
372
|
-
"""
|
|
373
|
-
from truthound_dashboard.db.models import AnomalyDetection, DriftMonitor
|
|
531
|
+
"""Auto-trigger drift check when anomaly detection shows high rate."""
|
|
532
|
+
from truthound_dashboard.db.models import (
|
|
533
|
+
AnomalyDetection,
|
|
534
|
+
CrossAlertTriggerEvent,
|
|
535
|
+
DriftMonitor,
|
|
536
|
+
)
|
|
374
537
|
from truthound_dashboard.core.drift_monitor import DriftMonitorService
|
|
375
538
|
|
|
376
539
|
# Get detection
|
|
@@ -383,9 +546,9 @@ class CrossAlertService:
|
|
|
383
546
|
return None
|
|
384
547
|
|
|
385
548
|
# Get config
|
|
386
|
-
config = self.get_config(detection.source_id)
|
|
549
|
+
config = await self.get_config(detection.source_id)
|
|
387
550
|
if not config.get("enabled") or not config.get("trigger_drift_on_anomaly"):
|
|
388
|
-
return self._create_skip_event(
|
|
551
|
+
return await self._create_skip_event(
|
|
389
552
|
detection.source_id,
|
|
390
553
|
"anomaly_to_drift",
|
|
391
554
|
detection_id,
|
|
@@ -402,7 +565,7 @@ class CrossAlertService:
|
|
|
402
565
|
count = detection.anomaly_count or 0
|
|
403
566
|
|
|
404
567
|
if rate < rate_threshold and count < count_threshold:
|
|
405
|
-
return self._create_skip_event(
|
|
568
|
+
return await self._create_skip_event(
|
|
406
569
|
detection.source_id,
|
|
407
570
|
"anomaly_to_drift",
|
|
408
571
|
detection_id,
|
|
@@ -412,13 +575,12 @@ class CrossAlertService:
|
|
|
412
575
|
|
|
413
576
|
# Check cooldown
|
|
414
577
|
cooldown = config.get("cooldown_seconds", 300)
|
|
415
|
-
|
|
416
|
-
if
|
|
417
|
-
|
|
418
|
-
last_trigger = datetime.fromisoformat(last_trigger)
|
|
578
|
+
last_trigger_str = config.get("last_anomaly_trigger_at")
|
|
579
|
+
if last_trigger_str:
|
|
580
|
+
last_trigger = datetime.fromisoformat(last_trigger_str)
|
|
419
581
|
elapsed = (datetime.utcnow() - last_trigger).total_seconds()
|
|
420
582
|
if elapsed < cooldown:
|
|
421
|
-
return self._create_skip_event(
|
|
583
|
+
return await self._create_skip_event(
|
|
422
584
|
detection.source_id,
|
|
423
585
|
"anomaly_to_drift",
|
|
424
586
|
detection_id,
|
|
@@ -439,37 +601,35 @@ class CrossAlertService:
|
|
|
439
601
|
)
|
|
440
602
|
monitor = monitor_result.scalar_one_or_none()
|
|
441
603
|
|
|
442
|
-
event
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"
|
|
448
|
-
|
|
449
|
-
"
|
|
450
|
-
"
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"skipped_reason": None,
|
|
454
|
-
"created_at": datetime.utcnow().isoformat(),
|
|
455
|
-
"updated_at": datetime.utcnow().isoformat(),
|
|
456
|
-
}
|
|
604
|
+
# Create event record
|
|
605
|
+
event_id = str(uuid.uuid4())
|
|
606
|
+
event = CrossAlertTriggerEvent(
|
|
607
|
+
id=event_id,
|
|
608
|
+
source_id=detection.source_id,
|
|
609
|
+
trigger_type="anomaly_to_drift",
|
|
610
|
+
trigger_alert_id=detection_id,
|
|
611
|
+
trigger_alert_type="anomaly",
|
|
612
|
+
status="pending",
|
|
613
|
+
)
|
|
614
|
+
self.session.add(event)
|
|
457
615
|
|
|
458
616
|
if not monitor:
|
|
459
|
-
event
|
|
460
|
-
event
|
|
461
|
-
|
|
462
|
-
return event
|
|
617
|
+
event.status = "skipped"
|
|
618
|
+
event.skipped_reason = "No drift monitor configured for this source"
|
|
619
|
+
await self.session.flush()
|
|
620
|
+
return self._event_to_dict(event)
|
|
463
621
|
|
|
464
622
|
try:
|
|
465
623
|
# Run the drift monitor
|
|
466
|
-
event
|
|
624
|
+
event.status = "running"
|
|
625
|
+
await self.session.flush()
|
|
626
|
+
|
|
467
627
|
drift_service = DriftMonitorService(self.session)
|
|
468
628
|
comparison = await drift_service.run_monitor(monitor.id)
|
|
469
629
|
|
|
470
630
|
if comparison:
|
|
471
|
-
event
|
|
472
|
-
event
|
|
631
|
+
event.status = "completed"
|
|
632
|
+
event.result_id = comparison.id
|
|
473
633
|
|
|
474
634
|
# Check for correlation
|
|
475
635
|
if comparison.has_drift:
|
|
@@ -477,39 +637,33 @@ class CrossAlertService:
|
|
|
477
637
|
detection.source_id, time_window_hours=1
|
|
478
638
|
)
|
|
479
639
|
if correlations:
|
|
480
|
-
event
|
|
481
|
-
event
|
|
640
|
+
event.correlation_found = True
|
|
641
|
+
event.correlation_id = correlations[0]["id"]
|
|
482
642
|
else:
|
|
483
|
-
event
|
|
484
|
-
event
|
|
643
|
+
event.status = "failed"
|
|
644
|
+
event.error_message = "Drift monitor run failed"
|
|
485
645
|
|
|
486
646
|
except Exception as e:
|
|
487
|
-
event
|
|
488
|
-
event
|
|
647
|
+
event.status = "failed"
|
|
648
|
+
event.error_message = str(e)
|
|
489
649
|
logger.error(f"Auto-trigger drift check failed: {e}")
|
|
490
650
|
|
|
491
651
|
# Update last trigger time
|
|
492
|
-
self.
|
|
493
|
-
|
|
494
|
-
{"last_anomaly_trigger_at": datetime.utcnow().isoformat()},
|
|
495
|
-
)
|
|
652
|
+
await self._update_last_trigger_time(detection.source_id, "anomaly")
|
|
653
|
+
await self.session.flush()
|
|
496
654
|
|
|
497
|
-
|
|
498
|
-
return event
|
|
655
|
+
return self._event_to_dict(event)
|
|
499
656
|
|
|
500
657
|
async def auto_trigger_anomaly_on_drift(
|
|
501
658
|
self,
|
|
502
659
|
monitor_id: str,
|
|
503
660
|
) -> dict[str, Any] | None:
|
|
504
|
-
"""Auto-trigger anomaly check when drift is detected.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
Trigger event result or None if skipped.
|
|
511
|
-
"""
|
|
512
|
-
from truthound_dashboard.db.models import DriftMonitor, DriftAlert
|
|
661
|
+
"""Auto-trigger anomaly check when drift is detected."""
|
|
662
|
+
from truthound_dashboard.db.models import (
|
|
663
|
+
CrossAlertTriggerEvent,
|
|
664
|
+
DriftAlert,
|
|
665
|
+
DriftMonitor,
|
|
666
|
+
)
|
|
513
667
|
from truthound_dashboard.core.anomaly import AnomalyDetectionService
|
|
514
668
|
|
|
515
669
|
# Get monitor
|
|
@@ -536,9 +690,9 @@ class CrossAlertService:
|
|
|
536
690
|
return None
|
|
537
691
|
|
|
538
692
|
# Get config
|
|
539
|
-
config = self.get_config(source_id)
|
|
693
|
+
config = await self.get_config(source_id)
|
|
540
694
|
if not config.get("enabled") or not config.get("trigger_anomaly_on_drift"):
|
|
541
|
-
return self._create_skip_event(
|
|
695
|
+
return await self._create_skip_event(
|
|
542
696
|
source_id,
|
|
543
697
|
"drift_to_anomaly",
|
|
544
698
|
alert.id,
|
|
@@ -555,7 +709,7 @@ class CrossAlertService:
|
|
|
555
709
|
cols_count = len(alert.drifted_columns_json or [])
|
|
556
710
|
|
|
557
711
|
if drift_pct < drift_threshold and cols_count < cols_threshold:
|
|
558
|
-
return self._create_skip_event(
|
|
712
|
+
return await self._create_skip_event(
|
|
559
713
|
source_id,
|
|
560
714
|
"drift_to_anomaly",
|
|
561
715
|
alert.id,
|
|
@@ -565,13 +719,12 @@ class CrossAlertService:
|
|
|
565
719
|
|
|
566
720
|
# Check cooldown
|
|
567
721
|
cooldown = config.get("cooldown_seconds", 300)
|
|
568
|
-
|
|
569
|
-
if
|
|
570
|
-
|
|
571
|
-
last_trigger = datetime.fromisoformat(last_trigger)
|
|
722
|
+
last_trigger_str = config.get("last_drift_trigger_at")
|
|
723
|
+
if last_trigger_str:
|
|
724
|
+
last_trigger = datetime.fromisoformat(last_trigger_str)
|
|
572
725
|
elapsed = (datetime.utcnow() - last_trigger).total_seconds()
|
|
573
726
|
if elapsed < cooldown:
|
|
574
|
-
return self._create_skip_event(
|
|
727
|
+
return await self._create_skip_event(
|
|
575
728
|
source_id,
|
|
576
729
|
"drift_to_anomaly",
|
|
577
730
|
alert.id,
|
|
@@ -579,25 +732,23 @@ class CrossAlertService:
|
|
|
579
732
|
f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
|
|
580
733
|
)
|
|
581
734
|
|
|
582
|
-
event
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
"
|
|
588
|
-
|
|
589
|
-
"
|
|
590
|
-
"
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
"skipped_reason": None,
|
|
594
|
-
"created_at": datetime.utcnow().isoformat(),
|
|
595
|
-
"updated_at": datetime.utcnow().isoformat(),
|
|
596
|
-
}
|
|
735
|
+
# Create event record
|
|
736
|
+
event_id = str(uuid.uuid4())
|
|
737
|
+
event = CrossAlertTriggerEvent(
|
|
738
|
+
id=event_id,
|
|
739
|
+
source_id=source_id,
|
|
740
|
+
trigger_type="drift_to_anomaly",
|
|
741
|
+
trigger_alert_id=alert.id,
|
|
742
|
+
trigger_alert_type="drift",
|
|
743
|
+
status="pending",
|
|
744
|
+
)
|
|
745
|
+
self.session.add(event)
|
|
597
746
|
|
|
598
747
|
try:
|
|
599
748
|
# Run anomaly detection
|
|
600
|
-
event
|
|
749
|
+
event.status = "running"
|
|
750
|
+
await self.session.flush()
|
|
751
|
+
|
|
601
752
|
anomaly_service = AnomalyDetectionService(self.session)
|
|
602
753
|
|
|
603
754
|
detection = await anomaly_service.create_detection(
|
|
@@ -607,8 +758,8 @@ class CrossAlertService:
|
|
|
607
758
|
)
|
|
608
759
|
detection = await anomaly_service.run_detection(detection.id)
|
|
609
760
|
|
|
610
|
-
event
|
|
611
|
-
event
|
|
761
|
+
event.status = "completed"
|
|
762
|
+
event.result_id = detection.id
|
|
612
763
|
|
|
613
764
|
# Check for correlation
|
|
614
765
|
if detection.anomaly_count and detection.anomaly_count > 0:
|
|
@@ -616,24 +767,21 @@ class CrossAlertService:
|
|
|
616
767
|
source_id, time_window_hours=1
|
|
617
768
|
)
|
|
618
769
|
if correlations:
|
|
619
|
-
event
|
|
620
|
-
event
|
|
770
|
+
event.correlation_found = True
|
|
771
|
+
event.correlation_id = correlations[0]["id"]
|
|
621
772
|
|
|
622
773
|
except Exception as e:
|
|
623
|
-
event
|
|
624
|
-
event
|
|
774
|
+
event.status = "failed"
|
|
775
|
+
event.error_message = str(e)
|
|
625
776
|
logger.error(f"Auto-trigger anomaly check failed: {e}")
|
|
626
777
|
|
|
627
778
|
# Update last trigger time
|
|
628
|
-
self.
|
|
629
|
-
|
|
630
|
-
{"last_drift_trigger_at": datetime.utcnow().isoformat()},
|
|
631
|
-
)
|
|
779
|
+
await self._update_last_trigger_time(source_id, "drift")
|
|
780
|
+
await self.session.flush()
|
|
632
781
|
|
|
633
|
-
|
|
634
|
-
return event
|
|
782
|
+
return self._event_to_dict(event)
|
|
635
783
|
|
|
636
|
-
def _create_skip_event(
|
|
784
|
+
async def _create_skip_event(
|
|
637
785
|
self,
|
|
638
786
|
source_id: str,
|
|
639
787
|
trigger_type: str,
|
|
@@ -641,82 +789,41 @@ class CrossAlertService:
|
|
|
641
789
|
alert_type: str,
|
|
642
790
|
reason: str,
|
|
643
791
|
) -> dict[str, Any]:
|
|
644
|
-
"""Create a skipped trigger event."""
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
"
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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.
|
|
792
|
+
"""Create a skipped trigger event and save to DB."""
|
|
793
|
+
from truthound_dashboard.db.models import CrossAlertTriggerEvent
|
|
794
|
+
|
|
795
|
+
event = CrossAlertTriggerEvent(
|
|
796
|
+
source_id=source_id,
|
|
797
|
+
trigger_type=trigger_type,
|
|
798
|
+
trigger_alert_id=alert_id,
|
|
799
|
+
trigger_alert_type=alert_type,
|
|
800
|
+
status="skipped",
|
|
801
|
+
skipped_reason=reason,
|
|
802
|
+
)
|
|
803
|
+
self.session.add(event)
|
|
804
|
+
await self.session.flush()
|
|
689
805
|
|
|
690
|
-
|
|
691
|
-
source_id: Source ID for source-specific config, None for global.
|
|
692
|
-
**kwargs: Configuration fields to update.
|
|
806
|
+
return self._event_to_dict(event)
|
|
693
807
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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()
|
|
808
|
+
def _event_to_dict(self, event: "CrossAlertTriggerEvent") -> dict[str, Any]:
|
|
809
|
+
"""Convert CrossAlertTriggerEvent model to dictionary."""
|
|
810
|
+
return {
|
|
811
|
+
"id": event.id,
|
|
812
|
+
"source_id": event.source_id,
|
|
813
|
+
"trigger_type": event.trigger_type,
|
|
814
|
+
"trigger_alert_id": event.trigger_alert_id,
|
|
815
|
+
"trigger_alert_type": event.trigger_alert_type,
|
|
816
|
+
"result_id": event.result_id,
|
|
817
|
+
"correlation_found": event.correlation_found,
|
|
818
|
+
"correlation_id": event.correlation_id,
|
|
819
|
+
"status": event.status,
|
|
820
|
+
"error_message": event.error_message,
|
|
821
|
+
"skipped_reason": event.skipped_reason,
|
|
822
|
+
"created_at": event.created_at.isoformat() if event.created_at else None,
|
|
823
|
+
}
|
|
717
824
|
|
|
718
825
|
# =========================================================================
|
|
719
|
-
# Query Operations
|
|
826
|
+
# Query Operations (DB-backed)
|
|
720
827
|
# =========================================================================
|
|
721
828
|
|
|
722
829
|
async def get_correlations(
|
|
@@ -726,7 +833,7 @@ class CrossAlertService:
|
|
|
726
833
|
limit: int = 50,
|
|
727
834
|
offset: int = 0,
|
|
728
835
|
) -> tuple[list[dict[str, Any]], int]:
|
|
729
|
-
"""Get correlation records.
|
|
836
|
+
"""Get correlation records from database.
|
|
730
837
|
|
|
731
838
|
Args:
|
|
732
839
|
source_id: Filter by source ID.
|
|
@@ -736,14 +843,63 @@ class CrossAlertService:
|
|
|
736
843
|
Returns:
|
|
737
844
|
Tuple of (correlations, total_count).
|
|
738
845
|
"""
|
|
846
|
+
from truthound_dashboard.db.models import CrossAlertCorrelation, Source
|
|
847
|
+
|
|
848
|
+
# Build query
|
|
849
|
+
query = select(CrossAlertCorrelation)
|
|
850
|
+
count_query = select(func.count(CrossAlertCorrelation.id))
|
|
851
|
+
|
|
739
852
|
if source_id:
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
853
|
+
query = query.where(CrossAlertCorrelation.source_id == source_id)
|
|
854
|
+
count_query = count_query.where(
|
|
855
|
+
CrossAlertCorrelation.source_id == source_id
|
|
856
|
+
)
|
|
743
857
|
|
|
744
|
-
total
|
|
745
|
-
|
|
746
|
-
|
|
858
|
+
# Get total count
|
|
859
|
+
count_result = await self.session.execute(count_query)
|
|
860
|
+
total = count_result.scalar() or 0
|
|
861
|
+
|
|
862
|
+
# Get paginated results
|
|
863
|
+
query = (
|
|
864
|
+
query.order_by(CrossAlertCorrelation.created_at.desc())
|
|
865
|
+
.offset(offset)
|
|
866
|
+
.limit(limit)
|
|
867
|
+
)
|
|
868
|
+
result = await self.session.execute(query)
|
|
869
|
+
correlations = result.scalars().all()
|
|
870
|
+
|
|
871
|
+
# Convert to dicts with source names
|
|
872
|
+
items = []
|
|
873
|
+
for corr in correlations:
|
|
874
|
+
# Get source name
|
|
875
|
+
source_result = await self.session.execute(
|
|
876
|
+
select(Source).where(Source.id == corr.source_id)
|
|
877
|
+
)
|
|
878
|
+
source = source_result.scalar_one_or_none()
|
|
879
|
+
source_name = source.name if source else None
|
|
880
|
+
|
|
881
|
+
items.append({
|
|
882
|
+
"id": corr.id,
|
|
883
|
+
"source_id": corr.source_id,
|
|
884
|
+
"source_name": source_name,
|
|
885
|
+
"correlation_strength": corr.correlation_strength,
|
|
886
|
+
"confidence_score": corr.confidence_score,
|
|
887
|
+
"time_delta_seconds": corr.time_delta_seconds,
|
|
888
|
+
"anomaly_alert": corr.anomaly_data or {
|
|
889
|
+
"alert_id": corr.anomaly_alert_id,
|
|
890
|
+
"alert_type": "anomaly",
|
|
891
|
+
},
|
|
892
|
+
"drift_alert": corr.drift_data or {
|
|
893
|
+
"alert_id": corr.drift_alert_id,
|
|
894
|
+
"alert_type": "drift",
|
|
895
|
+
},
|
|
896
|
+
"common_columns": corr.common_columns,
|
|
897
|
+
"suggested_action": corr.suggested_action,
|
|
898
|
+
"notes": corr.notes,
|
|
899
|
+
"created_at": corr.created_at.isoformat() if corr.created_at else None,
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
return items, total
|
|
747
903
|
|
|
748
904
|
async def get_auto_trigger_events(
|
|
749
905
|
self,
|
|
@@ -752,7 +908,7 @@ class CrossAlertService:
|
|
|
752
908
|
limit: int = 50,
|
|
753
909
|
offset: int = 0,
|
|
754
910
|
) -> tuple[list[dict[str, Any]], int]:
|
|
755
|
-
"""Get auto-trigger event records.
|
|
911
|
+
"""Get auto-trigger event records from database.
|
|
756
912
|
|
|
757
913
|
Args:
|
|
758
914
|
source_id: Filter by source ID.
|
|
@@ -762,76 +918,140 @@ class CrossAlertService:
|
|
|
762
918
|
Returns:
|
|
763
919
|
Tuple of (events, total_count).
|
|
764
920
|
"""
|
|
921
|
+
from truthound_dashboard.db.models import CrossAlertTriggerEvent
|
|
922
|
+
|
|
923
|
+
# Build query
|
|
924
|
+
query = select(CrossAlertTriggerEvent)
|
|
925
|
+
count_query = select(func.count(CrossAlertTriggerEvent.id))
|
|
926
|
+
|
|
765
927
|
if source_id:
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
filtered = _auto_trigger_events.copy()
|
|
928
|
+
query = query.where(CrossAlertTriggerEvent.source_id == source_id)
|
|
929
|
+
count_query = count_query.where(
|
|
930
|
+
CrossAlertTriggerEvent.source_id == source_id
|
|
931
|
+
)
|
|
771
932
|
|
|
772
|
-
#
|
|
773
|
-
|
|
933
|
+
# Get total count
|
|
934
|
+
count_result = await self.session.execute(count_query)
|
|
935
|
+
total = count_result.scalar() or 0
|
|
774
936
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
937
|
+
# Get paginated results
|
|
938
|
+
query = (
|
|
939
|
+
query.order_by(CrossAlertTriggerEvent.created_at.desc())
|
|
940
|
+
.offset(offset)
|
|
941
|
+
.limit(limit)
|
|
942
|
+
)
|
|
943
|
+
result = await self.session.execute(query)
|
|
944
|
+
events = result.scalars().all()
|
|
945
|
+
|
|
946
|
+
items = [self._event_to_dict(event) for event in events]
|
|
947
|
+
return items, total
|
|
778
948
|
|
|
779
949
|
async def get_summary(self) -> dict[str, Any]:
|
|
780
|
-
"""Get cross-alert summary statistics.
|
|
950
|
+
"""Get cross-alert summary statistics from database.
|
|
781
951
|
|
|
782
952
|
Returns:
|
|
783
953
|
Summary dictionary.
|
|
784
954
|
"""
|
|
955
|
+
from truthound_dashboard.db.models import (
|
|
956
|
+
CrossAlertConfig,
|
|
957
|
+
CrossAlertCorrelation,
|
|
958
|
+
CrossAlertTriggerEvent,
|
|
959
|
+
Source,
|
|
960
|
+
)
|
|
961
|
+
|
|
785
962
|
now = datetime.utcnow()
|
|
786
963
|
last_24h = now - timedelta(hours=24)
|
|
787
964
|
|
|
788
965
|
# Count correlations by strength
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
966
|
+
strong_result = await self.session.execute(
|
|
967
|
+
select(func.count(CrossAlertCorrelation.id)).where(
|
|
968
|
+
CrossAlertCorrelation.correlation_strength == "strong"
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
strong = strong_result.scalar() or 0
|
|
972
|
+
|
|
973
|
+
moderate_result = await self.session.execute(
|
|
974
|
+
select(func.count(CrossAlertCorrelation.id)).where(
|
|
975
|
+
CrossAlertCorrelation.correlation_strength == "moderate"
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
moderate = moderate_result.scalar() or 0
|
|
979
|
+
|
|
980
|
+
weak_result = await self.session.execute(
|
|
981
|
+
select(func.count(CrossAlertCorrelation.id)).where(
|
|
982
|
+
CrossAlertCorrelation.correlation_strength == "weak"
|
|
983
|
+
)
|
|
984
|
+
)
|
|
985
|
+
weak = weak_result.scalar() or 0
|
|
986
|
+
|
|
987
|
+
total_correlations = strong + moderate + weak
|
|
792
988
|
|
|
793
989
|
# Recent activity
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
990
|
+
recent_corr_result = await self.session.execute(
|
|
991
|
+
select(func.count(CrossAlertCorrelation.id)).where(
|
|
992
|
+
CrossAlertCorrelation.created_at >= last_24h
|
|
993
|
+
)
|
|
797
994
|
)
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
995
|
+
recent_correlations = recent_corr_result.scalar() or 0
|
|
996
|
+
|
|
997
|
+
recent_trigger_result = await self.session.execute(
|
|
998
|
+
select(func.count(CrossAlertTriggerEvent.id)).where(
|
|
999
|
+
CrossAlertTriggerEvent.created_at >= last_24h
|
|
1000
|
+
)
|
|
801
1001
|
)
|
|
1002
|
+
recent_triggers = recent_trigger_result.scalar() or 0
|
|
802
1003
|
|
|
803
1004
|
# Trigger counts by type
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1005
|
+
anomaly_to_drift_result = await self.session.execute(
|
|
1006
|
+
select(func.count(CrossAlertTriggerEvent.id)).where(
|
|
1007
|
+
CrossAlertTriggerEvent.trigger_type == "anomaly_to_drift"
|
|
1008
|
+
)
|
|
807
1009
|
)
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1010
|
+
anomaly_to_drift = anomaly_to_drift_result.scalar() or 0
|
|
1011
|
+
|
|
1012
|
+
drift_to_anomaly_result = await self.session.execute(
|
|
1013
|
+
select(func.count(CrossAlertTriggerEvent.id)).where(
|
|
1014
|
+
CrossAlertTriggerEvent.trigger_type == "drift_to_anomaly"
|
|
1015
|
+
)
|
|
811
1016
|
)
|
|
1017
|
+
drift_to_anomaly = drift_to_anomaly_result.scalar() or 0
|
|
812
1018
|
|
|
813
1019
|
# Top affected sources
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1020
|
+
top_sources_result = await self.session.execute(
|
|
1021
|
+
select(
|
|
1022
|
+
CrossAlertCorrelation.source_id,
|
|
1023
|
+
func.count(CrossAlertCorrelation.id).label("count"),
|
|
1024
|
+
)
|
|
1025
|
+
.group_by(CrossAlertCorrelation.source_id)
|
|
1026
|
+
.order_by(func.count(CrossAlertCorrelation.id).desc())
|
|
1027
|
+
.limit(5)
|
|
1028
|
+
)
|
|
1029
|
+
top_sources_raw = top_sources_result.all()
|
|
1030
|
+
|
|
1031
|
+
top_sources = []
|
|
1032
|
+
for source_id, count in top_sources_raw:
|
|
1033
|
+
source_result = await self.session.execute(
|
|
1034
|
+
select(Source).where(Source.id == source_id)
|
|
1035
|
+
)
|
|
1036
|
+
source = source_result.scalar_one_or_none()
|
|
1037
|
+
top_sources.append({
|
|
1038
|
+
"source_id": source_id,
|
|
1039
|
+
"source_name": source.name if source else None,
|
|
1040
|
+
"count": count,
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
# Get global config for enabled status
|
|
1044
|
+
global_config = await self.get_config(None)
|
|
825
1045
|
|
|
826
1046
|
return {
|
|
827
|
-
"total_correlations":
|
|
1047
|
+
"total_correlations": total_correlations,
|
|
828
1048
|
"strong_correlations": strong,
|
|
829
1049
|
"moderate_correlations": moderate,
|
|
830
1050
|
"weak_correlations": weak,
|
|
831
1051
|
"recent_correlations_24h": recent_correlations,
|
|
832
1052
|
"recent_auto_triggers_24h": recent_triggers,
|
|
833
1053
|
"top_affected_sources": top_sources,
|
|
834
|
-
"auto_trigger_enabled":
|
|
1054
|
+
"auto_trigger_enabled": global_config.get("enabled", True),
|
|
835
1055
|
"anomaly_to_drift_triggers": anomaly_to_drift,
|
|
836
1056
|
"drift_to_anomaly_triggers": drift_to_anomaly,
|
|
837
1057
|
}
|