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,609 @@
|
|
|
1
|
+
"""Trigger evaluator implementations.
|
|
2
|
+
|
|
3
|
+
Provides concrete implementations for all trigger types:
|
|
4
|
+
- CronTrigger: Cron expression based scheduling
|
|
5
|
+
- IntervalTrigger: Fixed time interval scheduling
|
|
6
|
+
- DataChangeTrigger: Profile-based change detection
|
|
7
|
+
- CompositeTrigger: Combine multiple triggers
|
|
8
|
+
- EventTrigger: Respond to system events
|
|
9
|
+
- ManualTrigger: API-only execution
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from apscheduler.triggers.cron import CronTrigger as APCronTrigger
|
|
19
|
+
from apscheduler.triggers.interval import IntervalTrigger as APIntervalTrigger
|
|
20
|
+
|
|
21
|
+
from .base import (
|
|
22
|
+
BaseTrigger,
|
|
23
|
+
TriggerContext,
|
|
24
|
+
TriggerEvaluation,
|
|
25
|
+
TriggerRegistry,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@TriggerRegistry.register("cron")
|
|
32
|
+
class CronTrigger(BaseTrigger):
|
|
33
|
+
"""Cron expression based trigger.
|
|
34
|
+
|
|
35
|
+
Uses standard cron format: minute hour day month weekday
|
|
36
|
+
|
|
37
|
+
Config:
|
|
38
|
+
expression: Cron expression string
|
|
39
|
+
timezone: Optional timezone (default: UTC)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def _validate_config(self) -> None:
|
|
43
|
+
"""Validate cron configuration."""
|
|
44
|
+
expression = self.config.get("expression")
|
|
45
|
+
if not expression:
|
|
46
|
+
raise ValueError("Cron trigger requires 'expression' field")
|
|
47
|
+
|
|
48
|
+
# Validate by attempting to parse
|
|
49
|
+
try:
|
|
50
|
+
APCronTrigger.from_crontab(expression)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise ValueError(f"Invalid cron expression: {e}")
|
|
53
|
+
|
|
54
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
55
|
+
"""Evaluate if cron trigger should fire.
|
|
56
|
+
|
|
57
|
+
For cron triggers, we check if we're past the scheduled time
|
|
58
|
+
since the last run.
|
|
59
|
+
"""
|
|
60
|
+
expression = self.config.get("expression")
|
|
61
|
+
trigger = APCronTrigger.from_crontab(expression)
|
|
62
|
+
|
|
63
|
+
# Get next fire time from last run (or from epoch if never run)
|
|
64
|
+
base_time = context.last_run_at or datetime(2000, 1, 1)
|
|
65
|
+
next_fire = trigger.get_next_fire_time(None, base_time)
|
|
66
|
+
|
|
67
|
+
if next_fire is None:
|
|
68
|
+
return TriggerEvaluation(
|
|
69
|
+
should_trigger=False,
|
|
70
|
+
reason="No upcoming scheduled time",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
should_trigger = context.current_time >= next_fire
|
|
74
|
+
|
|
75
|
+
return TriggerEvaluation(
|
|
76
|
+
should_trigger=should_trigger,
|
|
77
|
+
reason=(
|
|
78
|
+
f"Scheduled time reached ({next_fire.isoformat()})"
|
|
79
|
+
if should_trigger
|
|
80
|
+
else f"Waiting for scheduled time ({next_fire.isoformat()})"
|
|
81
|
+
),
|
|
82
|
+
next_evaluation_at=next_fire if not should_trigger else None,
|
|
83
|
+
details={
|
|
84
|
+
"expression": expression,
|
|
85
|
+
"next_fire_time": next_fire.isoformat(),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get_next_evaluation_time(
|
|
90
|
+
self, context: TriggerContext
|
|
91
|
+
) -> datetime | None:
|
|
92
|
+
"""Get next cron fire time."""
|
|
93
|
+
expression = self.config.get("expression")
|
|
94
|
+
try:
|
|
95
|
+
trigger = APCronTrigger.from_crontab(expression)
|
|
96
|
+
return trigger.get_next_fire_time(None, context.current_time)
|
|
97
|
+
except Exception:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def get_description(self) -> str:
|
|
101
|
+
"""Get human-readable description."""
|
|
102
|
+
return f"Cron: {self.config.get('expression', 'not configured')}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@TriggerRegistry.register("interval")
|
|
106
|
+
class IntervalTrigger(BaseTrigger):
|
|
107
|
+
"""Fixed time interval trigger.
|
|
108
|
+
|
|
109
|
+
Config:
|
|
110
|
+
seconds: Interval in seconds
|
|
111
|
+
minutes: Interval in minutes
|
|
112
|
+
hours: Interval in hours
|
|
113
|
+
days: Interval in days
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def _validate_config(self) -> None:
|
|
117
|
+
"""Validate interval configuration."""
|
|
118
|
+
has_interval = any(
|
|
119
|
+
self.config.get(key)
|
|
120
|
+
for key in ["seconds", "minutes", "hours", "days"]
|
|
121
|
+
)
|
|
122
|
+
if not has_interval:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"Interval trigger requires at least one of: seconds, minutes, hours, days"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _get_total_seconds(self) -> int:
|
|
128
|
+
"""Calculate total interval in seconds."""
|
|
129
|
+
total = 0
|
|
130
|
+
total += self.config.get("seconds", 0)
|
|
131
|
+
total += self.config.get("minutes", 0) * 60
|
|
132
|
+
total += self.config.get("hours", 0) * 3600
|
|
133
|
+
total += self.config.get("days", 0) * 86400
|
|
134
|
+
return total or 3600 # Default to 1 hour
|
|
135
|
+
|
|
136
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
137
|
+
"""Evaluate if interval trigger should fire."""
|
|
138
|
+
interval_seconds = self._get_total_seconds()
|
|
139
|
+
|
|
140
|
+
# If never run, trigger immediately
|
|
141
|
+
if context.last_run_at is None:
|
|
142
|
+
return TriggerEvaluation(
|
|
143
|
+
should_trigger=True,
|
|
144
|
+
reason="First run (never executed before)",
|
|
145
|
+
details={"interval_seconds": interval_seconds},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Calculate next run time
|
|
149
|
+
next_run = context.last_run_at + timedelta(seconds=interval_seconds)
|
|
150
|
+
should_trigger = context.current_time >= next_run
|
|
151
|
+
|
|
152
|
+
return TriggerEvaluation(
|
|
153
|
+
should_trigger=should_trigger,
|
|
154
|
+
reason=(
|
|
155
|
+
f"Interval elapsed ({interval_seconds}s since last run)"
|
|
156
|
+
if should_trigger
|
|
157
|
+
else f"Waiting for interval ({(next_run - context.current_time).seconds}s remaining)"
|
|
158
|
+
),
|
|
159
|
+
next_evaluation_at=next_run if not should_trigger else None,
|
|
160
|
+
details={
|
|
161
|
+
"interval_seconds": interval_seconds,
|
|
162
|
+
"next_run": next_run.isoformat(),
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def get_next_evaluation_time(
|
|
167
|
+
self, context: TriggerContext
|
|
168
|
+
) -> datetime | None:
|
|
169
|
+
"""Get next interval fire time."""
|
|
170
|
+
if context.last_run_at is None:
|
|
171
|
+
return context.current_time
|
|
172
|
+
return context.last_run_at + timedelta(seconds=self._get_total_seconds())
|
|
173
|
+
|
|
174
|
+
def get_description(self) -> str:
|
|
175
|
+
"""Get human-readable description."""
|
|
176
|
+
parts = []
|
|
177
|
+
if self.config.get("days"):
|
|
178
|
+
parts.append(f"{self.config['days']}d")
|
|
179
|
+
if self.config.get("hours"):
|
|
180
|
+
parts.append(f"{self.config['hours']}h")
|
|
181
|
+
if self.config.get("minutes"):
|
|
182
|
+
parts.append(f"{self.config['minutes']}m")
|
|
183
|
+
if self.config.get("seconds"):
|
|
184
|
+
parts.append(f"{self.config['seconds']}s")
|
|
185
|
+
return f"Every {' '.join(parts)}" if parts else "Interval: not configured"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@TriggerRegistry.register("data_change")
|
|
189
|
+
class DataChangeTrigger(BaseTrigger):
|
|
190
|
+
"""Data change detection trigger.
|
|
191
|
+
|
|
192
|
+
Triggers when profile metrics change by more than a threshold.
|
|
193
|
+
|
|
194
|
+
Config:
|
|
195
|
+
change_threshold: Minimum change percentage (0.0-1.0)
|
|
196
|
+
metrics: List of metrics to monitor
|
|
197
|
+
check_interval_minutes: How often to check
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
DEFAULT_METRICS = ["row_count", "null_percentage", "distinct_count"]
|
|
201
|
+
|
|
202
|
+
def _validate_config(self) -> None:
|
|
203
|
+
"""Validate data change configuration."""
|
|
204
|
+
threshold = self.config.get("change_threshold", 0.05)
|
|
205
|
+
if not 0.0 <= threshold <= 1.0:
|
|
206
|
+
raise ValueError("change_threshold must be between 0.0 and 1.0")
|
|
207
|
+
|
|
208
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
209
|
+
"""Evaluate if data has changed enough to trigger.
|
|
210
|
+
|
|
211
|
+
Compares current profile against baseline and checks if
|
|
212
|
+
any monitored metrics have changed beyond the threshold.
|
|
213
|
+
"""
|
|
214
|
+
threshold = self.config.get("change_threshold", 0.05)
|
|
215
|
+
metrics = self.config.get("metrics", self.DEFAULT_METRICS)
|
|
216
|
+
|
|
217
|
+
# Need both profiles to compare
|
|
218
|
+
if context.profile_data is None:
|
|
219
|
+
return TriggerEvaluation(
|
|
220
|
+
should_trigger=False,
|
|
221
|
+
reason="No current profile data available",
|
|
222
|
+
details={"error": "missing_current_profile"},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if context.baseline_profile is None:
|
|
226
|
+
# First profile - trigger to establish baseline
|
|
227
|
+
return TriggerEvaluation(
|
|
228
|
+
should_trigger=True,
|
|
229
|
+
reason="First profile (no baseline to compare)",
|
|
230
|
+
details={"reason": "no_baseline"},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Calculate changes for each metric
|
|
234
|
+
changes = {}
|
|
235
|
+
max_change = 0.0
|
|
236
|
+
triggered_metrics = []
|
|
237
|
+
|
|
238
|
+
for metric in metrics:
|
|
239
|
+
current = self._get_metric_value(context.profile_data, metric)
|
|
240
|
+
baseline = self._get_metric_value(context.baseline_profile, metric)
|
|
241
|
+
|
|
242
|
+
if current is None or baseline is None:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Calculate percentage change
|
|
246
|
+
if baseline == 0:
|
|
247
|
+
change = 1.0 if current != 0 else 0.0
|
|
248
|
+
else:
|
|
249
|
+
change = abs(current - baseline) / abs(baseline)
|
|
250
|
+
|
|
251
|
+
changes[metric] = {
|
|
252
|
+
"current": current,
|
|
253
|
+
"baseline": baseline,
|
|
254
|
+
"change": change,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if change >= threshold:
|
|
258
|
+
triggered_metrics.append(metric)
|
|
259
|
+
max_change = max(max_change, change)
|
|
260
|
+
|
|
261
|
+
should_trigger = len(triggered_metrics) > 0
|
|
262
|
+
|
|
263
|
+
return TriggerEvaluation(
|
|
264
|
+
should_trigger=should_trigger,
|
|
265
|
+
reason=(
|
|
266
|
+
f"Data change detected: {', '.join(triggered_metrics)} changed by >= {threshold*100:.0f}%"
|
|
267
|
+
if should_trigger
|
|
268
|
+
else f"No significant changes (max: {max_change*100:.1f}%, threshold: {threshold*100:.0f}%)"
|
|
269
|
+
),
|
|
270
|
+
details={
|
|
271
|
+
"threshold": threshold,
|
|
272
|
+
"max_change": max_change,
|
|
273
|
+
"changes": changes,
|
|
274
|
+
"triggered_metrics": triggered_metrics,
|
|
275
|
+
},
|
|
276
|
+
confidence=max_change if should_trigger else 1.0 - max_change,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _get_metric_value(
|
|
280
|
+
self, profile: dict[str, Any], metric: str
|
|
281
|
+
) -> float | None:
|
|
282
|
+
"""Extract metric value from profile data."""
|
|
283
|
+
# Handle top-level metrics
|
|
284
|
+
if metric in profile:
|
|
285
|
+
return float(profile[metric])
|
|
286
|
+
|
|
287
|
+
# Handle nested column metrics
|
|
288
|
+
if "columns" in profile:
|
|
289
|
+
for col in profile["columns"]:
|
|
290
|
+
if metric in col:
|
|
291
|
+
return float(col[metric])
|
|
292
|
+
|
|
293
|
+
# Handle summary metrics
|
|
294
|
+
if "summary" in profile and metric in profile["summary"]:
|
|
295
|
+
return float(profile["summary"][metric])
|
|
296
|
+
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def get_description(self) -> str:
|
|
300
|
+
"""Get human-readable description."""
|
|
301
|
+
threshold = self.config.get("change_threshold", 0.05)
|
|
302
|
+
return f"Data change >= {threshold * 100:.0f}%"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@TriggerRegistry.register("composite")
|
|
306
|
+
class CompositeTrigger(BaseTrigger):
|
|
307
|
+
"""Composite trigger that combines multiple triggers.
|
|
308
|
+
|
|
309
|
+
Config:
|
|
310
|
+
operator: "and" or "or"
|
|
311
|
+
triggers: List of trigger configurations
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def _validate_config(self) -> None:
|
|
315
|
+
"""Validate composite configuration."""
|
|
316
|
+
operator = self.config.get("operator", "and").lower()
|
|
317
|
+
if operator not in ("and", "or"):
|
|
318
|
+
raise ValueError("Composite operator must be 'and' or 'or'")
|
|
319
|
+
|
|
320
|
+
triggers = self.config.get("triggers", [])
|
|
321
|
+
if len(triggers) < 2:
|
|
322
|
+
raise ValueError("Composite trigger requires at least 2 sub-triggers")
|
|
323
|
+
|
|
324
|
+
# Validate sub-triggers don't contain composites (prevent nesting)
|
|
325
|
+
for trigger in triggers:
|
|
326
|
+
if trigger.get("type") == "composite":
|
|
327
|
+
raise ValueError("Nested composite triggers are not supported")
|
|
328
|
+
|
|
329
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
330
|
+
"""Evaluate composite trigger based on operator."""
|
|
331
|
+
operator = self.config.get("operator", "and").lower()
|
|
332
|
+
trigger_configs = self.config.get("triggers", [])
|
|
333
|
+
|
|
334
|
+
sub_results = []
|
|
335
|
+
|
|
336
|
+
for trigger_config in trigger_configs:
|
|
337
|
+
trigger_type = trigger_config.get("type")
|
|
338
|
+
trigger = TriggerRegistry.create(trigger_type, trigger_config)
|
|
339
|
+
|
|
340
|
+
if trigger is None:
|
|
341
|
+
sub_results.append({
|
|
342
|
+
"type": trigger_type,
|
|
343
|
+
"should_trigger": False,
|
|
344
|
+
"reason": f"Unknown trigger type: {trigger_type}",
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
result = await trigger.evaluate(context)
|
|
349
|
+
sub_results.append({
|
|
350
|
+
"type": trigger_type,
|
|
351
|
+
"should_trigger": result.should_trigger,
|
|
352
|
+
"reason": result.reason,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
# Apply operator logic
|
|
356
|
+
if operator == "and":
|
|
357
|
+
should_trigger = all(r["should_trigger"] for r in sub_results)
|
|
358
|
+
reason = "All conditions met" if should_trigger else "Not all conditions met"
|
|
359
|
+
else: # or
|
|
360
|
+
should_trigger = any(r["should_trigger"] for r in sub_results)
|
|
361
|
+
reason = "At least one condition met" if should_trigger else "No conditions met"
|
|
362
|
+
|
|
363
|
+
triggered_count = sum(1 for r in sub_results if r["should_trigger"])
|
|
364
|
+
|
|
365
|
+
return TriggerEvaluation(
|
|
366
|
+
should_trigger=should_trigger,
|
|
367
|
+
reason=f"{reason} ({triggered_count}/{len(sub_results)} triggers)",
|
|
368
|
+
details={
|
|
369
|
+
"operator": operator,
|
|
370
|
+
"sub_results": sub_results,
|
|
371
|
+
"triggered_count": triggered_count,
|
|
372
|
+
"total_count": len(sub_results),
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def get_description(self) -> str:
|
|
377
|
+
"""Get human-readable description."""
|
|
378
|
+
operator = self.config.get("operator", "and").upper()
|
|
379
|
+
triggers = self.config.get("triggers", [])
|
|
380
|
+
return f"Composite: {len(triggers)} triggers ({operator})"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@TriggerRegistry.register("event")
|
|
384
|
+
class EventTrigger(BaseTrigger):
|
|
385
|
+
"""Event-based trigger.
|
|
386
|
+
|
|
387
|
+
Triggers in response to specific system events.
|
|
388
|
+
|
|
389
|
+
Config:
|
|
390
|
+
event_types: List of event types to respond to
|
|
391
|
+
source_filter: Optional list of source IDs to filter
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
VALID_EVENTS = {
|
|
395
|
+
"validation_completed",
|
|
396
|
+
"validation_failed",
|
|
397
|
+
"schema_changed",
|
|
398
|
+
"drift_detected",
|
|
399
|
+
"profile_updated",
|
|
400
|
+
"source_created",
|
|
401
|
+
"source_updated",
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def _validate_config(self) -> None:
|
|
405
|
+
"""Validate event configuration."""
|
|
406
|
+
event_types = self.config.get("event_types", [])
|
|
407
|
+
if not event_types:
|
|
408
|
+
raise ValueError("Event trigger requires 'event_types' list")
|
|
409
|
+
|
|
410
|
+
invalid_events = set(event_types) - self.VALID_EVENTS
|
|
411
|
+
if invalid_events:
|
|
412
|
+
raise ValueError(f"Invalid event types: {invalid_events}")
|
|
413
|
+
|
|
414
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
415
|
+
"""Evaluate if event matches configured types."""
|
|
416
|
+
event_types = set(self.config.get("event_types", []))
|
|
417
|
+
source_filter = self.config.get("source_filter")
|
|
418
|
+
|
|
419
|
+
# Check if there's event data in context
|
|
420
|
+
if context.event_data is None:
|
|
421
|
+
return TriggerEvaluation(
|
|
422
|
+
should_trigger=False,
|
|
423
|
+
reason="No event data in context",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
event_type = context.event_data.get("type")
|
|
427
|
+
event_source = context.event_data.get("source_id")
|
|
428
|
+
|
|
429
|
+
# Check event type match
|
|
430
|
+
if event_type not in event_types:
|
|
431
|
+
return TriggerEvaluation(
|
|
432
|
+
should_trigger=False,
|
|
433
|
+
reason=f"Event type '{event_type}' not in configured types",
|
|
434
|
+
details={"event_type": event_type, "configured_types": list(event_types)},
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Check source filter if configured
|
|
438
|
+
if source_filter and event_source not in source_filter:
|
|
439
|
+
return TriggerEvaluation(
|
|
440
|
+
should_trigger=False,
|
|
441
|
+
reason=f"Event source '{event_source}' not in filter",
|
|
442
|
+
details={"event_source": event_source, "filter": source_filter},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return TriggerEvaluation(
|
|
446
|
+
should_trigger=True,
|
|
447
|
+
reason=f"Event '{event_type}' matched",
|
|
448
|
+
details={
|
|
449
|
+
"event_type": event_type,
|
|
450
|
+
"event_source": event_source,
|
|
451
|
+
"event_data": context.event_data,
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def get_description(self) -> str:
|
|
456
|
+
"""Get human-readable description."""
|
|
457
|
+
events = self.config.get("event_types", [])
|
|
458
|
+
return f"Events: {', '.join(events[:2])}{'...' if len(events) > 2 else ''}"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@TriggerRegistry.register("manual")
|
|
462
|
+
class ManualTrigger(BaseTrigger):
|
|
463
|
+
"""Manual-only trigger.
|
|
464
|
+
|
|
465
|
+
Only triggers when explicitly invoked via API.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
def _validate_config(self) -> None:
|
|
469
|
+
"""No validation needed for manual trigger."""
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
473
|
+
"""Manual triggers never auto-fire."""
|
|
474
|
+
# Check if manual trigger was requested via context
|
|
475
|
+
force_trigger = context.custom_data.get("force_trigger", False)
|
|
476
|
+
|
|
477
|
+
return TriggerEvaluation(
|
|
478
|
+
should_trigger=force_trigger,
|
|
479
|
+
reason=(
|
|
480
|
+
"Manually triggered via API"
|
|
481
|
+
if force_trigger
|
|
482
|
+
else "Manual trigger only (use API to execute)"
|
|
483
|
+
),
|
|
484
|
+
details={"manual_only": True},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def get_description(self) -> str:
|
|
488
|
+
"""Get human-readable description."""
|
|
489
|
+
return "Manual trigger only"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@TriggerRegistry.register("webhook")
|
|
493
|
+
class WebhookTrigger(BaseTrigger):
|
|
494
|
+
"""Webhook-based trigger.
|
|
495
|
+
|
|
496
|
+
Triggers when receiving webhook requests from external systems.
|
|
497
|
+
|
|
498
|
+
Config:
|
|
499
|
+
webhook_secret: Optional secret for HMAC validation
|
|
500
|
+
allowed_sources: List of allowed source identifiers
|
|
501
|
+
payload_filters: JSON path filters for payload matching
|
|
502
|
+
require_signature: Whether to require signature validation
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
def _validate_config(self) -> None:
|
|
506
|
+
"""Validate webhook configuration."""
|
|
507
|
+
# Webhook config is optional, but if require_signature is True,
|
|
508
|
+
# webhook_secret must be provided
|
|
509
|
+
if self.config.get("require_signature") and not self.config.get("webhook_secret"):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
"webhook_secret is required when require_signature is True"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
|
|
515
|
+
"""Evaluate if webhook trigger should fire.
|
|
516
|
+
|
|
517
|
+
Checks:
|
|
518
|
+
1. Webhook data exists in context
|
|
519
|
+
2. Source is in allowed_sources (if configured)
|
|
520
|
+
3. Payload matches filters (if configured)
|
|
521
|
+
4. Signature is valid (if required)
|
|
522
|
+
"""
|
|
523
|
+
# Check for webhook data in context
|
|
524
|
+
webhook_data = context.custom_data.get("webhook_data")
|
|
525
|
+
if webhook_data is None:
|
|
526
|
+
return TriggerEvaluation(
|
|
527
|
+
should_trigger=False,
|
|
528
|
+
reason="No webhook data in context",
|
|
529
|
+
details={"waiting_for_webhook": True},
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
source = webhook_data.get("source", "")
|
|
533
|
+
payload = webhook_data.get("payload", {})
|
|
534
|
+
signature_valid = webhook_data.get("signature_valid", True)
|
|
535
|
+
|
|
536
|
+
# Check signature if required
|
|
537
|
+
require_signature = self.config.get("require_signature", False)
|
|
538
|
+
if require_signature and not signature_valid:
|
|
539
|
+
return TriggerEvaluation(
|
|
540
|
+
should_trigger=False,
|
|
541
|
+
reason="Invalid webhook signature",
|
|
542
|
+
details={"error": "invalid_signature", "source": source},
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Check allowed sources
|
|
546
|
+
allowed_sources = self.config.get("allowed_sources")
|
|
547
|
+
if allowed_sources and source not in allowed_sources:
|
|
548
|
+
return TriggerEvaluation(
|
|
549
|
+
should_trigger=False,
|
|
550
|
+
reason=f"Source '{source}' not in allowed sources",
|
|
551
|
+
details={
|
|
552
|
+
"source": source,
|
|
553
|
+
"allowed_sources": allowed_sources,
|
|
554
|
+
},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Check payload filters
|
|
558
|
+
payload_filters = self.config.get("payload_filters", {})
|
|
559
|
+
if payload_filters:
|
|
560
|
+
filters_match = self._check_payload_filters(payload, payload_filters)
|
|
561
|
+
if not filters_match:
|
|
562
|
+
return TriggerEvaluation(
|
|
563
|
+
should_trigger=False,
|
|
564
|
+
reason="Payload does not match filters",
|
|
565
|
+
details={
|
|
566
|
+
"source": source,
|
|
567
|
+
"filters": payload_filters,
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
return TriggerEvaluation(
|
|
572
|
+
should_trigger=True,
|
|
573
|
+
reason=f"Webhook received from '{source}'",
|
|
574
|
+
details={
|
|
575
|
+
"source": source,
|
|
576
|
+
"event_type": webhook_data.get("event_type", "unknown"),
|
|
577
|
+
"payload_keys": list(payload.keys()) if payload else [],
|
|
578
|
+
},
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def _check_payload_filters(
|
|
582
|
+
self, payload: dict[str, Any], filters: dict[str, Any]
|
|
583
|
+
) -> bool:
|
|
584
|
+
"""Check if payload matches the configured filters.
|
|
585
|
+
|
|
586
|
+
Simple key-value matching. For nested paths, use dot notation.
|
|
587
|
+
"""
|
|
588
|
+
for key, expected_value in filters.items():
|
|
589
|
+
# Support dot notation for nested keys
|
|
590
|
+
parts = key.split(".")
|
|
591
|
+
value = payload
|
|
592
|
+
for part in parts:
|
|
593
|
+
if isinstance(value, dict):
|
|
594
|
+
value = value.get(part)
|
|
595
|
+
else:
|
|
596
|
+
value = None
|
|
597
|
+
break
|
|
598
|
+
|
|
599
|
+
if value != expected_value:
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
return True
|
|
603
|
+
|
|
604
|
+
def get_description(self) -> str:
|
|
605
|
+
"""Get human-readable description."""
|
|
606
|
+
allowed = self.config.get("allowed_sources", [])
|
|
607
|
+
if allowed:
|
|
608
|
+
return f"Webhook: {', '.join(allowed[:2])}{'...' if len(allowed) > 2 else ''}"
|
|
609
|
+
return "Webhook trigger"
|