truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
"""Notification channel implementations.
|
|
2
2
|
|
|
3
3
|
This module provides concrete implementations of notification channels
|
|
4
|
-
for
|
|
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
|
|
5
15
|
|
|
6
16
|
Each channel is registered with the ChannelRegistry and can be
|
|
7
17
|
instantiated dynamically based on channel type.
|
|
@@ -22,6 +32,7 @@ from .base import (
|
|
|
22
32
|
)
|
|
23
33
|
from .events import (
|
|
24
34
|
DriftDetectedEvent,
|
|
35
|
+
SchemaChangedEvent,
|
|
25
36
|
ScheduleFailedEvent,
|
|
26
37
|
TestNotificationEvent,
|
|
27
38
|
ValidationFailedEvent,
|
|
@@ -555,3 +566,1011 @@ class WebhookChannel(BaseNotificationChannel):
|
|
|
555
566
|
return f"Test notification from truthound-dashboard"
|
|
556
567
|
|
|
557
568
|
return self._default_format(event)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@ChannelRegistry.register("discord")
|
|
572
|
+
class DiscordChannel(BaseNotificationChannel):
|
|
573
|
+
"""Discord notification channel using webhooks.
|
|
574
|
+
|
|
575
|
+
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
|
+
}
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
channel_type = "discord"
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
592
|
+
"""Get Discord channel configuration schema."""
|
|
593
|
+
return {
|
|
594
|
+
"webhook_url": {
|
|
595
|
+
"type": "string",
|
|
596
|
+
"required": True,
|
|
597
|
+
"description": "Discord webhook URL",
|
|
598
|
+
},
|
|
599
|
+
"username": {
|
|
600
|
+
"type": "string",
|
|
601
|
+
"required": False,
|
|
602
|
+
"description": "Bot username override",
|
|
603
|
+
},
|
|
604
|
+
"avatar_url": {
|
|
605
|
+
"type": "string",
|
|
606
|
+
"required": False,
|
|
607
|
+
"description": "Bot avatar URL",
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async def send(
|
|
612
|
+
self,
|
|
613
|
+
message: str,
|
|
614
|
+
event: NotificationEvent | None = None,
|
|
615
|
+
**kwargs: Any,
|
|
616
|
+
) -> 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"
|
|
711
|
+
|
|
712
|
+
return self._default_format(event)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@ChannelRegistry.register("telegram")
|
|
716
|
+
class TelegramChannel(BaseNotificationChannel):
|
|
717
|
+
"""Telegram notification channel using Bot API.
|
|
718
|
+
|
|
719
|
+
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
|
+
}
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
channel_type = "telegram"
|
|
734
|
+
|
|
735
|
+
@classmethod
|
|
736
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
737
|
+
"""Get Telegram channel configuration schema."""
|
|
738
|
+
return {
|
|
739
|
+
"bot_token": {
|
|
740
|
+
"type": "string",
|
|
741
|
+
"required": True,
|
|
742
|
+
"secret": True,
|
|
743
|
+
"description": "Telegram Bot API token",
|
|
744
|
+
},
|
|
745
|
+
"chat_id": {
|
|
746
|
+
"type": "string",
|
|
747
|
+
"required": True,
|
|
748
|
+
"description": "Target chat/group/channel ID",
|
|
749
|
+
},
|
|
750
|
+
"parse_mode": {
|
|
751
|
+
"type": "string",
|
|
752
|
+
"required": False,
|
|
753
|
+
"default": "HTML",
|
|
754
|
+
"description": "Message parse mode (HTML or MarkdownV2)",
|
|
755
|
+
},
|
|
756
|
+
"disable_notification": {
|
|
757
|
+
"type": "boolean",
|
|
758
|
+
"required": False,
|
|
759
|
+
"default": False,
|
|
760
|
+
"description": "Send message silently",
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async def send(
|
|
765
|
+
self,
|
|
766
|
+
message: str,
|
|
767
|
+
event: NotificationEvent | None = None,
|
|
768
|
+
**kwargs: Any,
|
|
769
|
+
) -> 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)
|
|
794
|
+
|
|
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)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@ChannelRegistry.register("pagerduty")
|
|
839
|
+
class PagerDutyChannel(BaseNotificationChannel):
|
|
840
|
+
"""PagerDuty notification channel using Events API v2.
|
|
841
|
+
|
|
842
|
+
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
|
+
}
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
channel_type = "pagerduty"
|
|
858
|
+
|
|
859
|
+
EVENTS_API_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
860
|
+
|
|
861
|
+
@classmethod
|
|
862
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
863
|
+
"""Get PagerDuty channel configuration schema."""
|
|
864
|
+
return {
|
|
865
|
+
"routing_key": {
|
|
866
|
+
"type": "string",
|
|
867
|
+
"required": True,
|
|
868
|
+
"secret": True,
|
|
869
|
+
"description": "PagerDuty Events API v2 routing key",
|
|
870
|
+
},
|
|
871
|
+
"severity": {
|
|
872
|
+
"type": "string",
|
|
873
|
+
"required": False,
|
|
874
|
+
"default": "error",
|
|
875
|
+
"description": "Default severity (critical, error, warning, info)",
|
|
876
|
+
},
|
|
877
|
+
"component": {
|
|
878
|
+
"type": "string",
|
|
879
|
+
"required": False,
|
|
880
|
+
"description": "Component name",
|
|
881
|
+
},
|
|
882
|
+
"group": {
|
|
883
|
+
"type": "string",
|
|
884
|
+
"required": False,
|
|
885
|
+
"description": "Logical grouping",
|
|
886
|
+
},
|
|
887
|
+
"class_type": {
|
|
888
|
+
"type": "string",
|
|
889
|
+
"required": False,
|
|
890
|
+
"description": "Event class/type",
|
|
891
|
+
},
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async def send(
|
|
895
|
+
self,
|
|
896
|
+
message: str,
|
|
897
|
+
event: NotificationEvent | None = None,
|
|
898
|
+
action: str = "trigger",
|
|
899
|
+
dedup_key: str | None = None,
|
|
900
|
+
**kwargs: Any,
|
|
901
|
+
) -> 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})"
|
|
997
|
+
|
|
998
|
+
return self._default_format(event)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@ChannelRegistry.register("opsgenie")
|
|
1002
|
+
class OpsGenieChannel(BaseNotificationChannel):
|
|
1003
|
+
"""OpsGenie notification channel using Alert API.
|
|
1004
|
+
|
|
1005
|
+
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
|
+
}
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
channel_type = "opsgenie"
|
|
1022
|
+
|
|
1023
|
+
API_URL = "https://api.opsgenie.com/v2/alerts"
|
|
1024
|
+
|
|
1025
|
+
@classmethod
|
|
1026
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
1027
|
+
"""Get OpsGenie channel configuration schema."""
|
|
1028
|
+
return {
|
|
1029
|
+
"api_key": {
|
|
1030
|
+
"type": "string",
|
|
1031
|
+
"required": True,
|
|
1032
|
+
"secret": True,
|
|
1033
|
+
"description": "OpsGenie API key",
|
|
1034
|
+
},
|
|
1035
|
+
"priority": {
|
|
1036
|
+
"type": "string",
|
|
1037
|
+
"required": False,
|
|
1038
|
+
"default": "P3",
|
|
1039
|
+
"description": "Default priority (P1-P5)",
|
|
1040
|
+
},
|
|
1041
|
+
"tags": {
|
|
1042
|
+
"type": "array",
|
|
1043
|
+
"required": False,
|
|
1044
|
+
"items": {"type": "string"},
|
|
1045
|
+
"description": "Alert tags",
|
|
1046
|
+
},
|
|
1047
|
+
"team": {
|
|
1048
|
+
"type": "string",
|
|
1049
|
+
"required": False,
|
|
1050
|
+
"description": "Team name",
|
|
1051
|
+
},
|
|
1052
|
+
"responders": {
|
|
1053
|
+
"type": "array",
|
|
1054
|
+
"required": False,
|
|
1055
|
+
"items": {"type": "object"},
|
|
1056
|
+
"description": "List of responders",
|
|
1057
|
+
},
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async def send(
|
|
1061
|
+
self,
|
|
1062
|
+
message: str,
|
|
1063
|
+
event: NotificationEvent | None = None,
|
|
1064
|
+
alias: str | None = None,
|
|
1065
|
+
**kwargs: Any,
|
|
1066
|
+
) -> 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}"
|
|
1196
|
+
|
|
1197
|
+
return self._default_format(event)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
@ChannelRegistry.register("teams")
|
|
1201
|
+
class TeamsChannel(BaseNotificationChannel):
|
|
1202
|
+
"""Microsoft Teams notification channel using webhooks.
|
|
1203
|
+
|
|
1204
|
+
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
|
+
}
|
|
1213
|
+
"""
|
|
1214
|
+
|
|
1215
|
+
channel_type = "teams"
|
|
1216
|
+
|
|
1217
|
+
@classmethod
|
|
1218
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
1219
|
+
"""Get Teams channel configuration schema."""
|
|
1220
|
+
return {
|
|
1221
|
+
"webhook_url": {
|
|
1222
|
+
"type": "string",
|
|
1223
|
+
"required": True,
|
|
1224
|
+
"description": "Teams incoming webhook URL",
|
|
1225
|
+
},
|
|
1226
|
+
"theme_color": {
|
|
1227
|
+
"type": "string",
|
|
1228
|
+
"required": False,
|
|
1229
|
+
"default": "fd9e4b",
|
|
1230
|
+
"description": "Accent color (hex without #)",
|
|
1231
|
+
},
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async def send(
|
|
1235
|
+
self,
|
|
1236
|
+
message: str,
|
|
1237
|
+
event: NotificationEvent | None = None,
|
|
1238
|
+
**kwargs: Any,
|
|
1239
|
+
) -> 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"
|
|
1352
|
+
|
|
1353
|
+
return self._default_format(event)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
@ChannelRegistry.register("github")
|
|
1357
|
+
class GitHubChannel(BaseNotificationChannel):
|
|
1358
|
+
"""GitHub notification channel for creating issues.
|
|
1359
|
+
|
|
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
|
|
1366
|
+
|
|
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
|
+
}
|
|
1375
|
+
"""
|
|
1376
|
+
|
|
1377
|
+
channel_type = "github"
|
|
1378
|
+
|
|
1379
|
+
API_URL = "https://api.github.com"
|
|
1380
|
+
|
|
1381
|
+
@classmethod
|
|
1382
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
1383
|
+
"""Get GitHub channel configuration schema."""
|
|
1384
|
+
return {
|
|
1385
|
+
"token": {
|
|
1386
|
+
"type": "string",
|
|
1387
|
+
"required": True,
|
|
1388
|
+
"secret": True,
|
|
1389
|
+
"description": "GitHub personal access token",
|
|
1390
|
+
},
|
|
1391
|
+
"owner": {
|
|
1392
|
+
"type": "string",
|
|
1393
|
+
"required": True,
|
|
1394
|
+
"description": "Repository owner",
|
|
1395
|
+
},
|
|
1396
|
+
"repo": {
|
|
1397
|
+
"type": "string",
|
|
1398
|
+
"required": True,
|
|
1399
|
+
"description": "Repository name",
|
|
1400
|
+
},
|
|
1401
|
+
"labels": {
|
|
1402
|
+
"type": "array",
|
|
1403
|
+
"required": False,
|
|
1404
|
+
"items": {"type": "string"},
|
|
1405
|
+
"description": "Labels to apply to issues",
|
|
1406
|
+
},
|
|
1407
|
+
"assignees": {
|
|
1408
|
+
"type": "array",
|
|
1409
|
+
"required": False,
|
|
1410
|
+
"items": {"type": "string"},
|
|
1411
|
+
"description": "Users to assign",
|
|
1412
|
+
},
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async def send(
|
|
1416
|
+
self,
|
|
1417
|
+
message: str,
|
|
1418
|
+
event: NotificationEvent | None = None,
|
|
1419
|
+
title: str | None = None,
|
|
1420
|
+
**kwargs: Any,
|
|
1421
|
+
) -> 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)
|