truthound-dashboard 1.4.3__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.3.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.3.dist-info/METADATA +0 -505
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,29 +1,39 @@
|
|
|
1
|
-
"""Notification channel implementations.
|
|
2
|
-
|
|
3
|
-
This module provides
|
|
4
|
-
for
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
instantiated dynamically based on channel type.
|
|
1
|
+
"""Notification channel implementations using truthound library.
|
|
2
|
+
|
|
3
|
+
This module provides channel implementations that wrap truthound's
|
|
4
|
+
checkpoint.actions module for notification delivery.
|
|
5
|
+
|
|
6
|
+
Available channels (from truthound.checkpoint.actions):
|
|
7
|
+
- Slack: SlackNotification
|
|
8
|
+
- Email: EmailNotification
|
|
9
|
+
- Teams: TeamsNotification
|
|
10
|
+
- Discord: DiscordNotification
|
|
11
|
+
- Telegram: TelegramNotification
|
|
12
|
+
- PagerDuty: PagerDutyAction
|
|
13
|
+
- Webhook: WebhookAction
|
|
14
|
+
|
|
15
|
+
Each channel is registered with the ChannelRegistry and delegates
|
|
16
|
+
actual notification delivery to the corresponding truthound action.
|
|
18
17
|
"""
|
|
19
18
|
|
|
20
19
|
from __future__ import annotations
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
|
-
import
|
|
26
|
+
from truthound.checkpoint.actions import (
|
|
27
|
+
SlackNotification,
|
|
28
|
+
EmailNotification,
|
|
29
|
+
TeamsNotification,
|
|
30
|
+
DiscordNotification,
|
|
31
|
+
TelegramNotification,
|
|
32
|
+
PagerDutyAction,
|
|
33
|
+
OpsGenieAction,
|
|
34
|
+
WebhookAction,
|
|
35
|
+
GitHubAction,
|
|
36
|
+
)
|
|
27
37
|
|
|
28
38
|
from .base import (
|
|
29
39
|
BaseNotificationChannel,
|
|
@@ -38,22 +48,114 @@ from .events import (
|
|
|
38
48
|
ValidationFailedEvent,
|
|
39
49
|
)
|
|
40
50
|
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_checkpoint_result_mock(event: NotificationEvent | None) -> Any:
|
|
55
|
+
"""Build a mock CheckpointResult for truthound actions.
|
|
56
|
+
|
|
57
|
+
truthound actions expect a CheckpointResult object. We create a
|
|
58
|
+
minimal mock that provides the necessary attributes.
|
|
59
|
+
"""
|
|
60
|
+
@dataclass
|
|
61
|
+
class MockStatistics:
|
|
62
|
+
total_issues: int = 0
|
|
63
|
+
critical_issues: int = 0
|
|
64
|
+
high_issues: int = 0
|
|
65
|
+
medium_issues: int = 0
|
|
66
|
+
low_issues: int = 0
|
|
67
|
+
info_issues: int = 0
|
|
68
|
+
pass_rate: float = 100.0
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class MockValidationResult:
|
|
72
|
+
statistics: MockStatistics
|
|
73
|
+
error: str | None = None
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MockCheckpointResult:
|
|
77
|
+
checkpoint_name: str
|
|
78
|
+
run_id: str
|
|
79
|
+
status: str
|
|
80
|
+
data_asset: str
|
|
81
|
+
validation_result: MockValidationResult
|
|
82
|
+
duration_ms: float = 0.0
|
|
83
|
+
|
|
84
|
+
def summary(self) -> str:
|
|
85
|
+
return f"{self.checkpoint_name}: {self.status}"
|
|
86
|
+
|
|
87
|
+
if event is None:
|
|
88
|
+
return MockCheckpointResult(
|
|
89
|
+
checkpoint_name="test",
|
|
90
|
+
run_id="test-run",
|
|
91
|
+
status="success",
|
|
92
|
+
data_asset="test",
|
|
93
|
+
validation_result=MockValidationResult(statistics=MockStatistics()),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Extract data from event
|
|
97
|
+
data = event.data or {}
|
|
98
|
+
|
|
99
|
+
if isinstance(event, ValidationFailedEvent):
|
|
100
|
+
return MockCheckpointResult(
|
|
101
|
+
checkpoint_name=event.source_name or "validation",
|
|
102
|
+
run_id=data.get("validation_id", "unknown"),
|
|
103
|
+
status="failure",
|
|
104
|
+
data_asset=event.source_name or "unknown",
|
|
105
|
+
validation_result=MockValidationResult(
|
|
106
|
+
statistics=MockStatistics(
|
|
107
|
+
total_issues=data.get("total_issues", 0),
|
|
108
|
+
critical_issues=1 if data.get("has_critical") else 0,
|
|
109
|
+
high_issues=1 if data.get("has_high") else 0,
|
|
110
|
+
)
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
114
|
+
return MockCheckpointResult(
|
|
115
|
+
checkpoint_name=f"drift_{event.source_name or 'unknown'}",
|
|
116
|
+
run_id=data.get("comparison_id", "unknown"),
|
|
117
|
+
status="warning",
|
|
118
|
+
data_asset=event.source_name or "unknown",
|
|
119
|
+
validation_result=MockValidationResult(statistics=MockStatistics()),
|
|
120
|
+
)
|
|
121
|
+
elif isinstance(event, ScheduleFailedEvent):
|
|
122
|
+
return MockCheckpointResult(
|
|
123
|
+
checkpoint_name=data.get("schedule_name", "schedule"),
|
|
124
|
+
run_id=data.get("run_id", "unknown"),
|
|
125
|
+
status="error",
|
|
126
|
+
data_asset=event.source_name or "unknown",
|
|
127
|
+
validation_result=MockValidationResult(
|
|
128
|
+
statistics=MockStatistics(),
|
|
129
|
+
error=data.get("error_message"),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
return MockCheckpointResult(
|
|
134
|
+
checkpoint_name=event.event_type,
|
|
135
|
+
run_id="dashboard-event",
|
|
136
|
+
status="info",
|
|
137
|
+
data_asset=event.source_name or "unknown",
|
|
138
|
+
validation_result=MockValidationResult(statistics=MockStatistics()),
|
|
139
|
+
)
|
|
140
|
+
|
|
41
141
|
|
|
42
142
|
@ChannelRegistry.register("slack")
|
|
43
143
|
class SlackChannel(BaseNotificationChannel):
|
|
44
|
-
"""Slack notification channel using
|
|
144
|
+
"""Slack notification channel using truthound.checkpoint.actions.SlackNotification.
|
|
45
145
|
|
|
46
146
|
Configuration:
|
|
47
|
-
webhook_url: Slack incoming webhook URL
|
|
147
|
+
webhook_url: Slack incoming webhook URL (required)
|
|
48
148
|
channel: Optional channel override
|
|
49
149
|
username: Optional username override
|
|
50
150
|
icon_emoji: Optional emoji icon (e.g., ":robot:")
|
|
151
|
+
mention_on_failure: List of user IDs to mention on failure
|
|
51
152
|
|
|
52
153
|
Example config:
|
|
53
154
|
{
|
|
54
155
|
"webhook_url": "https://hooks.slack.com/services/...",
|
|
55
156
|
"username": "Truthound Bot",
|
|
56
|
-
"icon_emoji": ":bar_chart:"
|
|
157
|
+
"icon_emoji": ":bar_chart:",
|
|
158
|
+
"channel": "#data-quality"
|
|
57
159
|
}
|
|
58
160
|
"""
|
|
59
161
|
|
|
@@ -83,156 +185,67 @@ class SlackChannel(BaseNotificationChannel):
|
|
|
83
185
|
"required": False,
|
|
84
186
|
"description": "Emoji icon (e.g., :robot:)",
|
|
85
187
|
},
|
|
188
|
+
"mention_on_failure": {
|
|
189
|
+
"type": "array",
|
|
190
|
+
"required": False,
|
|
191
|
+
"description": "User IDs to mention on failure",
|
|
192
|
+
},
|
|
86
193
|
}
|
|
87
194
|
|
|
195
|
+
def _create_action(self) -> SlackNotification:
|
|
196
|
+
"""Create truthound SlackNotification action."""
|
|
197
|
+
return SlackNotification(
|
|
198
|
+
webhook_url=self.config["webhook_url"],
|
|
199
|
+
channel=self.config.get("channel"),
|
|
200
|
+
username=self.config.get("username", "Truthound Dashboard"),
|
|
201
|
+
icon_emoji=self.config.get("icon_emoji", ":bar_chart:"),
|
|
202
|
+
mention_on_failure=self.config.get("mention_on_failure", []),
|
|
203
|
+
include_details=True,
|
|
204
|
+
notify_on="always", # Dashboard handles when to send
|
|
205
|
+
)
|
|
206
|
+
|
|
88
207
|
async def send(
|
|
89
208
|
self,
|
|
90
209
|
message: str,
|
|
91
210
|
event: NotificationEvent | None = None,
|
|
92
211
|
**kwargs: Any,
|
|
93
212
|
) -> bool:
|
|
94
|
-
"""Send notification to Slack.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
event: Optional triggering event.
|
|
99
|
-
**kwargs: Additional Slack message options.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
True if message was sent successfully.
|
|
103
|
-
"""
|
|
104
|
-
webhook_url = self.config["webhook_url"]
|
|
105
|
-
|
|
106
|
-
# Build payload
|
|
107
|
-
payload: dict[str, Any] = {
|
|
108
|
-
"text": message,
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
# Add blocks for rich formatting
|
|
112
|
-
blocks = self._build_blocks(message, event)
|
|
113
|
-
if blocks:
|
|
114
|
-
payload["blocks"] = blocks
|
|
115
|
-
|
|
116
|
-
# Add optional overrides
|
|
117
|
-
if self.config.get("channel"):
|
|
118
|
-
payload["channel"] = self.config["channel"]
|
|
119
|
-
if self.config.get("username"):
|
|
120
|
-
payload["username"] = self.config["username"]
|
|
121
|
-
if self.config.get("icon_emoji"):
|
|
122
|
-
payload["icon_emoji"] = self.config["icon_emoji"]
|
|
123
|
-
|
|
124
|
-
# Merge any additional kwargs
|
|
125
|
-
payload.update(kwargs)
|
|
126
|
-
|
|
127
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
128
|
-
response = await client.post(webhook_url, json=payload)
|
|
129
|
-
return response.status_code == 200
|
|
130
|
-
|
|
131
|
-
def _build_blocks(
|
|
132
|
-
self,
|
|
133
|
-
message: str,
|
|
134
|
-
event: NotificationEvent | None,
|
|
135
|
-
) -> list[dict[str, Any]]:
|
|
136
|
-
"""Build Slack blocks for rich message formatting."""
|
|
137
|
-
blocks = []
|
|
138
|
-
|
|
139
|
-
# Main message section
|
|
140
|
-
blocks.append(
|
|
141
|
-
{
|
|
142
|
-
"type": "section",
|
|
143
|
-
"text": {"type": "mrkdwn", "text": message},
|
|
144
|
-
}
|
|
145
|
-
)
|
|
213
|
+
"""Send notification to Slack using truthound library."""
|
|
214
|
+
try:
|
|
215
|
+
action = self._create_action()
|
|
216
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
146
217
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
context_elements.append(
|
|
155
|
-
{"type": "mrkdwn", "text": f"*ID:* `{event.validation_id[:8]}...`"}
|
|
156
|
-
)
|
|
157
|
-
blocks.append({"type": "context", "elements": context_elements})
|
|
158
|
-
|
|
159
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
160
|
-
context_elements = [
|
|
161
|
-
{
|
|
162
|
-
"type": "mrkdwn",
|
|
163
|
-
"text": f"*Drift:* {event.drifted_columns}/{event.total_columns} columns",
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
"type": "mrkdwn",
|
|
167
|
-
"text": f"*Percentage:* {event.drift_percentage:.1f}%",
|
|
168
|
-
},
|
|
169
|
-
]
|
|
170
|
-
blocks.append({"type": "context", "elements": context_elements})
|
|
171
|
-
|
|
172
|
-
return blocks
|
|
218
|
+
# truthound actions are sync, run in executor
|
|
219
|
+
loop = asyncio.get_event_loop()
|
|
220
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
221
|
+
return True
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.error(f"Slack notification failed: {e}")
|
|
224
|
+
return False
|
|
173
225
|
|
|
174
226
|
def format_message(self, event: NotificationEvent) -> str:
|
|
175
|
-
"""Format message for Slack
|
|
176
|
-
if isinstance(event,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
f"{emoji} *Validation Failed*\n\n"
|
|
180
|
-
f"*Source:* {event.source_name or 'Unknown'}\n"
|
|
181
|
-
f"*Severity:* {event.severity}\n"
|
|
182
|
-
f"*Total Issues:* {event.total_issues}"
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
186
|
-
return (
|
|
187
|
-
f":clock1: *Scheduled Validation Failed*\n\n"
|
|
188
|
-
f"*Schedule:* {event.schedule_name}\n"
|
|
189
|
-
f"*Source:* {event.source_name or 'Unknown'}\n"
|
|
190
|
-
f"*Error:* {event.error_message or 'Validation failed'}"
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
194
|
-
emoji = ":chart_with_upwards_trend:" if event.has_high_drift else ":chart_with_downwards_trend:"
|
|
195
|
-
return (
|
|
196
|
-
f"{emoji} *Drift Detected*\n\n"
|
|
197
|
-
f"*Baseline:* {event.baseline_source_name}\n"
|
|
198
|
-
f"*Current:* {event.current_source_name}\n"
|
|
199
|
-
f"*Drifted Columns:* {event.drifted_columns}/{event.total_columns} "
|
|
200
|
-
f"({event.drift_percentage:.1f}%)"
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
elif isinstance(event, TestNotificationEvent):
|
|
204
|
-
return (
|
|
205
|
-
f":white_check_mark: *Test Notification*\n\n"
|
|
206
|
-
f"This is a test from truthound-dashboard.\n"
|
|
207
|
-
f"Channel: {event.channel_name}"
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
return self._default_format(event)
|
|
227
|
+
"""Format message for Slack."""
|
|
228
|
+
if isinstance(event, TestNotificationEvent):
|
|
229
|
+
return f"✅ *Test Notification* from truthound-dashboard\nChannel: {event.channel_name}"
|
|
230
|
+
return super().format_message(event)
|
|
211
231
|
|
|
212
232
|
|
|
213
233
|
@ChannelRegistry.register("email")
|
|
214
234
|
class EmailChannel(BaseNotificationChannel):
|
|
215
|
-
"""Email notification channel using
|
|
235
|
+
"""Email notification channel using truthound.checkpoint.actions.EmailNotification.
|
|
216
236
|
|
|
217
237
|
Configuration:
|
|
218
|
-
smtp_host: SMTP server
|
|
238
|
+
smtp_host: SMTP server host (required)
|
|
219
239
|
smtp_port: SMTP server port (default: 587)
|
|
220
|
-
|
|
240
|
+
smtp_user: SMTP authentication user
|
|
221
241
|
smtp_password: SMTP authentication password
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"smtp_port": 587,
|
|
230
|
-
"smtp_username": "user@gmail.com",
|
|
231
|
-
"smtp_password": "app-password",
|
|
232
|
-
"from_email": "alerts@example.com",
|
|
233
|
-
"recipients": ["admin@example.com"],
|
|
234
|
-
"use_tls": true
|
|
235
|
-
}
|
|
242
|
+
use_tls: Use TLS (default: true)
|
|
243
|
+
use_ssl: Use SSL (default: false)
|
|
244
|
+
from_address: Sender email address (required)
|
|
245
|
+
to_addresses: List of recipient addresses (required)
|
|
246
|
+
cc_addresses: List of CC addresses
|
|
247
|
+
provider: Email provider (smtp, sendgrid, ses)
|
|
248
|
+
api_key: API key for SendGrid/SES
|
|
236
249
|
"""
|
|
237
250
|
|
|
238
251
|
channel_type = "email"
|
|
@@ -243,346 +256,169 @@ class EmailChannel(BaseNotificationChannel):
|
|
|
243
256
|
return {
|
|
244
257
|
"smtp_host": {
|
|
245
258
|
"type": "string",
|
|
246
|
-
"required":
|
|
247
|
-
"description": "SMTP server
|
|
259
|
+
"required": False,
|
|
260
|
+
"description": "SMTP server host",
|
|
248
261
|
},
|
|
249
262
|
"smtp_port": {
|
|
250
263
|
"type": "integer",
|
|
251
264
|
"required": False,
|
|
252
|
-
"
|
|
253
|
-
"description": "SMTP server port",
|
|
265
|
+
"description": "SMTP server port (default: 587)",
|
|
254
266
|
},
|
|
255
|
-
"
|
|
267
|
+
"smtp_user": {
|
|
256
268
|
"type": "string",
|
|
257
269
|
"required": False,
|
|
258
|
-
"description": "SMTP authentication
|
|
270
|
+
"description": "SMTP authentication user",
|
|
259
271
|
},
|
|
260
272
|
"smtp_password": {
|
|
261
273
|
"type": "string",
|
|
262
274
|
"required": False,
|
|
263
|
-
"secret": True,
|
|
264
275
|
"description": "SMTP authentication password",
|
|
265
276
|
},
|
|
266
|
-
"
|
|
277
|
+
"use_tls": {
|
|
278
|
+
"type": "boolean",
|
|
279
|
+
"required": False,
|
|
280
|
+
"description": "Use TLS encryption",
|
|
281
|
+
},
|
|
282
|
+
"use_ssl": {
|
|
283
|
+
"type": "boolean",
|
|
284
|
+
"required": False,
|
|
285
|
+
"description": "Use SSL encryption",
|
|
286
|
+
},
|
|
287
|
+
"from_address": {
|
|
267
288
|
"type": "string",
|
|
268
289
|
"required": True,
|
|
269
290
|
"description": "Sender email address",
|
|
270
291
|
},
|
|
271
|
-
"
|
|
292
|
+
"to_addresses": {
|
|
272
293
|
"type": "array",
|
|
273
294
|
"required": True,
|
|
274
|
-
"items": {"type": "string"},
|
|
275
295
|
"description": "List of recipient email addresses",
|
|
276
296
|
},
|
|
277
|
-
"
|
|
278
|
-
"type": "
|
|
297
|
+
"cc_addresses": {
|
|
298
|
+
"type": "array",
|
|
279
299
|
"required": False,
|
|
280
|
-
"
|
|
281
|
-
|
|
300
|
+
"description": "List of CC email addresses",
|
|
301
|
+
},
|
|
302
|
+
"provider": {
|
|
303
|
+
"type": "string",
|
|
304
|
+
"required": False,
|
|
305
|
+
"description": "Email provider: smtp, sendgrid, ses",
|
|
306
|
+
},
|
|
307
|
+
"api_key": {
|
|
308
|
+
"type": "string",
|
|
309
|
+
"required": False,
|
|
310
|
+
"description": "API key for SendGrid/SES",
|
|
282
311
|
},
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
def _create_action(self) -> EmailNotification:
|
|
315
|
+
"""Create truthound EmailNotification action."""
|
|
316
|
+
return EmailNotification(
|
|
317
|
+
smtp_host=self.config.get("smtp_host", "localhost"),
|
|
318
|
+
smtp_port=self.config.get("smtp_port", 587),
|
|
319
|
+
smtp_user=self.config.get("smtp_user"),
|
|
320
|
+
smtp_password=self.config.get("smtp_password"),
|
|
321
|
+
use_tls=self.config.get("use_tls", True),
|
|
322
|
+
use_ssl=self.config.get("use_ssl", False),
|
|
323
|
+
from_address=self.config["from_address"],
|
|
324
|
+
to_addresses=self.config["to_addresses"],
|
|
325
|
+
cc_addresses=self.config.get("cc_addresses", []),
|
|
326
|
+
provider=self.config.get("provider", "smtp"),
|
|
327
|
+
api_key=self.config.get("api_key"),
|
|
328
|
+
include_html=True,
|
|
329
|
+
notify_on="always",
|
|
330
|
+
)
|
|
331
|
+
|
|
285
332
|
async def send(
|
|
286
333
|
self,
|
|
287
334
|
message: str,
|
|
288
335
|
event: NotificationEvent | None = None,
|
|
289
|
-
subject: str | None = None,
|
|
290
336
|
**kwargs: Any,
|
|
291
337
|
) -> bool:
|
|
292
|
-
"""Send notification via
|
|
293
|
-
|
|
294
|
-
Args:
|
|
295
|
-
message: Email body text.
|
|
296
|
-
event: Optional triggering event (used for subject).
|
|
297
|
-
subject: Optional subject override.
|
|
298
|
-
**kwargs: Additional options.
|
|
299
|
-
|
|
300
|
-
Returns:
|
|
301
|
-
True if email was sent successfully.
|
|
302
|
-
"""
|
|
338
|
+
"""Send notification via Email using truthound library."""
|
|
303
339
|
try:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
raise ImportError(
|
|
307
|
-
"aiosmtplib is required for email notifications. "
|
|
308
|
-
"Install with: pip install aiosmtplib"
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
# Build subject
|
|
312
|
-
if subject is None:
|
|
313
|
-
subject = self._build_subject(event)
|
|
314
|
-
|
|
315
|
-
# Create message
|
|
316
|
-
msg = MIMEMultipart("alternative")
|
|
317
|
-
msg["Subject"] = subject
|
|
318
|
-
msg["From"] = self.config["from_email"]
|
|
319
|
-
msg["To"] = ", ".join(self.config["recipients"])
|
|
320
|
-
|
|
321
|
-
# Plain text version
|
|
322
|
-
text_part = MIMEText(message, "plain")
|
|
323
|
-
msg.attach(text_part)
|
|
324
|
-
|
|
325
|
-
# HTML version
|
|
326
|
-
html_content = self._build_html(message, event)
|
|
327
|
-
html_part = MIMEText(html_content, "html")
|
|
328
|
-
msg.attach(html_part)
|
|
329
|
-
|
|
330
|
-
# Send
|
|
331
|
-
await aiosmtplib.send(
|
|
332
|
-
msg,
|
|
333
|
-
hostname=self.config["smtp_host"],
|
|
334
|
-
port=self.config.get("smtp_port", 587),
|
|
335
|
-
username=self.config.get("smtp_username"),
|
|
336
|
-
password=self.config.get("smtp_password"),
|
|
337
|
-
use_tls=self.config.get("use_tls", True),
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
return True
|
|
341
|
-
|
|
342
|
-
def _build_subject(self, event: NotificationEvent | None) -> str:
|
|
343
|
-
"""Build email subject from event."""
|
|
344
|
-
if event is None:
|
|
345
|
-
return "[Truthound] Notification"
|
|
346
|
-
|
|
347
|
-
if isinstance(event, ValidationFailedEvent):
|
|
348
|
-
severity = event.severity
|
|
349
|
-
return f"[Truthound] {severity} - Validation Failed: {event.source_name}"
|
|
350
|
-
|
|
351
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
352
|
-
return f"[Truthound] Schedule Failed: {event.schedule_name}"
|
|
353
|
-
|
|
354
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
355
|
-
return f"[Truthound] Drift Detected: {event.baseline_source_name} → {event.current_source_name}"
|
|
356
|
-
|
|
357
|
-
elif isinstance(event, TestNotificationEvent):
|
|
358
|
-
return "[Truthound] Test Notification"
|
|
359
|
-
|
|
360
|
-
return f"[Truthound] {event.event_type}"
|
|
361
|
-
|
|
362
|
-
def _build_html(self, message: str, event: NotificationEvent | None) -> str:
|
|
363
|
-
"""Build HTML email body."""
|
|
364
|
-
# Convert newlines to <br> for simple HTML
|
|
365
|
-
html_message = message.replace("\n", "<br>")
|
|
366
|
-
|
|
367
|
-
return f"""
|
|
368
|
-
<!DOCTYPE html>
|
|
369
|
-
<html>
|
|
370
|
-
<head>
|
|
371
|
-
<style>
|
|
372
|
-
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
|
373
|
-
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
374
|
-
.header {{ background: #fd9e4b; color: white; padding: 15px; border-radius: 5px 5px 0 0; }}
|
|
375
|
-
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }}
|
|
376
|
-
.footer {{ font-size: 12px; color: #666; margin-top: 20px; }}
|
|
377
|
-
</style>
|
|
378
|
-
</head>
|
|
379
|
-
<body>
|
|
380
|
-
<div class="container">
|
|
381
|
-
<div class="header">
|
|
382
|
-
<h2 style="margin: 0;">Truthound Dashboard</h2>
|
|
383
|
-
</div>
|
|
384
|
-
<div class="content">
|
|
385
|
-
<p>{html_message}</p>
|
|
386
|
-
</div>
|
|
387
|
-
<div class="footer">
|
|
388
|
-
<p>This notification was sent by Truthound Dashboard.</p>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
</body>
|
|
392
|
-
</html>
|
|
393
|
-
"""
|
|
394
|
-
|
|
395
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
396
|
-
"""Format message for email (plain text)."""
|
|
397
|
-
if isinstance(event, ValidationFailedEvent):
|
|
398
|
-
return (
|
|
399
|
-
f"Validation Failed\n\n"
|
|
400
|
-
f"Source: {event.source_name or 'Unknown'}\n"
|
|
401
|
-
f"Severity: {event.severity}\n"
|
|
402
|
-
f"Total Issues: {event.total_issues}\n"
|
|
403
|
-
f"Validation ID: {event.validation_id}\n\n"
|
|
404
|
-
f"Please check the dashboard for details."
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
408
|
-
return (
|
|
409
|
-
f"Scheduled Validation Failed\n\n"
|
|
410
|
-
f"Schedule: {event.schedule_name}\n"
|
|
411
|
-
f"Source: {event.source_name or 'Unknown'}\n"
|
|
412
|
-
f"Error: {event.error_message or 'Validation failed'}\n\n"
|
|
413
|
-
f"Please check the dashboard for details."
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
417
|
-
return (
|
|
418
|
-
f"Drift Detected\n\n"
|
|
419
|
-
f"Baseline: {event.baseline_source_name}\n"
|
|
420
|
-
f"Current: {event.current_source_name}\n"
|
|
421
|
-
f"Drifted Columns: {event.drifted_columns}/{event.total_columns} "
|
|
422
|
-
f"({event.drift_percentage:.1f}%)\n\n"
|
|
423
|
-
f"Please check the dashboard for details."
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
elif isinstance(event, TestNotificationEvent):
|
|
427
|
-
return (
|
|
428
|
-
f"Test Notification\n\n"
|
|
429
|
-
f"This is a test notification from truthound-dashboard.\n"
|
|
430
|
-
f"Channel: {event.channel_name}\n\n"
|
|
431
|
-
f"If you received this, your email channel is configured correctly."
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
return self._default_format(event)
|
|
340
|
+
action = self._create_action()
|
|
341
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
435
342
|
|
|
343
|
+
loop = asyncio.get_event_loop()
|
|
344
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
345
|
+
return True
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.error(f"Email notification failed: {e}")
|
|
348
|
+
return False
|
|
436
349
|
|
|
437
|
-
@ChannelRegistry.register("webhook")
|
|
438
|
-
class WebhookChannel(BaseNotificationChannel):
|
|
439
|
-
"""Generic webhook notification channel.
|
|
440
350
|
|
|
441
|
-
|
|
351
|
+
@ChannelRegistry.register("teams")
|
|
352
|
+
class TeamsChannel(BaseNotificationChannel):
|
|
353
|
+
"""Microsoft Teams notification channel using truthound.checkpoint.actions.TeamsNotification.
|
|
442
354
|
|
|
443
355
|
Configuration:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
include_event_data: Whether to include full event data (default: True)
|
|
448
|
-
|
|
449
|
-
Example config:
|
|
450
|
-
{
|
|
451
|
-
"url": "https://example.com/webhook",
|
|
452
|
-
"method": "POST",
|
|
453
|
-
"headers": {
|
|
454
|
-
"Authorization": "Bearer token",
|
|
455
|
-
"X-Custom-Header": "value"
|
|
456
|
-
}
|
|
457
|
-
}
|
|
356
|
+
webhook_url: Teams incoming webhook URL (required)
|
|
357
|
+
channel: Channel name for display
|
|
358
|
+
include_details: Include detailed information
|
|
458
359
|
"""
|
|
459
360
|
|
|
460
|
-
channel_type = "
|
|
361
|
+
channel_type = "teams"
|
|
461
362
|
|
|
462
363
|
@classmethod
|
|
463
364
|
def get_config_schema(cls) -> dict[str, Any]:
|
|
464
|
-
"""Get
|
|
365
|
+
"""Get Teams channel configuration schema."""
|
|
465
366
|
return {
|
|
466
|
-
"
|
|
367
|
+
"webhook_url": {
|
|
467
368
|
"type": "string",
|
|
468
369
|
"required": True,
|
|
469
|
-
"description": "
|
|
370
|
+
"description": "Teams incoming webhook URL",
|
|
470
371
|
},
|
|
471
|
-
"
|
|
372
|
+
"channel": {
|
|
472
373
|
"type": "string",
|
|
473
374
|
"required": False,
|
|
474
|
-
"
|
|
475
|
-
"description": "HTTP method (GET, POST, PUT)",
|
|
476
|
-
},
|
|
477
|
-
"headers": {
|
|
478
|
-
"type": "object",
|
|
479
|
-
"required": False,
|
|
480
|
-
"description": "Custom HTTP headers",
|
|
375
|
+
"description": "Channel name for display",
|
|
481
376
|
},
|
|
482
|
-
"
|
|
377
|
+
"include_details": {
|
|
483
378
|
"type": "boolean",
|
|
484
379
|
"required": False,
|
|
485
|
-
"
|
|
486
|
-
"description": "Include full event data in payload",
|
|
380
|
+
"description": "Include detailed statistics",
|
|
487
381
|
},
|
|
488
382
|
}
|
|
489
383
|
|
|
384
|
+
def _create_action(self) -> TeamsNotification:
|
|
385
|
+
"""Create truthound TeamsNotification action."""
|
|
386
|
+
return TeamsNotification(
|
|
387
|
+
webhook_url=self.config["webhook_url"],
|
|
388
|
+
channel=self.config.get("channel"),
|
|
389
|
+
include_details=self.config.get("include_details", True),
|
|
390
|
+
notify_on="always",
|
|
391
|
+
)
|
|
392
|
+
|
|
490
393
|
async def send(
|
|
491
394
|
self,
|
|
492
395
|
message: str,
|
|
493
396
|
event: NotificationEvent | None = None,
|
|
494
|
-
payload: dict[str, Any] | None = None,
|
|
495
397
|
**kwargs: Any,
|
|
496
398
|
) -> bool:
|
|
497
|
-
"""Send notification
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
event: Optional triggering event.
|
|
502
|
-
payload: Optional custom payload (overrides default).
|
|
503
|
-
**kwargs: Additional options.
|
|
504
|
-
|
|
505
|
-
Returns:
|
|
506
|
-
True if webhook call was successful.
|
|
507
|
-
"""
|
|
508
|
-
url = self.config["url"]
|
|
509
|
-
method = self.config.get("method", "POST").upper()
|
|
510
|
-
headers = self.config.get("headers", {})
|
|
511
|
-
|
|
512
|
-
# Build payload
|
|
513
|
-
if payload is None:
|
|
514
|
-
payload = self._build_payload(message, event)
|
|
515
|
-
|
|
516
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
517
|
-
if method == "GET":
|
|
518
|
-
response = await client.get(url, headers=headers, params=payload)
|
|
519
|
-
elif method == "PUT":
|
|
520
|
-
response = await client.put(url, headers=headers, json=payload)
|
|
521
|
-
else: # Default to POST
|
|
522
|
-
response = await client.post(url, headers=headers, json=payload)
|
|
523
|
-
|
|
524
|
-
# Consider 2xx responses as success
|
|
525
|
-
return 200 <= response.status_code < 300
|
|
526
|
-
|
|
527
|
-
def _build_payload(
|
|
528
|
-
self,
|
|
529
|
-
message: str,
|
|
530
|
-
event: NotificationEvent | None,
|
|
531
|
-
) -> dict[str, Any]:
|
|
532
|
-
"""Build webhook payload."""
|
|
533
|
-
payload: dict[str, Any] = {
|
|
534
|
-
"message": message,
|
|
535
|
-
"channel_id": self.channel_id,
|
|
536
|
-
"channel_name": self.name,
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if event and self.config.get("include_event_data", True):
|
|
540
|
-
payload["event"] = event.to_dict()
|
|
541
|
-
payload["event_type"] = event.event_type
|
|
542
|
-
|
|
543
|
-
return payload
|
|
544
|
-
|
|
545
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
546
|
-
"""Format message for webhook (plain text)."""
|
|
547
|
-
if isinstance(event, ValidationFailedEvent):
|
|
548
|
-
return (
|
|
549
|
-
f"Validation failed for {event.source_name or 'Unknown'}: "
|
|
550
|
-
f"{event.total_issues} issues ({event.severity})"
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
554
|
-
return (
|
|
555
|
-
f"Schedule '{event.schedule_name}' failed for "
|
|
556
|
-
f"{event.source_name or 'Unknown'}"
|
|
557
|
-
)
|
|
558
|
-
|
|
559
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
560
|
-
return (
|
|
561
|
-
f"Drift detected: {event.drifted_columns}/{event.total_columns} columns "
|
|
562
|
-
f"({event.drift_percentage:.1f}%)"
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
elif isinstance(event, TestNotificationEvent):
|
|
566
|
-
return f"Test notification from truthound-dashboard"
|
|
399
|
+
"""Send notification to Teams using truthound library."""
|
|
400
|
+
try:
|
|
401
|
+
action = self._create_action()
|
|
402
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
567
403
|
|
|
568
|
-
|
|
404
|
+
loop = asyncio.get_event_loop()
|
|
405
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
406
|
+
return True
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error(f"Teams notification failed: {e}")
|
|
409
|
+
return False
|
|
569
410
|
|
|
570
411
|
|
|
571
412
|
@ChannelRegistry.register("discord")
|
|
572
413
|
class DiscordChannel(BaseNotificationChannel):
|
|
573
|
-
"""Discord notification channel using
|
|
414
|
+
"""Discord notification channel using truthound.checkpoint.actions.DiscordNotification.
|
|
574
415
|
|
|
575
416
|
Configuration:
|
|
576
|
-
webhook_url: Discord webhook URL
|
|
577
|
-
username:
|
|
578
|
-
avatar_url:
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
{
|
|
582
|
-
"webhook_url": "https://discord.com/api/webhooks/...",
|
|
583
|
-
"username": "Truthound Bot",
|
|
584
|
-
"avatar_url": "https://example.com/avatar.png"
|
|
585
|
-
}
|
|
417
|
+
webhook_url: Discord webhook URL (required)
|
|
418
|
+
username: Bot display name
|
|
419
|
+
avatar_url: Bot avatar URL
|
|
420
|
+
embed_color: Embed color (hex integer)
|
|
421
|
+
include_mentions: List of mentions (@here, role IDs, etc.)
|
|
586
422
|
"""
|
|
587
423
|
|
|
588
424
|
channel_type = "discord"
|
|
@@ -599,135 +435,64 @@ class DiscordChannel(BaseNotificationChannel):
|
|
|
599
435
|
"username": {
|
|
600
436
|
"type": "string",
|
|
601
437
|
"required": False,
|
|
602
|
-
"description": "Bot
|
|
438
|
+
"description": "Bot display name",
|
|
603
439
|
},
|
|
604
440
|
"avatar_url": {
|
|
605
441
|
"type": "string",
|
|
606
442
|
"required": False,
|
|
607
443
|
"description": "Bot avatar URL",
|
|
608
444
|
},
|
|
445
|
+
"embed_color": {
|
|
446
|
+
"type": "integer",
|
|
447
|
+
"required": False,
|
|
448
|
+
"description": "Embed color (hex integer)",
|
|
449
|
+
},
|
|
450
|
+
"include_mentions": {
|
|
451
|
+
"type": "array",
|
|
452
|
+
"required": False,
|
|
453
|
+
"description": "List of mentions (@here, role IDs, etc.)",
|
|
454
|
+
},
|
|
609
455
|
}
|
|
610
456
|
|
|
457
|
+
def _create_action(self) -> DiscordNotification:
|
|
458
|
+
"""Create truthound DiscordNotification action."""
|
|
459
|
+
return DiscordNotification(
|
|
460
|
+
webhook_url=self.config["webhook_url"],
|
|
461
|
+
username=self.config.get("username", "Truthound Bot"),
|
|
462
|
+
avatar_url=self.config.get("avatar_url"),
|
|
463
|
+
embed_color=self.config.get("embed_color"),
|
|
464
|
+
include_mentions=self.config.get("include_mentions", []),
|
|
465
|
+
notify_on="always",
|
|
466
|
+
)
|
|
467
|
+
|
|
611
468
|
async def send(
|
|
612
469
|
self,
|
|
613
470
|
message: str,
|
|
614
471
|
event: NotificationEvent | None = None,
|
|
615
472
|
**kwargs: Any,
|
|
616
473
|
) -> bool:
|
|
617
|
-
"""Send notification to Discord.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
event: Optional triggering event.
|
|
622
|
-
**kwargs: Additional Discord message options.
|
|
623
|
-
|
|
624
|
-
Returns:
|
|
625
|
-
True if message was sent successfully.
|
|
626
|
-
"""
|
|
627
|
-
webhook_url = self.config["webhook_url"]
|
|
628
|
-
|
|
629
|
-
# Build payload with embeds for rich formatting
|
|
630
|
-
payload: dict[str, Any] = {"content": message}
|
|
631
|
-
|
|
632
|
-
# Add embeds for specific events
|
|
633
|
-
embeds = self._build_embeds(event)
|
|
634
|
-
if embeds:
|
|
635
|
-
payload["embeds"] = embeds
|
|
636
|
-
|
|
637
|
-
# Add optional overrides
|
|
638
|
-
if self.config.get("username"):
|
|
639
|
-
payload["username"] = self.config["username"]
|
|
640
|
-
if self.config.get("avatar_url"):
|
|
641
|
-
payload["avatar_url"] = self.config["avatar_url"]
|
|
642
|
-
|
|
643
|
-
payload.update(kwargs)
|
|
644
|
-
|
|
645
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
646
|
-
response = await client.post(webhook_url, json=payload)
|
|
647
|
-
# Discord returns 204 on success
|
|
648
|
-
return response.status_code in (200, 204)
|
|
649
|
-
|
|
650
|
-
def _build_embeds(
|
|
651
|
-
self,
|
|
652
|
-
event: NotificationEvent | None,
|
|
653
|
-
) -> list[dict[str, Any]]:
|
|
654
|
-
"""Build Discord embeds for rich message formatting."""
|
|
655
|
-
if event is None:
|
|
656
|
-
return []
|
|
657
|
-
|
|
658
|
-
embed: dict[str, Any] = {
|
|
659
|
-
"timestamp": event.timestamp.isoformat(),
|
|
660
|
-
"footer": {"text": "Truthound Dashboard"},
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if isinstance(event, ValidationFailedEvent):
|
|
664
|
-
embed["title"] = "🚨 Validation Failed"
|
|
665
|
-
embed["color"] = 0xFF0000 if event.has_critical else 0xFFA500
|
|
666
|
-
embed["fields"] = [
|
|
667
|
-
{"name": "Source", "value": event.source_name or "Unknown", "inline": True},
|
|
668
|
-
{"name": "Severity", "value": event.severity, "inline": True},
|
|
669
|
-
{"name": "Issues", "value": str(event.total_issues), "inline": True},
|
|
670
|
-
]
|
|
671
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
672
|
-
embed["title"] = "📊 Drift Detected"
|
|
673
|
-
embed["color"] = 0xFFA500
|
|
674
|
-
embed["fields"] = [
|
|
675
|
-
{"name": "Baseline", "value": event.baseline_source_name, "inline": True},
|
|
676
|
-
{"name": "Current", "value": event.current_source_name, "inline": True},
|
|
677
|
-
{"name": "Drift", "value": f"{event.drift_percentage:.1f}%", "inline": True},
|
|
678
|
-
]
|
|
679
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
680
|
-
embed["title"] = "⏰ Schedule Failed"
|
|
681
|
-
embed["color"] = 0xFF6B6B
|
|
682
|
-
embed["fields"] = [
|
|
683
|
-
{"name": "Schedule", "value": event.schedule_name, "inline": True},
|
|
684
|
-
{"name": "Source", "value": event.source_name or "Unknown", "inline": True},
|
|
685
|
-
]
|
|
686
|
-
if event.error_message:
|
|
687
|
-
embed["fields"].append({"name": "Error", "value": event.error_message[:1024], "inline": False})
|
|
688
|
-
elif isinstance(event, TestNotificationEvent):
|
|
689
|
-
embed["title"] = "✅ Test Notification"
|
|
690
|
-
embed["color"] = 0x00FF00
|
|
691
|
-
embed["description"] = f"Channel: {event.channel_name}"
|
|
692
|
-
else:
|
|
693
|
-
return []
|
|
694
|
-
|
|
695
|
-
return [embed]
|
|
696
|
-
|
|
697
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
698
|
-
"""Format message for Discord."""
|
|
699
|
-
if isinstance(event, ValidationFailedEvent):
|
|
700
|
-
emoji = "🚨" if event.has_critical else "⚠️"
|
|
701
|
-
return f"{emoji} **Validation Failed** for `{event.source_name or 'Unknown'}`"
|
|
702
|
-
|
|
703
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
704
|
-
return f"⏰ **Schedule Failed**: `{event.schedule_name}`"
|
|
705
|
-
|
|
706
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
707
|
-
return f"📊 **Drift Detected**: {event.drift_percentage:.1f}% drift"
|
|
708
|
-
|
|
709
|
-
elif isinstance(event, TestNotificationEvent):
|
|
710
|
-
return f"✅ **Test Notification** from truthound-dashboard"
|
|
474
|
+
"""Send notification to Discord using truthound library."""
|
|
475
|
+
try:
|
|
476
|
+
action = self._create_action()
|
|
477
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
711
478
|
|
|
712
|
-
|
|
479
|
+
loop = asyncio.get_event_loop()
|
|
480
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
481
|
+
return True
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.error(f"Discord notification failed: {e}")
|
|
484
|
+
return False
|
|
713
485
|
|
|
714
486
|
|
|
715
487
|
@ChannelRegistry.register("telegram")
|
|
716
488
|
class TelegramChannel(BaseNotificationChannel):
|
|
717
|
-
"""Telegram notification channel using
|
|
489
|
+
"""Telegram notification channel using truthound.checkpoint.actions.TelegramNotification.
|
|
718
490
|
|
|
719
491
|
Configuration:
|
|
720
|
-
bot_token: Telegram Bot
|
|
721
|
-
chat_id:
|
|
722
|
-
parse_mode:
|
|
723
|
-
disable_notification:
|
|
724
|
-
|
|
725
|
-
Example config:
|
|
726
|
-
{
|
|
727
|
-
"bot_token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
|
728
|
-
"chat_id": "-1001234567890",
|
|
729
|
-
"parse_mode": "HTML"
|
|
730
|
-
}
|
|
492
|
+
bot_token: Telegram Bot Token (required)
|
|
493
|
+
chat_id: Channel/Group ID (required)
|
|
494
|
+
parse_mode: Parse mode (Markdown or HTML)
|
|
495
|
+
disable_notification: Silent notification
|
|
731
496
|
"""
|
|
732
497
|
|
|
733
498
|
channel_type = "telegram"
|
|
@@ -739,125 +504,69 @@ class TelegramChannel(BaseNotificationChannel):
|
|
|
739
504
|
"bot_token": {
|
|
740
505
|
"type": "string",
|
|
741
506
|
"required": True,
|
|
742
|
-
"
|
|
743
|
-
"description": "Telegram Bot API token",
|
|
507
|
+
"description": "Telegram Bot Token",
|
|
744
508
|
},
|
|
745
509
|
"chat_id": {
|
|
746
510
|
"type": "string",
|
|
747
511
|
"required": True,
|
|
748
|
-
"description": "
|
|
512
|
+
"description": "Channel/Group ID",
|
|
749
513
|
},
|
|
750
514
|
"parse_mode": {
|
|
751
515
|
"type": "string",
|
|
752
516
|
"required": False,
|
|
753
|
-
"
|
|
754
|
-
"description": "Message parse mode (HTML or MarkdownV2)",
|
|
517
|
+
"description": "Parse mode: Markdown or HTML",
|
|
755
518
|
},
|
|
756
519
|
"disable_notification": {
|
|
757
520
|
"type": "boolean",
|
|
758
521
|
"required": False,
|
|
759
|
-
"
|
|
760
|
-
"description": "Send message silently",
|
|
522
|
+
"description": "Silent notification",
|
|
761
523
|
},
|
|
762
524
|
}
|
|
763
525
|
|
|
526
|
+
def _create_action(self) -> TelegramNotification:
|
|
527
|
+
"""Create truthound TelegramNotification action."""
|
|
528
|
+
return TelegramNotification(
|
|
529
|
+
bot_token=self.config["bot_token"],
|
|
530
|
+
chat_id=self.config["chat_id"],
|
|
531
|
+
parse_mode=self.config.get("parse_mode", "Markdown"),
|
|
532
|
+
disable_notification=self.config.get("disable_notification", False),
|
|
533
|
+
notify_on="always",
|
|
534
|
+
)
|
|
535
|
+
|
|
764
536
|
async def send(
|
|
765
537
|
self,
|
|
766
538
|
message: str,
|
|
767
539
|
event: NotificationEvent | None = None,
|
|
768
540
|
**kwargs: Any,
|
|
769
541
|
) -> bool:
|
|
770
|
-
"""Send notification
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
event: Optional triggering event.
|
|
775
|
-
**kwargs: Additional Telegram API options.
|
|
776
|
-
|
|
777
|
-
Returns:
|
|
778
|
-
True if message was sent successfully.
|
|
779
|
-
"""
|
|
780
|
-
bot_token = self.config["bot_token"]
|
|
781
|
-
chat_id = self.config["chat_id"]
|
|
782
|
-
api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
783
|
-
|
|
784
|
-
payload: dict[str, Any] = {
|
|
785
|
-
"chat_id": chat_id,
|
|
786
|
-
"text": message,
|
|
787
|
-
"parse_mode": self.config.get("parse_mode", "HTML"),
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if self.config.get("disable_notification"):
|
|
791
|
-
payload["disable_notification"] = True
|
|
792
|
-
|
|
793
|
-
payload.update(kwargs)
|
|
542
|
+
"""Send notification to Telegram using truthound library."""
|
|
543
|
+
try:
|
|
544
|
+
action = self._create_action()
|
|
545
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
794
546
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
"""Format message for Telegram with HTML."""
|
|
802
|
-
if isinstance(event, ValidationFailedEvent):
|
|
803
|
-
emoji = "🚨" if event.has_critical else "⚠️"
|
|
804
|
-
return (
|
|
805
|
-
f"{emoji} <b>Validation Failed</b>\n\n"
|
|
806
|
-
f"<b>Source:</b> {event.source_name or 'Unknown'}\n"
|
|
807
|
-
f"<b>Severity:</b> {event.severity}\n"
|
|
808
|
-
f"<b>Issues:</b> {event.total_issues}"
|
|
809
|
-
)
|
|
810
|
-
|
|
811
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
812
|
-
return (
|
|
813
|
-
f"⏰ <b>Schedule Failed</b>\n\n"
|
|
814
|
-
f"<b>Schedule:</b> {event.schedule_name}\n"
|
|
815
|
-
f"<b>Source:</b> {event.source_name or 'Unknown'}\n"
|
|
816
|
-
f"<b>Error:</b> {event.error_message or 'Validation failed'}"
|
|
817
|
-
)
|
|
818
|
-
|
|
819
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
820
|
-
return (
|
|
821
|
-
f"📊 <b>Drift Detected</b>\n\n"
|
|
822
|
-
f"<b>Baseline:</b> {event.baseline_source_name}\n"
|
|
823
|
-
f"<b>Current:</b> {event.current_source_name}\n"
|
|
824
|
-
f"<b>Drift:</b> {event.drifted_columns}/{event.total_columns} columns "
|
|
825
|
-
f"({event.drift_percentage:.1f}%)"
|
|
826
|
-
)
|
|
827
|
-
|
|
828
|
-
elif isinstance(event, TestNotificationEvent):
|
|
829
|
-
return (
|
|
830
|
-
f"✅ <b>Test Notification</b>\n\n"
|
|
831
|
-
f"This is a test from truthound-dashboard.\n"
|
|
832
|
-
f"<b>Channel:</b> {event.channel_name}"
|
|
833
|
-
)
|
|
834
|
-
|
|
835
|
-
return self._default_format(event)
|
|
547
|
+
loop = asyncio.get_event_loop()
|
|
548
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
549
|
+
return True
|
|
550
|
+
except Exception as e:
|
|
551
|
+
logger.error(f"Telegram notification failed: {e}")
|
|
552
|
+
return False
|
|
836
553
|
|
|
837
554
|
|
|
838
555
|
@ChannelRegistry.register("pagerduty")
|
|
839
556
|
class PagerDutyChannel(BaseNotificationChannel):
|
|
840
|
-
"""PagerDuty notification channel using
|
|
557
|
+
"""PagerDuty notification channel using truthound.checkpoint.actions.PagerDutyAction.
|
|
841
558
|
|
|
842
559
|
Configuration:
|
|
843
|
-
routing_key: PagerDuty Events API v2 routing
|
|
844
|
-
severity:
|
|
845
|
-
component:
|
|
846
|
-
group:
|
|
847
|
-
class_type:
|
|
848
|
-
|
|
849
|
-
Example config:
|
|
850
|
-
{
|
|
851
|
-
"routing_key": "your-32-char-routing-key",
|
|
852
|
-
"severity": "error",
|
|
853
|
-
"component": "data-quality"
|
|
854
|
-
}
|
|
560
|
+
routing_key: PagerDuty Events API v2 routing key (required)
|
|
561
|
+
severity: Alert severity (critical, error, warning, info)
|
|
562
|
+
component: Affected component name
|
|
563
|
+
group: Alert grouping key
|
|
564
|
+
class_type: Alert class
|
|
565
|
+
custom_details: Additional details to include
|
|
855
566
|
"""
|
|
856
567
|
|
|
857
568
|
channel_type = "pagerduty"
|
|
858
569
|
|
|
859
|
-
EVENTS_API_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
860
|
-
|
|
861
570
|
@classmethod
|
|
862
571
|
def get_config_schema(cls) -> dict[str, Any]:
|
|
863
572
|
"""Get PagerDuty channel configuration schema."""
|
|
@@ -865,519 +574,240 @@ class PagerDutyChannel(BaseNotificationChannel):
|
|
|
865
574
|
"routing_key": {
|
|
866
575
|
"type": "string",
|
|
867
576
|
"required": True,
|
|
868
|
-
"secret": True,
|
|
869
577
|
"description": "PagerDuty Events API v2 routing key",
|
|
870
578
|
},
|
|
871
579
|
"severity": {
|
|
872
580
|
"type": "string",
|
|
873
581
|
"required": False,
|
|
874
|
-
"
|
|
875
|
-
"description": "Default severity (critical, error, warning, info)",
|
|
582
|
+
"description": "Alert severity: critical, error, warning, info",
|
|
876
583
|
},
|
|
877
584
|
"component": {
|
|
878
585
|
"type": "string",
|
|
879
586
|
"required": False,
|
|
880
|
-
"description": "
|
|
587
|
+
"description": "Affected component name",
|
|
881
588
|
},
|
|
882
589
|
"group": {
|
|
883
590
|
"type": "string",
|
|
884
591
|
"required": False,
|
|
885
|
-
"description": "
|
|
592
|
+
"description": "Alert grouping key",
|
|
886
593
|
},
|
|
887
594
|
"class_type": {
|
|
888
595
|
"type": "string",
|
|
889
596
|
"required": False,
|
|
890
|
-
"description": "
|
|
597
|
+
"description": "Alert class/type",
|
|
598
|
+
},
|
|
599
|
+
"custom_details": {
|
|
600
|
+
"type": "object",
|
|
601
|
+
"required": False,
|
|
602
|
+
"description": "Additional custom details",
|
|
891
603
|
},
|
|
892
604
|
}
|
|
893
605
|
|
|
606
|
+
def _create_action(self) -> PagerDutyAction:
|
|
607
|
+
"""Create truthound PagerDutyAction."""
|
|
608
|
+
return PagerDutyAction(
|
|
609
|
+
routing_key=self.config["routing_key"],
|
|
610
|
+
severity=self.config.get("severity", "error"),
|
|
611
|
+
component=self.config.get("component"),
|
|
612
|
+
group=self.config.get("group"),
|
|
613
|
+
class_type=self.config.get("class_type"),
|
|
614
|
+
custom_details=self.config.get("custom_details", {}),
|
|
615
|
+
notify_on="always",
|
|
616
|
+
)
|
|
617
|
+
|
|
894
618
|
async def send(
|
|
895
619
|
self,
|
|
896
620
|
message: str,
|
|
897
621
|
event: NotificationEvent | None = None,
|
|
898
|
-
action: str = "trigger",
|
|
899
|
-
dedup_key: str | None = None,
|
|
900
622
|
**kwargs: Any,
|
|
901
623
|
) -> bool:
|
|
902
|
-
"""Send
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
event: Optional triggering event.
|
|
907
|
-
action: Event action (trigger, acknowledge, resolve).
|
|
908
|
-
dedup_key: Optional deduplication key.
|
|
909
|
-
**kwargs: Additional PagerDuty event options.
|
|
910
|
-
|
|
911
|
-
Returns:
|
|
912
|
-
True if event was accepted.
|
|
913
|
-
"""
|
|
914
|
-
routing_key = self.config["routing_key"]
|
|
915
|
-
|
|
916
|
-
# Determine severity from event
|
|
917
|
-
severity = self._determine_severity(event)
|
|
918
|
-
|
|
919
|
-
# Build payload
|
|
920
|
-
payload: dict[str, Any] = {
|
|
921
|
-
"routing_key": routing_key,
|
|
922
|
-
"event_action": action,
|
|
923
|
-
"payload": {
|
|
924
|
-
"summary": message[:1024], # PagerDuty limit
|
|
925
|
-
"severity": severity,
|
|
926
|
-
"source": "truthound-dashboard",
|
|
927
|
-
"timestamp": event.timestamp.isoformat() if event else None,
|
|
928
|
-
},
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
# Add optional fields
|
|
932
|
-
if dedup_key:
|
|
933
|
-
payload["dedup_key"] = dedup_key
|
|
934
|
-
elif event:
|
|
935
|
-
# Generate dedup key from event
|
|
936
|
-
payload["dedup_key"] = f"truthound-{event.event_type}-{event.source_id or 'global'}"
|
|
937
|
-
|
|
938
|
-
if self.config.get("component"):
|
|
939
|
-
payload["payload"]["component"] = self.config["component"]
|
|
940
|
-
if self.config.get("group"):
|
|
941
|
-
payload["payload"]["group"] = self.config["group"]
|
|
942
|
-
if self.config.get("class_type"):
|
|
943
|
-
payload["payload"]["class"] = self.config["class_type"]
|
|
944
|
-
|
|
945
|
-
# Add custom details
|
|
946
|
-
if event:
|
|
947
|
-
payload["payload"]["custom_details"] = event.to_dict()
|
|
948
|
-
|
|
949
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
950
|
-
response = await client.post(self.EVENTS_API_URL, json=payload)
|
|
951
|
-
return response.status_code == 202
|
|
952
|
-
|
|
953
|
-
def _determine_severity(self, event: NotificationEvent | None) -> str:
|
|
954
|
-
"""Determine PagerDuty severity from event."""
|
|
955
|
-
default_severity = self.config.get("severity", "error")
|
|
956
|
-
|
|
957
|
-
if event is None:
|
|
958
|
-
return default_severity
|
|
959
|
-
|
|
960
|
-
if isinstance(event, ValidationFailedEvent):
|
|
961
|
-
if event.has_critical:
|
|
962
|
-
return "critical"
|
|
963
|
-
elif event.has_high:
|
|
964
|
-
return "error"
|
|
965
|
-
return "warning"
|
|
966
|
-
|
|
967
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
968
|
-
return "error"
|
|
969
|
-
|
|
970
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
971
|
-
return "warning" if event.has_high_drift else "info"
|
|
972
|
-
|
|
973
|
-
elif isinstance(event, TestNotificationEvent):
|
|
974
|
-
return "info"
|
|
975
|
-
|
|
976
|
-
return default_severity
|
|
977
|
-
|
|
978
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
979
|
-
"""Format message for PagerDuty alert summary."""
|
|
980
|
-
if isinstance(event, ValidationFailedEvent):
|
|
981
|
-
return (
|
|
982
|
-
f"Validation failed for {event.source_name or 'Unknown'}: "
|
|
983
|
-
f"{event.total_issues} {event.severity} issues"
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
987
|
-
return f"Scheduled validation failed: {event.schedule_name}"
|
|
988
|
-
|
|
989
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
990
|
-
return (
|
|
991
|
-
f"Data drift detected: {event.drifted_columns}/{event.total_columns} columns "
|
|
992
|
-
f"({event.drift_percentage:.1f}%)"
|
|
993
|
-
)
|
|
994
|
-
|
|
995
|
-
elif isinstance(event, TestNotificationEvent):
|
|
996
|
-
return f"Test alert from truthound-dashboard ({event.channel_name})"
|
|
624
|
+
"""Send notification to PagerDuty using truthound library."""
|
|
625
|
+
try:
|
|
626
|
+
action = self._create_action()
|
|
627
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
997
628
|
|
|
998
|
-
|
|
629
|
+
loop = asyncio.get_event_loop()
|
|
630
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
631
|
+
return True
|
|
632
|
+
except Exception as e:
|
|
633
|
+
logger.error(f"PagerDuty notification failed: {e}")
|
|
634
|
+
return False
|
|
999
635
|
|
|
1000
636
|
|
|
1001
|
-
@ChannelRegistry.register("
|
|
1002
|
-
class
|
|
1003
|
-
"""
|
|
637
|
+
@ChannelRegistry.register("webhook")
|
|
638
|
+
class WebhookChannel(BaseNotificationChannel):
|
|
639
|
+
"""Generic webhook notification channel using truthound.checkpoint.actions.WebhookAction.
|
|
1004
640
|
|
|
1005
641
|
Configuration:
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
Example config:
|
|
1013
|
-
{
|
|
1014
|
-
"api_key": "your-opsgenie-api-key",
|
|
1015
|
-
"priority": "P3",
|
|
1016
|
-
"tags": ["data-quality", "automated"],
|
|
1017
|
-
"team": "data-platform"
|
|
1018
|
-
}
|
|
642
|
+
url: Webhook URL (required)
|
|
643
|
+
method: HTTP method (GET, POST, PUT, PATCH)
|
|
644
|
+
headers: Custom HTTP headers
|
|
645
|
+
timeout: Request timeout in seconds
|
|
646
|
+
include_result: Include full result in payload
|
|
1019
647
|
"""
|
|
1020
648
|
|
|
1021
|
-
channel_type = "
|
|
1022
|
-
|
|
1023
|
-
API_URL = "https://api.opsgenie.com/v2/alerts"
|
|
649
|
+
channel_type = "webhook"
|
|
1024
650
|
|
|
1025
651
|
@classmethod
|
|
1026
652
|
def get_config_schema(cls) -> dict[str, Any]:
|
|
1027
|
-
"""Get
|
|
653
|
+
"""Get Webhook channel configuration schema."""
|
|
1028
654
|
return {
|
|
1029
|
-
"
|
|
655
|
+
"url": {
|
|
1030
656
|
"type": "string",
|
|
1031
657
|
"required": True,
|
|
1032
|
-
"
|
|
1033
|
-
"description": "OpsGenie API key",
|
|
658
|
+
"description": "Webhook URL",
|
|
1034
659
|
},
|
|
1035
|
-
"
|
|
660
|
+
"method": {
|
|
1036
661
|
"type": "string",
|
|
1037
662
|
"required": False,
|
|
1038
|
-
"
|
|
1039
|
-
"description": "Default priority (P1-P5)",
|
|
663
|
+
"description": "HTTP method: GET, POST, PUT, PATCH",
|
|
1040
664
|
},
|
|
1041
|
-
"
|
|
1042
|
-
"type": "
|
|
665
|
+
"headers": {
|
|
666
|
+
"type": "object",
|
|
1043
667
|
"required": False,
|
|
1044
|
-
"
|
|
1045
|
-
"description": "Alert tags",
|
|
668
|
+
"description": "Custom HTTP headers",
|
|
1046
669
|
},
|
|
1047
|
-
"
|
|
1048
|
-
"type": "
|
|
670
|
+
"timeout": {
|
|
671
|
+
"type": "integer",
|
|
1049
672
|
"required": False,
|
|
1050
|
-
"description": "
|
|
673
|
+
"description": "Request timeout in seconds",
|
|
1051
674
|
},
|
|
1052
|
-
"
|
|
1053
|
-
"type": "
|
|
675
|
+
"include_result": {
|
|
676
|
+
"type": "boolean",
|
|
1054
677
|
"required": False,
|
|
1055
|
-
"
|
|
1056
|
-
"description": "List of responders",
|
|
678
|
+
"description": "Include full result in payload",
|
|
1057
679
|
},
|
|
1058
680
|
}
|
|
1059
681
|
|
|
682
|
+
def _create_action(self) -> WebhookAction:
|
|
683
|
+
"""Create truthound WebhookAction."""
|
|
684
|
+
return WebhookAction(
|
|
685
|
+
url=self.config["url"],
|
|
686
|
+
method=self.config.get("method", "POST"),
|
|
687
|
+
headers=self.config.get("headers", {}),
|
|
688
|
+
timeout=self.config.get("timeout", 30),
|
|
689
|
+
include_result=self.config.get("include_result", True),
|
|
690
|
+
notify_on="always",
|
|
691
|
+
)
|
|
692
|
+
|
|
1060
693
|
async def send(
|
|
1061
694
|
self,
|
|
1062
695
|
message: str,
|
|
1063
696
|
event: NotificationEvent | None = None,
|
|
1064
|
-
alias: str | None = None,
|
|
1065
697
|
**kwargs: Any,
|
|
1066
698
|
) -> bool:
|
|
1067
|
-
"""Send
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
event: Optional triggering event.
|
|
1072
|
-
alias: Optional unique alert identifier.
|
|
1073
|
-
**kwargs: Additional OpsGenie alert options.
|
|
1074
|
-
|
|
1075
|
-
Returns:
|
|
1076
|
-
True if alert was created.
|
|
1077
|
-
"""
|
|
1078
|
-
api_key = self.config["api_key"]
|
|
1079
|
-
|
|
1080
|
-
# Determine priority from event
|
|
1081
|
-
priority = self._determine_priority(event)
|
|
1082
|
-
|
|
1083
|
-
# Build payload
|
|
1084
|
-
payload: dict[str, Any] = {
|
|
1085
|
-
"message": message[:130], # OpsGenie message limit
|
|
1086
|
-
"priority": priority,
|
|
1087
|
-
"source": "truthound-dashboard",
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
# Add alias for deduplication
|
|
1091
|
-
if alias:
|
|
1092
|
-
payload["alias"] = alias
|
|
1093
|
-
elif event:
|
|
1094
|
-
payload["alias"] = f"truthound-{event.event_type}-{event.source_id or 'global'}"
|
|
1095
|
-
|
|
1096
|
-
# Add description with full details
|
|
1097
|
-
if event:
|
|
1098
|
-
payload["description"] = self._build_description(event)
|
|
1099
|
-
|
|
1100
|
-
# Add optional fields
|
|
1101
|
-
tags = list(self.config.get("tags", [])) + ["truthound"]
|
|
1102
|
-
payload["tags"] = tags
|
|
1103
|
-
|
|
1104
|
-
if self.config.get("team"):
|
|
1105
|
-
payload["responders"] = [{"type": "team", "name": self.config["team"]}]
|
|
1106
|
-
elif self.config.get("responders"):
|
|
1107
|
-
payload["responders"] = self.config["responders"]
|
|
1108
|
-
|
|
1109
|
-
# Add details
|
|
1110
|
-
if event:
|
|
1111
|
-
payload["details"] = {
|
|
1112
|
-
"event_type": event.event_type,
|
|
1113
|
-
"source_id": event.source_id,
|
|
1114
|
-
"source_name": event.source_name,
|
|
1115
|
-
"timestamp": event.timestamp.isoformat(),
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
headers = {
|
|
1119
|
-
"Authorization": f"GenieKey {api_key}",
|
|
1120
|
-
"Content-Type": "application/json",
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
1124
|
-
response = await client.post(self.API_URL, json=payload, headers=headers)
|
|
1125
|
-
return response.status_code == 202
|
|
1126
|
-
|
|
1127
|
-
def _determine_priority(self, event: NotificationEvent | None) -> str:
|
|
1128
|
-
"""Determine OpsGenie priority from event."""
|
|
1129
|
-
default_priority = self.config.get("priority", "P3")
|
|
1130
|
-
|
|
1131
|
-
if event is None:
|
|
1132
|
-
return default_priority
|
|
1133
|
-
|
|
1134
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1135
|
-
if event.has_critical:
|
|
1136
|
-
return "P1"
|
|
1137
|
-
elif event.has_high:
|
|
1138
|
-
return "P2"
|
|
1139
|
-
return "P3"
|
|
1140
|
-
|
|
1141
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1142
|
-
return "P2"
|
|
1143
|
-
|
|
1144
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1145
|
-
return "P3" if event.has_high_drift else "P4"
|
|
1146
|
-
|
|
1147
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1148
|
-
return "P5"
|
|
1149
|
-
|
|
1150
|
-
return default_priority
|
|
1151
|
-
|
|
1152
|
-
def _build_description(self, event: NotificationEvent) -> str:
|
|
1153
|
-
"""Build detailed description for OpsGenie alert."""
|
|
1154
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1155
|
-
return (
|
|
1156
|
-
f"Validation failed for source: {event.source_name or 'Unknown'}\n\n"
|
|
1157
|
-
f"Severity: {event.severity}\n"
|
|
1158
|
-
f"Total Issues: {event.total_issues}\n"
|
|
1159
|
-
f"Critical Issues: {event.has_critical}\n"
|
|
1160
|
-
f"High Severity Issues: {event.has_high}\n"
|
|
1161
|
-
f"Validation ID: {event.validation_id}"
|
|
1162
|
-
)
|
|
1163
|
-
|
|
1164
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1165
|
-
return (
|
|
1166
|
-
f"Scheduled validation failed\n\n"
|
|
1167
|
-
f"Schedule: {event.schedule_name}\n"
|
|
1168
|
-
f"Source: {event.source_name or 'Unknown'}\n"
|
|
1169
|
-
f"Error: {event.error_message or 'Validation failed'}"
|
|
1170
|
-
)
|
|
1171
|
-
|
|
1172
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1173
|
-
return (
|
|
1174
|
-
f"Data drift detected between datasets\n\n"
|
|
1175
|
-
f"Baseline: {event.baseline_source_name}\n"
|
|
1176
|
-
f"Current: {event.current_source_name}\n"
|
|
1177
|
-
f"Drifted Columns: {event.drifted_columns}/{event.total_columns}\n"
|
|
1178
|
-
f"Drift Percentage: {event.drift_percentage:.1f}%"
|
|
1179
|
-
)
|
|
1180
|
-
|
|
1181
|
-
return f"Event type: {event.event_type}"
|
|
1182
|
-
|
|
1183
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
1184
|
-
"""Format message for OpsGenie alert (short)."""
|
|
1185
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1186
|
-
return f"Validation failed: {event.source_name or 'Unknown'} ({event.severity})"
|
|
1187
|
-
|
|
1188
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1189
|
-
return f"Schedule failed: {event.schedule_name}"
|
|
1190
|
-
|
|
1191
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1192
|
-
return f"Drift detected: {event.drift_percentage:.1f}%"
|
|
1193
|
-
|
|
1194
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1195
|
-
return f"Test alert: {event.channel_name}"
|
|
699
|
+
"""Send notification via Webhook using truthound library."""
|
|
700
|
+
try:
|
|
701
|
+
action = self._create_action()
|
|
702
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
1196
703
|
|
|
1197
|
-
|
|
704
|
+
loop = asyncio.get_event_loop()
|
|
705
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
706
|
+
return True
|
|
707
|
+
except Exception as e:
|
|
708
|
+
logger.error(f"Webhook notification failed: {e}")
|
|
709
|
+
return False
|
|
1198
710
|
|
|
1199
711
|
|
|
1200
|
-
@ChannelRegistry.register("
|
|
1201
|
-
class
|
|
1202
|
-
"""
|
|
712
|
+
@ChannelRegistry.register("opsgenie")
|
|
713
|
+
class OpsGenieChannel(BaseNotificationChannel):
|
|
714
|
+
"""OpsGenie notification channel using truthound.checkpoint.actions.OpsGenieAction.
|
|
1203
715
|
|
|
1204
716
|
Configuration:
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
"theme_color": "fd9e4b"
|
|
1212
|
-
}
|
|
717
|
+
api_key: OpsGenie API key (required)
|
|
718
|
+
region: OpsGenie region (us or eu)
|
|
719
|
+
priority: Alert priority (P1-P5)
|
|
720
|
+
auto_priority: Automatic priority mapping
|
|
721
|
+
tags: Alert tags
|
|
722
|
+
auto_close_on_success: Automatically close on success
|
|
1213
723
|
"""
|
|
1214
724
|
|
|
1215
|
-
channel_type = "
|
|
725
|
+
channel_type = "opsgenie"
|
|
1216
726
|
|
|
1217
727
|
@classmethod
|
|
1218
728
|
def get_config_schema(cls) -> dict[str, Any]:
|
|
1219
|
-
"""Get
|
|
729
|
+
"""Get OpsGenie channel configuration schema."""
|
|
1220
730
|
return {
|
|
1221
|
-
"
|
|
731
|
+
"api_key": {
|
|
1222
732
|
"type": "string",
|
|
1223
733
|
"required": True,
|
|
1224
|
-
"description": "
|
|
734
|
+
"description": "OpsGenie API key",
|
|
1225
735
|
},
|
|
1226
|
-
"
|
|
736
|
+
"region": {
|
|
1227
737
|
"type": "string",
|
|
1228
738
|
"required": False,
|
|
1229
|
-
"
|
|
1230
|
-
|
|
739
|
+
"description": "OpsGenie region: us or eu",
|
|
740
|
+
},
|
|
741
|
+
"priority": {
|
|
742
|
+
"type": "string",
|
|
743
|
+
"required": False,
|
|
744
|
+
"description": "Alert priority: P1, P2, P3, P4, P5",
|
|
745
|
+
},
|
|
746
|
+
"auto_priority": {
|
|
747
|
+
"type": "boolean",
|
|
748
|
+
"required": False,
|
|
749
|
+
"description": "Automatic priority mapping based on validation results",
|
|
750
|
+
},
|
|
751
|
+
"tags": {
|
|
752
|
+
"type": "array",
|
|
753
|
+
"required": False,
|
|
754
|
+
"description": "Alert tags",
|
|
755
|
+
},
|
|
756
|
+
"auto_close_on_success": {
|
|
757
|
+
"type": "boolean",
|
|
758
|
+
"required": False,
|
|
759
|
+
"description": "Automatically close on success",
|
|
1231
760
|
},
|
|
1232
761
|
}
|
|
1233
762
|
|
|
763
|
+
def _create_action(self) -> OpsGenieAction:
|
|
764
|
+
"""Create truthound OpsGenieAction."""
|
|
765
|
+
return OpsGenieAction(
|
|
766
|
+
api_key=self.config["api_key"],
|
|
767
|
+
region=self.config.get("region", "us"),
|
|
768
|
+
priority=self.config.get("priority", "P3"),
|
|
769
|
+
auto_priority=self.config.get("auto_priority", True),
|
|
770
|
+
tags=self.config.get("tags", []),
|
|
771
|
+
auto_close_on_success=self.config.get("auto_close_on_success", True),
|
|
772
|
+
notify_on="always",
|
|
773
|
+
)
|
|
774
|
+
|
|
1234
775
|
async def send(
|
|
1235
776
|
self,
|
|
1236
777
|
message: str,
|
|
1237
778
|
event: NotificationEvent | None = None,
|
|
1238
779
|
**kwargs: Any,
|
|
1239
780
|
) -> bool:
|
|
1240
|
-
"""Send notification to
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
event: Optional triggering event.
|
|
1245
|
-
**kwargs: Additional Teams message options.
|
|
1246
|
-
|
|
1247
|
-
Returns:
|
|
1248
|
-
True if message was sent successfully.
|
|
1249
|
-
"""
|
|
1250
|
-
webhook_url = self.config["webhook_url"]
|
|
1251
|
-
|
|
1252
|
-
# Build Adaptive Card payload
|
|
1253
|
-
payload = self._build_card(message, event)
|
|
1254
|
-
|
|
1255
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
1256
|
-
response = await client.post(webhook_url, json=payload)
|
|
1257
|
-
return response.status_code == 200
|
|
1258
|
-
|
|
1259
|
-
def _build_card(
|
|
1260
|
-
self,
|
|
1261
|
-
message: str,
|
|
1262
|
-
event: NotificationEvent | None,
|
|
1263
|
-
) -> dict[str, Any]:
|
|
1264
|
-
"""Build Teams Adaptive Card payload."""
|
|
1265
|
-
theme_color = self.config.get("theme_color", "fd9e4b")
|
|
1266
|
-
|
|
1267
|
-
# Build card content
|
|
1268
|
-
card: dict[str, Any] = {
|
|
1269
|
-
"@type": "MessageCard",
|
|
1270
|
-
"@context": "http://schema.org/extensions",
|
|
1271
|
-
"themeColor": theme_color,
|
|
1272
|
-
"summary": message[:150],
|
|
1273
|
-
"sections": [],
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
# Build sections based on event type
|
|
1277
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1278
|
-
card["title"] = "🚨 Validation Failed"
|
|
1279
|
-
card["sections"].append({
|
|
1280
|
-
"activityTitle": event.source_name or "Unknown Source",
|
|
1281
|
-
"activitySubtitle": f"Severity: {event.severity}",
|
|
1282
|
-
"facts": [
|
|
1283
|
-
{"name": "Total Issues", "value": str(event.total_issues)},
|
|
1284
|
-
{"name": "Critical", "value": "Yes" if event.has_critical else "No"},
|
|
1285
|
-
{"name": "High", "value": "Yes" if event.has_high else "No"},
|
|
1286
|
-
{"name": "Validation ID", "value": event.validation_id[:8] + "..."},
|
|
1287
|
-
],
|
|
1288
|
-
"markdown": True,
|
|
1289
|
-
})
|
|
1290
|
-
|
|
1291
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1292
|
-
card["title"] = "⏰ Schedule Failed"
|
|
1293
|
-
card["sections"].append({
|
|
1294
|
-
"activityTitle": event.schedule_name,
|
|
1295
|
-
"activitySubtitle": event.source_name or "Unknown Source",
|
|
1296
|
-
"facts": [
|
|
1297
|
-
{"name": "Error", "value": event.error_message or "Validation failed"},
|
|
1298
|
-
],
|
|
1299
|
-
"markdown": True,
|
|
1300
|
-
})
|
|
1301
|
-
|
|
1302
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1303
|
-
card["title"] = "📊 Drift Detected"
|
|
1304
|
-
card["sections"].append({
|
|
1305
|
-
"activityTitle": "Data Drift Analysis",
|
|
1306
|
-
"facts": [
|
|
1307
|
-
{"name": "Baseline", "value": event.baseline_source_name},
|
|
1308
|
-
{"name": "Current", "value": event.current_source_name},
|
|
1309
|
-
{"name": "Drifted Columns", "value": f"{event.drifted_columns}/{event.total_columns}"},
|
|
1310
|
-
{"name": "Drift %", "value": f"{event.drift_percentage:.1f}%"},
|
|
1311
|
-
],
|
|
1312
|
-
"markdown": True,
|
|
1313
|
-
})
|
|
1314
|
-
|
|
1315
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1316
|
-
card["title"] = "✅ Test Notification"
|
|
1317
|
-
card["sections"].append({
|
|
1318
|
-
"activityTitle": "truthound-dashboard",
|
|
1319
|
-
"activitySubtitle": f"Channel: {event.channel_name}",
|
|
1320
|
-
"text": "This is a test notification.",
|
|
1321
|
-
"markdown": True,
|
|
1322
|
-
})
|
|
1323
|
-
|
|
1324
|
-
else:
|
|
1325
|
-
card["title"] = "Truthound Notification"
|
|
1326
|
-
card["sections"].append({
|
|
1327
|
-
"text": message,
|
|
1328
|
-
"markdown": True,
|
|
1329
|
-
})
|
|
1330
|
-
|
|
1331
|
-
return card
|
|
1332
|
-
|
|
1333
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
1334
|
-
"""Format message for Teams."""
|
|
1335
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1336
|
-
return (
|
|
1337
|
-
f"Validation failed for {event.source_name or 'Unknown'}: "
|
|
1338
|
-
f"{event.total_issues} issues ({event.severity})"
|
|
1339
|
-
)
|
|
1340
|
-
|
|
1341
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1342
|
-
return f"Schedule '{event.schedule_name}' failed"
|
|
1343
|
-
|
|
1344
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1345
|
-
return (
|
|
1346
|
-
f"Drift detected: {event.drifted_columns} columns "
|
|
1347
|
-
f"({event.drift_percentage:.1f}%)"
|
|
1348
|
-
)
|
|
1349
|
-
|
|
1350
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1351
|
-
return "Test notification from truthound-dashboard"
|
|
781
|
+
"""Send notification to OpsGenie using truthound library."""
|
|
782
|
+
try:
|
|
783
|
+
action = self._create_action()
|
|
784
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
1352
785
|
|
|
1353
|
-
|
|
786
|
+
loop = asyncio.get_event_loop()
|
|
787
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
788
|
+
return True
|
|
789
|
+
except Exception as e:
|
|
790
|
+
logger.error(f"OpsGenie notification failed: {e}")
|
|
791
|
+
return False
|
|
1354
792
|
|
|
1355
793
|
|
|
1356
794
|
@ChannelRegistry.register("github")
|
|
1357
795
|
class GitHubChannel(BaseNotificationChannel):
|
|
1358
|
-
"""GitHub notification channel
|
|
796
|
+
"""GitHub notification channel using truthound.checkpoint.actions.GitHubAction.
|
|
1359
797
|
|
|
1360
|
-
|
|
1361
|
-
token: GitHub personal access token
|
|
1362
|
-
owner: Repository owner
|
|
1363
|
-
repo: Repository name
|
|
1364
|
-
labels: Optional list of labels to apply
|
|
1365
|
-
assignees: Optional list of assignees
|
|
798
|
+
Creates GitHub issues or check runs for notifications.
|
|
1366
799
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
}
|
|
800
|
+
Configuration:
|
|
801
|
+
token: GitHub personal access token (required)
|
|
802
|
+
owner: Repository owner (required)
|
|
803
|
+
repo: Repository name (required)
|
|
804
|
+
labels: Issue labels
|
|
805
|
+
assignees: Issue assignees
|
|
806
|
+
create_check_run: Create check run instead of issue
|
|
1375
807
|
"""
|
|
1376
808
|
|
|
1377
809
|
channel_type = "github"
|
|
1378
810
|
|
|
1379
|
-
API_URL = "https://api.github.com"
|
|
1380
|
-
|
|
1381
811
|
@classmethod
|
|
1382
812
|
def get_config_schema(cls) -> dict[str, Any]:
|
|
1383
813
|
"""Get GitHub channel configuration schema."""
|
|
@@ -1385,7 +815,6 @@ class GitHubChannel(BaseNotificationChannel):
|
|
|
1385
815
|
"token": {
|
|
1386
816
|
"type": "string",
|
|
1387
817
|
"required": True,
|
|
1388
|
-
"secret": True,
|
|
1389
818
|
"description": "GitHub personal access token",
|
|
1390
819
|
},
|
|
1391
820
|
"owner": {
|
|
@@ -1401,176 +830,45 @@ class GitHubChannel(BaseNotificationChannel):
|
|
|
1401
830
|
"labels": {
|
|
1402
831
|
"type": "array",
|
|
1403
832
|
"required": False,
|
|
1404
|
-
"
|
|
1405
|
-
"description": "Labels to apply to issues",
|
|
833
|
+
"description": "Issue labels",
|
|
1406
834
|
},
|
|
1407
835
|
"assignees": {
|
|
1408
836
|
"type": "array",
|
|
1409
837
|
"required": False,
|
|
1410
|
-
"
|
|
1411
|
-
|
|
838
|
+
"description": "Issue assignees",
|
|
839
|
+
},
|
|
840
|
+
"create_check_run": {
|
|
841
|
+
"type": "boolean",
|
|
842
|
+
"required": False,
|
|
843
|
+
"description": "Create check run instead of issue",
|
|
1412
844
|
},
|
|
1413
845
|
}
|
|
1414
846
|
|
|
847
|
+
def _create_action(self) -> GitHubAction:
|
|
848
|
+
"""Create truthound GitHubAction."""
|
|
849
|
+
return GitHubAction(
|
|
850
|
+
token=self.config["token"],
|
|
851
|
+
repo=f"{self.config['owner']}/{self.config['repo']}",
|
|
852
|
+
create_check_run=self.config.get("create_check_run", False),
|
|
853
|
+
labels=self.config.get("labels", ["data-quality"]),
|
|
854
|
+
assignees=self.config.get("assignees", []),
|
|
855
|
+
notify_on="always",
|
|
856
|
+
)
|
|
857
|
+
|
|
1415
858
|
async def send(
|
|
1416
859
|
self,
|
|
1417
860
|
message: str,
|
|
1418
861
|
event: NotificationEvent | None = None,
|
|
1419
|
-
title: str | None = None,
|
|
1420
862
|
**kwargs: Any,
|
|
1421
863
|
) -> bool:
|
|
1422
|
-
"""
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
token = self.config["token"]
|
|
1434
|
-
owner = self.config["owner"]
|
|
1435
|
-
repo = self.config["repo"]
|
|
1436
|
-
|
|
1437
|
-
# Build issue title
|
|
1438
|
-
if title is None:
|
|
1439
|
-
title = self._build_title(event)
|
|
1440
|
-
|
|
1441
|
-
# Build issue body
|
|
1442
|
-
body = self._build_body(message, event)
|
|
1443
|
-
|
|
1444
|
-
# Build payload
|
|
1445
|
-
payload: dict[str, Any] = {
|
|
1446
|
-
"title": title,
|
|
1447
|
-
"body": body,
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
# Add optional fields
|
|
1451
|
-
labels = list(self.config.get("labels", [])) + ["truthound-alert"]
|
|
1452
|
-
payload["labels"] = labels
|
|
1453
|
-
|
|
1454
|
-
if self.config.get("assignees"):
|
|
1455
|
-
payload["assignees"] = self.config["assignees"]
|
|
1456
|
-
|
|
1457
|
-
headers = {
|
|
1458
|
-
"Authorization": f"Bearer {token}",
|
|
1459
|
-
"Accept": "application/vnd.github.v3+json",
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
url = f"{self.API_URL}/repos/{owner}/{repo}/issues"
|
|
1463
|
-
|
|
1464
|
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
1465
|
-
response = await client.post(url, json=payload, headers=headers)
|
|
1466
|
-
return response.status_code == 201
|
|
1467
|
-
|
|
1468
|
-
def _build_title(self, event: NotificationEvent | None) -> str:
|
|
1469
|
-
"""Build issue title from event."""
|
|
1470
|
-
if event is None:
|
|
1471
|
-
return "[Truthound] Alert"
|
|
1472
|
-
|
|
1473
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1474
|
-
return f"[Truthound] Validation Failed: {event.source_name or 'Unknown'}"
|
|
1475
|
-
|
|
1476
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1477
|
-
return f"[Truthound] Schedule Failed: {event.schedule_name}"
|
|
1478
|
-
|
|
1479
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1480
|
-
return f"[Truthound] Drift Detected: {event.baseline_source_name}"
|
|
1481
|
-
|
|
1482
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1483
|
-
return f"[Truthound] Test Issue: {event.channel_name}"
|
|
1484
|
-
|
|
1485
|
-
return f"[Truthound] {event.event_type}"
|
|
1486
|
-
|
|
1487
|
-
def _build_body(self, message: str, event: NotificationEvent | None) -> str:
|
|
1488
|
-
"""Build issue body with markdown formatting."""
|
|
1489
|
-
body_parts = [
|
|
1490
|
-
"## Truthound Dashboard Alert",
|
|
1491
|
-
"",
|
|
1492
|
-
message,
|
|
1493
|
-
"",
|
|
1494
|
-
]
|
|
1495
|
-
|
|
1496
|
-
if event:
|
|
1497
|
-
body_parts.extend([
|
|
1498
|
-
"## Details",
|
|
1499
|
-
"",
|
|
1500
|
-
f"- **Event Type:** `{event.event_type}`",
|
|
1501
|
-
f"- **Timestamp:** {event.timestamp.isoformat()}",
|
|
1502
|
-
])
|
|
1503
|
-
|
|
1504
|
-
if event.source_id:
|
|
1505
|
-
body_parts.append(f"- **Source ID:** `{event.source_id}`")
|
|
1506
|
-
if event.source_name:
|
|
1507
|
-
body_parts.append(f"- **Source Name:** {event.source_name}")
|
|
1508
|
-
|
|
1509
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1510
|
-
body_parts.extend([
|
|
1511
|
-
"",
|
|
1512
|
-
"### Validation Summary",
|
|
1513
|
-
"",
|
|
1514
|
-
f"| Metric | Value |",
|
|
1515
|
-
f"|--------|-------|",
|
|
1516
|
-
f"| Severity | {event.severity} |",
|
|
1517
|
-
f"| Total Issues | {event.total_issues} |",
|
|
1518
|
-
f"| Critical | {'Yes' if event.has_critical else 'No'} |",
|
|
1519
|
-
f"| High | {'Yes' if event.has_high else 'No'} |",
|
|
1520
|
-
f"| Validation ID | `{event.validation_id}` |",
|
|
1521
|
-
])
|
|
1522
|
-
|
|
1523
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1524
|
-
body_parts.extend([
|
|
1525
|
-
"",
|
|
1526
|
-
"### Drift Summary",
|
|
1527
|
-
"",
|
|
1528
|
-
f"| Metric | Value |",
|
|
1529
|
-
f"|--------|-------|",
|
|
1530
|
-
f"| Baseline | {event.baseline_source_name} |",
|
|
1531
|
-
f"| Current | {event.current_source_name} |",
|
|
1532
|
-
f"| Drifted Columns | {event.drifted_columns}/{event.total_columns} |",
|
|
1533
|
-
f"| Drift % | {event.drift_percentage:.1f}% |",
|
|
1534
|
-
])
|
|
1535
|
-
|
|
1536
|
-
body_parts.extend([
|
|
1537
|
-
"",
|
|
1538
|
-
"---",
|
|
1539
|
-
"_This issue was automatically created by truthound-dashboard._",
|
|
1540
|
-
])
|
|
1541
|
-
|
|
1542
|
-
return "\n".join(body_parts)
|
|
1543
|
-
|
|
1544
|
-
def format_message(self, event: NotificationEvent) -> str:
|
|
1545
|
-
"""Format message for GitHub issue body."""
|
|
1546
|
-
if isinstance(event, ValidationFailedEvent):
|
|
1547
|
-
return (
|
|
1548
|
-
f"A validation failure has been detected.\n\n"
|
|
1549
|
-
f"**Source:** {event.source_name or 'Unknown'}\n"
|
|
1550
|
-
f"**Severity:** {event.severity}\n"
|
|
1551
|
-
f"**Total Issues:** {event.total_issues}"
|
|
1552
|
-
)
|
|
1553
|
-
|
|
1554
|
-
elif isinstance(event, ScheduleFailedEvent):
|
|
1555
|
-
return (
|
|
1556
|
-
f"A scheduled validation has failed.\n\n"
|
|
1557
|
-
f"**Schedule:** {event.schedule_name}\n"
|
|
1558
|
-
f"**Source:** {event.source_name or 'Unknown'}\n"
|
|
1559
|
-
f"**Error:** {event.error_message or 'Validation failed'}"
|
|
1560
|
-
)
|
|
1561
|
-
|
|
1562
|
-
elif isinstance(event, DriftDetectedEvent):
|
|
1563
|
-
return (
|
|
1564
|
-
f"Data drift has been detected between datasets.\n\n"
|
|
1565
|
-
f"**Baseline:** {event.baseline_source_name}\n"
|
|
1566
|
-
f"**Current:** {event.current_source_name}\n"
|
|
1567
|
-
f"**Drift:** {event.drift_percentage:.1f}%"
|
|
1568
|
-
)
|
|
1569
|
-
|
|
1570
|
-
elif isinstance(event, TestNotificationEvent):
|
|
1571
|
-
return (
|
|
1572
|
-
f"This is a test issue created by truthound-dashboard.\n\n"
|
|
1573
|
-
f"**Channel:** {event.channel_name}"
|
|
1574
|
-
)
|
|
1575
|
-
|
|
1576
|
-
return self._default_format(event)
|
|
864
|
+
"""Send notification to GitHub using truthound library."""
|
|
865
|
+
try:
|
|
866
|
+
action = self._create_action()
|
|
867
|
+
mock_result = _build_checkpoint_result_mock(event)
|
|
868
|
+
|
|
869
|
+
loop = asyncio.get_event_loop()
|
|
870
|
+
await loop.run_in_executor(None, action.execute, mock_result)
|
|
871
|
+
return True
|
|
872
|
+
except Exception as e:
|
|
873
|
+
logger.error(f"GitHub notification failed: {e}")
|
|
874
|
+
return False
|