truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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.
Files changed (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +645 -23
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +15 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.1.dist-info/METADATA +312 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,29 +1,39 @@
1
- """Notification channel implementations.
2
-
3
- This module provides concrete implementations of notification channels
4
- for various notification services:
5
-
6
- - Slack: Incoming webhooks
7
- - Email: SMTP
8
- - Webhook: Generic HTTP endpoints
9
- - Discord: Discord webhooks
10
- - Telegram: Telegram Bot API
11
- - PagerDuty: Events API v2
12
- - OpsGenie: Alert API
13
- - Teams: Microsoft Teams webhooks
14
- - GitHub: Issues/Discussions API
15
-
16
- Each channel is registered with the ChannelRegistry and can be
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
- from email.mime.multipart import MIMEMultipart
23
- from email.mime.text import MIMEText
21
+ import asyncio
22
+ import logging
23
+ from dataclasses import dataclass
24
24
  from typing import Any
25
25
 
26
- import httpx
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 incoming webhooks.
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
- Args:
97
- message: Message text (supports Slack markdown).
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
- # Add context for specific event types
148
- if isinstance(event, ValidationFailedEvent):
149
- context_elements = [
150
- {"type": "mrkdwn", "text": f"*Severity:* {event.severity}"},
151
- {"type": "mrkdwn", "text": f"*Issues:* {event.total_issues}"},
152
- ]
153
- if event.validation_id:
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 with mrkdwn syntax."""
176
- if isinstance(event, ValidationFailedEvent):
177
- emoji = ":rotating_light:" if event.has_critical else ":warning:"
178
- return (
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 SMTP.
235
+ """Email notification channel using truthound.checkpoint.actions.EmailNotification.
216
236
 
217
237
  Configuration:
218
- smtp_host: SMTP server hostname
238
+ smtp_host: SMTP server host (required)
219
239
  smtp_port: SMTP server port (default: 587)
220
- smtp_username: SMTP authentication username
240
+ smtp_user: SMTP authentication user
221
241
  smtp_password: SMTP authentication password
222
- from_email: Sender email address
223
- recipients: List of recipient email addresses
224
- use_tls: Whether to use TLS (default: True)
225
-
226
- Example config:
227
- {
228
- "smtp_host": "smtp.gmail.com",
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": True,
247
- "description": "SMTP server hostname",
259
+ "required": False,
260
+ "description": "SMTP server host",
248
261
  },
249
262
  "smtp_port": {
250
263
  "type": "integer",
251
264
  "required": False,
252
- "default": 587,
253
- "description": "SMTP server port",
265
+ "description": "SMTP server port (default: 587)",
254
266
  },
255
- "smtp_username": {
267
+ "smtp_user": {
256
268
  "type": "string",
257
269
  "required": False,
258
- "description": "SMTP authentication username",
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
- "from_email": {
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
- "recipients": {
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
- "use_tls": {
278
- "type": "boolean",
297
+ "cc_addresses": {
298
+ "type": "array",
279
299
  "required": False,
280
- "default": True,
281
- "description": "Use TLS encryption",
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 email.
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
- import aiosmtplib
305
- except ImportError:
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
- Sends JSON payloads to any HTTP endpoint.
351
+ @ChannelRegistry.register("teams")
352
+ class TeamsChannel(BaseNotificationChannel):
353
+ """Microsoft Teams notification channel using truthound.checkpoint.actions.TeamsNotification.
442
354
 
443
355
  Configuration:
444
- url: Webhook endpoint URL
445
- method: HTTP method (default: POST)
446
- headers: Optional custom headers
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 = "webhook"
361
+ channel_type = "teams"
461
362
 
462
363
  @classmethod
463
364
  def get_config_schema(cls) -> dict[str, Any]:
464
- """Get Webhook channel configuration schema."""
365
+ """Get Teams channel configuration schema."""
465
366
  return {
466
- "url": {
367
+ "webhook_url": {
467
368
  "type": "string",
468
369
  "required": True,
469
- "description": "Webhook endpoint URL",
370
+ "description": "Teams incoming webhook URL",
470
371
  },
471
- "method": {
372
+ "channel": {
472
373
  "type": "string",
473
374
  "required": False,
474
- "default": "POST",
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
- "include_event_data": {
377
+ "include_details": {
483
378
  "type": "boolean",
484
379
  "required": False,
485
- "default": True,
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 via webhook.
498
-
499
- Args:
500
- message: Message text.
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
- return self._default_format(event)
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 webhooks.
414
+ """Discord notification channel using truthound.checkpoint.actions.DiscordNotification.
574
415
 
575
416
  Configuration:
576
- webhook_url: Discord webhook URL
577
- username: Optional bot username override
578
- avatar_url: Optional avatar URL
579
-
580
- Example config:
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 username override",
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
- Args:
620
- message: Message text (supports Discord markdown).
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
- return self._default_format(event)
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 Bot API.
489
+ """Telegram notification channel using truthound.checkpoint.actions.TelegramNotification.
718
490
 
719
491
  Configuration:
720
- bot_token: Telegram Bot API token
721
- chat_id: Target chat/group/channel ID
722
- parse_mode: Message parse mode (HTML or MarkdownV2)
723
- disable_notification: Send silently (default: False)
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
- "secret": True,
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": "Target chat/group/channel ID",
512
+ "description": "Channel/Group ID",
749
513
  },
750
514
  "parse_mode": {
751
515
  "type": "string",
752
516
  "required": False,
753
- "default": "HTML",
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
- "default": False,
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 via Telegram Bot API.
771
-
772
- Args:
773
- message: Message text (supports HTML or Markdown).
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
- async with httpx.AsyncClient(timeout=30.0) as client:
796
- response = await client.post(api_url, json=payload)
797
- result = response.json()
798
- return result.get("ok", False)
799
-
800
- def format_message(self, event: NotificationEvent) -> str:
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 Events API v2.
557
+ """PagerDuty notification channel using truthound.checkpoint.actions.PagerDutyAction.
841
558
 
842
559
  Configuration:
843
- routing_key: PagerDuty Events API v2 routing/integration key
844
- severity: Default severity (critical, error, warning, info)
845
- component: Optional component name
846
- group: Optional logical grouping
847
- class_type: Optional class/type of event
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
- "default": "error",
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": "Component name",
587
+ "description": "Affected component name",
881
588
  },
882
589
  "group": {
883
590
  "type": "string",
884
591
  "required": False,
885
- "description": "Logical grouping",
592
+ "description": "Alert grouping key",
886
593
  },
887
594
  "class_type": {
888
595
  "type": "string",
889
596
  "required": False,
890
- "description": "Event class/type",
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 event to PagerDuty.
903
-
904
- Args:
905
- message: Alert summary.
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
- return self._default_format(event)
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("opsgenie")
1002
- class OpsGenieChannel(BaseNotificationChannel):
1003
- """OpsGenie notification channel using Alert API.
637
+ @ChannelRegistry.register("webhook")
638
+ class WebhookChannel(BaseNotificationChannel):
639
+ """Generic webhook notification channel using truthound.checkpoint.actions.WebhookAction.
1004
640
 
1005
641
  Configuration:
1006
- api_key: OpsGenie API key
1007
- priority: Default priority (P1-P5)
1008
- tags: Optional list of tags
1009
- team: Optional team name
1010
- responders: Optional list of responders
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 = "opsgenie"
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 OpsGenie channel configuration schema."""
653
+ """Get Webhook channel configuration schema."""
1028
654
  return {
1029
- "api_key": {
655
+ "url": {
1030
656
  "type": "string",
1031
657
  "required": True,
1032
- "secret": True,
1033
- "description": "OpsGenie API key",
658
+ "description": "Webhook URL",
1034
659
  },
1035
- "priority": {
660
+ "method": {
1036
661
  "type": "string",
1037
662
  "required": False,
1038
- "default": "P3",
1039
- "description": "Default priority (P1-P5)",
663
+ "description": "HTTP method: GET, POST, PUT, PATCH",
1040
664
  },
1041
- "tags": {
1042
- "type": "array",
665
+ "headers": {
666
+ "type": "object",
1043
667
  "required": False,
1044
- "items": {"type": "string"},
1045
- "description": "Alert tags",
668
+ "description": "Custom HTTP headers",
1046
669
  },
1047
- "team": {
1048
- "type": "string",
670
+ "timeout": {
671
+ "type": "integer",
1049
672
  "required": False,
1050
- "description": "Team name",
673
+ "description": "Request timeout in seconds",
1051
674
  },
1052
- "responders": {
1053
- "type": "array",
675
+ "include_result": {
676
+ "type": "boolean",
1054
677
  "required": False,
1055
- "items": {"type": "object"},
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 alert to OpsGenie.
1068
-
1069
- Args:
1070
- message: Alert message.
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
- return self._default_format(event)
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("teams")
1201
- class TeamsChannel(BaseNotificationChannel):
1202
- """Microsoft Teams notification channel using webhooks.
712
+ @ChannelRegistry.register("opsgenie")
713
+ class OpsGenieChannel(BaseNotificationChannel):
714
+ """OpsGenie notification channel using truthound.checkpoint.actions.OpsGenieAction.
1203
715
 
1204
716
  Configuration:
1205
- webhook_url: Teams incoming webhook URL
1206
- theme_color: Optional accent color (hex without #)
1207
-
1208
- Example config:
1209
- {
1210
- "webhook_url": "https://outlook.office.com/webhook/...",
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 = "teams"
725
+ channel_type = "opsgenie"
1216
726
 
1217
727
  @classmethod
1218
728
  def get_config_schema(cls) -> dict[str, Any]:
1219
- """Get Teams channel configuration schema."""
729
+ """Get OpsGenie channel configuration schema."""
1220
730
  return {
1221
- "webhook_url": {
731
+ "api_key": {
1222
732
  "type": "string",
1223
733
  "required": True,
1224
- "description": "Teams incoming webhook URL",
734
+ "description": "OpsGenie API key",
1225
735
  },
1226
- "theme_color": {
736
+ "region": {
1227
737
  "type": "string",
1228
738
  "required": False,
1229
- "default": "fd9e4b",
1230
- "description": "Accent color (hex without #)",
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 Microsoft Teams.
1241
-
1242
- Args:
1243
- message: Message text.
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
- return self._default_format(event)
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 for creating issues.
796
+ """GitHub notification channel using truthound.checkpoint.actions.GitHubAction.
1359
797
 
1360
- Configuration:
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
- Example config:
1368
- {
1369
- "token": "ghp_xxxxxxxxxxxxxxxxxxxx",
1370
- "owner": "myorg",
1371
- "repo": "data-quality",
1372
- "labels": ["data-quality", "automated"],
1373
- "assignees": ["data-team-lead"]
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
- "items": {"type": "string"},
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
- "items": {"type": "string"},
1411
- "description": "Users to assign",
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
- """Create GitHub issue for notification.
1423
-
1424
- Args:
1425
- message: Issue body.
1426
- event: Optional triggering event.
1427
- title: Optional title override.
1428
- **kwargs: Additional issue options.
1429
-
1430
- Returns:
1431
- True if issue was created.
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