truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/api/alerts.py +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.0.dist-info}/METADATA +142 -22
- truthound_dashboard-1.4.0.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.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Window strategies for deduplication.
|
|
2
|
+
|
|
3
|
+
This module provides different windowing strategies that determine
|
|
4
|
+
how the deduplication time window is calculated.
|
|
5
|
+
|
|
6
|
+
Strategies:
|
|
7
|
+
- SlidingWindowStrategy: Rolling time window from last occurrence
|
|
8
|
+
- TumblingWindowStrategy: Fixed non-overlapping time windows
|
|
9
|
+
- SessionWindowStrategy: Gap-based windows for event bursts
|
|
10
|
+
- AdaptiveWindowStrategy: Dynamic window sizing based on load
|
|
11
|
+
|
|
12
|
+
Each strategy can be configured with different parameters to
|
|
13
|
+
suit various use cases.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import time
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any, ClassVar
|
|
24
|
+
|
|
25
|
+
from ...validation_limits import (
|
|
26
|
+
get_deduplication_limits,
|
|
27
|
+
validate_positive_int,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StrategyRegistry:
|
|
32
|
+
"""Registry for window strategies.
|
|
33
|
+
|
|
34
|
+
Provides plugin architecture for custom strategy implementations.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_strategies: ClassVar[dict[str, type["BaseWindowStrategy"]]] = {}
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def register(cls, strategy_type: str):
|
|
41
|
+
"""Decorator to register a strategy type."""
|
|
42
|
+
|
|
43
|
+
def decorator(strategy_class: type["BaseWindowStrategy"]) -> type["BaseWindowStrategy"]:
|
|
44
|
+
strategy_class.strategy_type = strategy_type
|
|
45
|
+
cls._strategies[strategy_type] = strategy_class
|
|
46
|
+
return strategy_class
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def get(cls, strategy_type: str) -> type["BaseWindowStrategy"] | None:
|
|
52
|
+
"""Get a registered strategy class by type."""
|
|
53
|
+
return cls._strategies.get(strategy_type)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(cls, strategy_type: str, **params: Any) -> "BaseWindowStrategy | None":
|
|
57
|
+
"""Create a strategy instance by type."""
|
|
58
|
+
strategy_class = cls.get(strategy_type)
|
|
59
|
+
if strategy_class is None:
|
|
60
|
+
return None
|
|
61
|
+
return strategy_class(**params)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def list_types(cls) -> list[str]:
|
|
65
|
+
"""Get list of registered strategy types."""
|
|
66
|
+
return list(cls._strategies.keys())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class BaseWindowStrategy(ABC):
|
|
71
|
+
"""Abstract base class for window strategies.
|
|
72
|
+
|
|
73
|
+
Strategies determine how the deduplication window is calculated
|
|
74
|
+
for a given fingerprint and context.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
strategy_type: ClassVar[str] = "base"
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def get_window_seconds(
|
|
81
|
+
self,
|
|
82
|
+
fingerprint: str,
|
|
83
|
+
context: dict[str, Any] | None = None,
|
|
84
|
+
) -> int:
|
|
85
|
+
"""Calculate the window duration in seconds.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
fingerprint: The notification fingerprint.
|
|
89
|
+
context: Optional context for window calculation.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Window duration in seconds.
|
|
93
|
+
"""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def get_window_key(
|
|
98
|
+
self,
|
|
99
|
+
fingerprint: str,
|
|
100
|
+
timestamp: datetime | None = None,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Generate a window-specific key for the fingerprint.
|
|
103
|
+
|
|
104
|
+
Used by tumbling windows to align entries to window boundaries.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
fingerprint: The notification fingerprint.
|
|
108
|
+
timestamp: Optional timestamp (defaults to now).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Window-aligned key.
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@StrategyRegistry.register("sliding")
|
|
117
|
+
@dataclass
|
|
118
|
+
class SlidingWindowStrategy(BaseWindowStrategy):
|
|
119
|
+
"""Sliding window strategy with validation.
|
|
120
|
+
|
|
121
|
+
The window slides with each occurrence - duplicates are suppressed
|
|
122
|
+
if they occur within `window_seconds` of the last occurrence.
|
|
123
|
+
|
|
124
|
+
This is the most common strategy for real-time deduplication.
|
|
125
|
+
|
|
126
|
+
Validation:
|
|
127
|
+
- window_seconds must be between 1 and 86400 (configurable).
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
window_seconds: Base window duration in seconds.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
window_seconds: int = 300
|
|
134
|
+
|
|
135
|
+
def __post_init__(self) -> None:
|
|
136
|
+
"""Validate window_seconds after initialization."""
|
|
137
|
+
limits = get_deduplication_limits()
|
|
138
|
+
valid, error = limits.validate_window_seconds(self.window_seconds)
|
|
139
|
+
if not valid:
|
|
140
|
+
raise ValueError(error)
|
|
141
|
+
|
|
142
|
+
def get_window_seconds(
|
|
143
|
+
self,
|
|
144
|
+
fingerprint: str,
|
|
145
|
+
context: dict[str, Any] | None = None,
|
|
146
|
+
) -> int:
|
|
147
|
+
"""Return the configured window duration."""
|
|
148
|
+
return self.window_seconds
|
|
149
|
+
|
|
150
|
+
def get_window_key(
|
|
151
|
+
self,
|
|
152
|
+
fingerprint: str,
|
|
153
|
+
timestamp: datetime | None = None,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Return the fingerprint unchanged (no window alignment)."""
|
|
156
|
+
return fingerprint
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@StrategyRegistry.register("tumbling")
|
|
160
|
+
@dataclass
|
|
161
|
+
class TumblingWindowStrategy(BaseWindowStrategy):
|
|
162
|
+
"""Tumbling window strategy with validation.
|
|
163
|
+
|
|
164
|
+
The window is divided into fixed, non-overlapping periods.
|
|
165
|
+
All events within the same period share the same window.
|
|
166
|
+
|
|
167
|
+
Useful for batch-style deduplication where you want at most
|
|
168
|
+
one notification per time period.
|
|
169
|
+
|
|
170
|
+
Validation:
|
|
171
|
+
- window_seconds must be between 1 and 86400 (configurable).
|
|
172
|
+
- align_to must be one of 'minute', 'hour', 'day'.
|
|
173
|
+
|
|
174
|
+
Attributes:
|
|
175
|
+
window_seconds: Duration of each tumbling window.
|
|
176
|
+
align_to: What to align windows to ('minute', 'hour', 'day').
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
window_seconds: int = 300
|
|
180
|
+
align_to: str = "minute"
|
|
181
|
+
|
|
182
|
+
def __post_init__(self) -> None:
|
|
183
|
+
"""Validate configuration after initialization."""
|
|
184
|
+
limits = get_deduplication_limits()
|
|
185
|
+
valid, error = limits.validate_window_seconds(self.window_seconds)
|
|
186
|
+
if not valid:
|
|
187
|
+
raise ValueError(error)
|
|
188
|
+
|
|
189
|
+
valid_alignments = ("minute", "hour", "day")
|
|
190
|
+
if self.align_to not in valid_alignments:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"align_to must be one of {valid_alignments}, got '{self.align_to}'"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def get_window_seconds(
|
|
196
|
+
self,
|
|
197
|
+
fingerprint: str,
|
|
198
|
+
context: dict[str, Any] | None = None,
|
|
199
|
+
) -> int:
|
|
200
|
+
"""Return the configured window duration."""
|
|
201
|
+
return self.window_seconds
|
|
202
|
+
|
|
203
|
+
def get_window_key(
|
|
204
|
+
self,
|
|
205
|
+
fingerprint: str,
|
|
206
|
+
timestamp: datetime | None = None,
|
|
207
|
+
) -> str:
|
|
208
|
+
"""Generate window-aligned key.
|
|
209
|
+
|
|
210
|
+
Aligns the fingerprint to the start of its tumbling window.
|
|
211
|
+
"""
|
|
212
|
+
if timestamp is None:
|
|
213
|
+
timestamp = datetime.utcnow()
|
|
214
|
+
|
|
215
|
+
# Calculate window start
|
|
216
|
+
ts = timestamp.timestamp()
|
|
217
|
+
window_start = int(ts // self.window_seconds) * self.window_seconds
|
|
218
|
+
|
|
219
|
+
return f"{fingerprint}:{window_start}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@StrategyRegistry.register("session")
|
|
223
|
+
@dataclass
|
|
224
|
+
class SessionWindowStrategy(BaseWindowStrategy):
|
|
225
|
+
"""Session window strategy with validation.
|
|
226
|
+
|
|
227
|
+
Groups events into sessions based on gaps between occurrences.
|
|
228
|
+
A new session starts if no event occurs within `gap_seconds`.
|
|
229
|
+
|
|
230
|
+
Useful for handling event bursts where you want to deduplicate
|
|
231
|
+
within a burst but allow new notifications after quiet periods.
|
|
232
|
+
|
|
233
|
+
Validation:
|
|
234
|
+
- gap_seconds must be between 1 and 86400 (configurable).
|
|
235
|
+
- max_session_seconds must be between 1 and 86400 (configurable).
|
|
236
|
+
- max_session_seconds must be >= gap_seconds.
|
|
237
|
+
|
|
238
|
+
Attributes:
|
|
239
|
+
gap_seconds: Maximum gap between events in same session.
|
|
240
|
+
max_session_seconds: Maximum session duration.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
gap_seconds: int = 60
|
|
244
|
+
max_session_seconds: int = 3600
|
|
245
|
+
|
|
246
|
+
_session_starts: dict[str, float] = field(default_factory=dict)
|
|
247
|
+
_last_events: dict[str, float] = field(default_factory=dict)
|
|
248
|
+
|
|
249
|
+
def __post_init__(self) -> None:
|
|
250
|
+
"""Validate configuration after initialization."""
|
|
251
|
+
limits = get_deduplication_limits()
|
|
252
|
+
|
|
253
|
+
# Validate gap_seconds
|
|
254
|
+
valid, error = limits.validate_window_seconds(self.gap_seconds)
|
|
255
|
+
if not valid:
|
|
256
|
+
raise ValueError(f"gap_seconds: {error}")
|
|
257
|
+
|
|
258
|
+
# Validate max_session_seconds
|
|
259
|
+
valid, error = limits.validate_window_seconds(self.max_session_seconds)
|
|
260
|
+
if not valid:
|
|
261
|
+
raise ValueError(f"max_session_seconds: {error}")
|
|
262
|
+
|
|
263
|
+
# max_session_seconds should be >= gap_seconds
|
|
264
|
+
if self.max_session_seconds < self.gap_seconds:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"max_session_seconds ({self.max_session_seconds}) must be >= "
|
|
267
|
+
f"gap_seconds ({self.gap_seconds})"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def get_window_seconds(
|
|
271
|
+
self,
|
|
272
|
+
fingerprint: str,
|
|
273
|
+
context: dict[str, Any] | None = None,
|
|
274
|
+
) -> int:
|
|
275
|
+
"""Calculate remaining session window."""
|
|
276
|
+
now = time.time()
|
|
277
|
+
last_event = self._last_events.get(fingerprint, 0)
|
|
278
|
+
|
|
279
|
+
# Check if session is still active
|
|
280
|
+
if now - last_event > self.gap_seconds:
|
|
281
|
+
# New session
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
# Return remaining session time
|
|
285
|
+
session_start = self._session_starts.get(fingerprint, now)
|
|
286
|
+
elapsed = now - session_start
|
|
287
|
+
remaining = self.max_session_seconds - elapsed
|
|
288
|
+
|
|
289
|
+
return max(0, int(remaining))
|
|
290
|
+
|
|
291
|
+
def get_window_key(
|
|
292
|
+
self,
|
|
293
|
+
fingerprint: str,
|
|
294
|
+
timestamp: datetime | None = None,
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Generate session-aligned key."""
|
|
297
|
+
now = time.time() if timestamp is None else timestamp.timestamp()
|
|
298
|
+
last_event = self._last_events.get(fingerprint, 0)
|
|
299
|
+
|
|
300
|
+
# Check if this is a new session
|
|
301
|
+
if now - last_event > self.gap_seconds:
|
|
302
|
+
self._session_starts[fingerprint] = now
|
|
303
|
+
self._last_events[fingerprint] = now
|
|
304
|
+
return f"{fingerprint}:session:{int(now)}"
|
|
305
|
+
|
|
306
|
+
# Same session
|
|
307
|
+
self._last_events[fingerprint] = now
|
|
308
|
+
session_start = self._session_starts.get(fingerprint, now)
|
|
309
|
+
return f"{fingerprint}:session:{int(session_start)}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@StrategyRegistry.register("adaptive")
|
|
313
|
+
@dataclass
|
|
314
|
+
class AdaptiveWindowStrategy(BaseWindowStrategy):
|
|
315
|
+
"""Adaptive window strategy with validation.
|
|
316
|
+
|
|
317
|
+
Dynamically adjusts window size based on event frequency.
|
|
318
|
+
High-frequency events get longer windows, low-frequency get shorter.
|
|
319
|
+
|
|
320
|
+
This helps prevent notification fatigue during incidents while
|
|
321
|
+
maintaining responsiveness during normal operations.
|
|
322
|
+
|
|
323
|
+
Validation:
|
|
324
|
+
- min_window_seconds must be between 1 and 86400 (configurable).
|
|
325
|
+
- max_window_seconds must be between 1 and 86400 (configurable).
|
|
326
|
+
- max_window_seconds must be >= min_window_seconds.
|
|
327
|
+
- scale_factor must be >= 0.1.
|
|
328
|
+
- decay_seconds must be between 1 and 86400 (configurable).
|
|
329
|
+
|
|
330
|
+
Attributes:
|
|
331
|
+
min_window_seconds: Minimum window duration.
|
|
332
|
+
max_window_seconds: Maximum window duration.
|
|
333
|
+
scale_factor: How quickly window grows with frequency.
|
|
334
|
+
decay_seconds: Time for window to decay back to minimum.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
min_window_seconds: int = 60
|
|
338
|
+
max_window_seconds: int = 3600
|
|
339
|
+
scale_factor: float = 2.0
|
|
340
|
+
decay_seconds: int = 1800
|
|
341
|
+
|
|
342
|
+
_event_counts: dict[str, int] = field(default_factory=dict)
|
|
343
|
+
_last_events: dict[str, float] = field(default_factory=dict)
|
|
344
|
+
|
|
345
|
+
def __post_init__(self) -> None:
|
|
346
|
+
"""Validate configuration after initialization."""
|
|
347
|
+
limits = get_deduplication_limits()
|
|
348
|
+
|
|
349
|
+
# Validate min_window_seconds
|
|
350
|
+
valid, error = limits.validate_window_seconds(self.min_window_seconds)
|
|
351
|
+
if not valid:
|
|
352
|
+
raise ValueError(f"min_window_seconds: {error}")
|
|
353
|
+
|
|
354
|
+
# Validate max_window_seconds
|
|
355
|
+
valid, error = limits.validate_window_seconds(self.max_window_seconds)
|
|
356
|
+
if not valid:
|
|
357
|
+
raise ValueError(f"max_window_seconds: {error}")
|
|
358
|
+
|
|
359
|
+
# Validate decay_seconds
|
|
360
|
+
valid, error = limits.validate_window_seconds(self.decay_seconds)
|
|
361
|
+
if not valid:
|
|
362
|
+
raise ValueError(f"decay_seconds: {error}")
|
|
363
|
+
|
|
364
|
+
# max_window_seconds must be >= min_window_seconds
|
|
365
|
+
if self.max_window_seconds < self.min_window_seconds:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f"max_window_seconds ({self.max_window_seconds}) must be >= "
|
|
368
|
+
f"min_window_seconds ({self.min_window_seconds})"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# scale_factor must be positive
|
|
372
|
+
if self.scale_factor < 0.1:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"scale_factor must be at least 0.1, got {self.scale_factor}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def get_window_seconds(
|
|
378
|
+
self,
|
|
379
|
+
fingerprint: str,
|
|
380
|
+
context: dict[str, Any] | None = None,
|
|
381
|
+
) -> int:
|
|
382
|
+
"""Calculate adaptive window based on event frequency."""
|
|
383
|
+
now = time.time()
|
|
384
|
+
last_event = self._last_events.get(fingerprint, 0)
|
|
385
|
+
count = self._event_counts.get(fingerprint, 0)
|
|
386
|
+
|
|
387
|
+
# Decay count over time
|
|
388
|
+
if last_event > 0:
|
|
389
|
+
elapsed = now - last_event
|
|
390
|
+
decay_factor = max(0, 1 - (elapsed / self.decay_seconds))
|
|
391
|
+
count = int(count * decay_factor)
|
|
392
|
+
|
|
393
|
+
# Calculate window based on count
|
|
394
|
+
if count == 0:
|
|
395
|
+
window = self.min_window_seconds
|
|
396
|
+
else:
|
|
397
|
+
# Logarithmic scaling
|
|
398
|
+
scale = 1 + math.log(1 + count) * self.scale_factor
|
|
399
|
+
window = int(self.min_window_seconds * scale)
|
|
400
|
+
|
|
401
|
+
# Clamp to bounds
|
|
402
|
+
return min(self.max_window_seconds, max(self.min_window_seconds, window))
|
|
403
|
+
|
|
404
|
+
def get_window_key(
|
|
405
|
+
self,
|
|
406
|
+
fingerprint: str,
|
|
407
|
+
timestamp: datetime | None = None,
|
|
408
|
+
) -> str:
|
|
409
|
+
"""Return fingerprint and update counts."""
|
|
410
|
+
now = time.time() if timestamp is None else timestamp.timestamp()
|
|
411
|
+
|
|
412
|
+
# Update event tracking
|
|
413
|
+
count = self._event_counts.get(fingerprint, 0)
|
|
414
|
+
self._event_counts[fingerprint] = count + 1
|
|
415
|
+
self._last_events[fingerprint] = now
|
|
416
|
+
|
|
417
|
+
return fingerprint
|
|
418
|
+
|
|
419
|
+
def reset(self, fingerprint: str) -> None:
|
|
420
|
+
"""Reset tracking for a fingerprint."""
|
|
421
|
+
self._event_counts.pop(fingerprint, None)
|
|
422
|
+
self._last_events.pop(fingerprint, None)
|
|
@@ -44,6 +44,7 @@ from .base import (
|
|
|
44
44
|
)
|
|
45
45
|
from .events import (
|
|
46
46
|
DriftDetectedEvent,
|
|
47
|
+
SchemaChangedEvent,
|
|
47
48
|
ScheduleFailedEvent,
|
|
48
49
|
TestNotificationEvent,
|
|
49
50
|
ValidationFailedEvent,
|
|
@@ -159,6 +160,7 @@ class NotificationDispatcher:
|
|
|
159
160
|
"validation_failed": ["validation_failed", "critical_issues", "high_issues"],
|
|
160
161
|
"schedule_failed": ["schedule_failed", "validation_failed"],
|
|
161
162
|
"drift_detected": ["drift_detected"],
|
|
163
|
+
"schema_changed": ["schema_changed", "breaking_schema_change"],
|
|
162
164
|
"test": [],
|
|
163
165
|
}
|
|
164
166
|
|
|
@@ -202,6 +204,11 @@ class NotificationDispatcher:
|
|
|
202
204
|
# Could add threshold checks here from rule.condition_config
|
|
203
205
|
pass
|
|
204
206
|
|
|
207
|
+
elif isinstance(event, SchemaChangedEvent):
|
|
208
|
+
# Check for breaking change condition
|
|
209
|
+
if rule.condition == "breaking_schema_change" and not event.has_breaking_changes:
|
|
210
|
+
return False
|
|
211
|
+
|
|
205
212
|
return True
|
|
206
213
|
|
|
207
214
|
async def _send_to_channel(
|
|
@@ -391,6 +398,42 @@ class NotificationDispatcher:
|
|
|
391
398
|
|
|
392
399
|
return await self.dispatch(event)
|
|
393
400
|
|
|
401
|
+
async def notify_schema_changed(
|
|
402
|
+
self,
|
|
403
|
+
source_id: str,
|
|
404
|
+
source_name: str,
|
|
405
|
+
from_version: int | None,
|
|
406
|
+
to_version: int,
|
|
407
|
+
total_changes: int,
|
|
408
|
+
breaking_changes: int = 0,
|
|
409
|
+
changes: list[dict[str, Any]] | None = None,
|
|
410
|
+
) -> list[NotificationResult]:
|
|
411
|
+
"""Send notifications for schema changes.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
source_id: ID of the source with schema changes.
|
|
415
|
+
source_name: Name of the source.
|
|
416
|
+
from_version: Previous schema version (None if first version).
|
|
417
|
+
to_version: New schema version.
|
|
418
|
+
total_changes: Total number of changes detected.
|
|
419
|
+
breaking_changes: Number of breaking changes.
|
|
420
|
+
changes: Optional list of change details.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
List of delivery results.
|
|
424
|
+
"""
|
|
425
|
+
event = SchemaChangedEvent(
|
|
426
|
+
source_id=source_id,
|
|
427
|
+
source_name=source_name,
|
|
428
|
+
from_version=from_version,
|
|
429
|
+
to_version=to_version,
|
|
430
|
+
total_changes=total_changes,
|
|
431
|
+
breaking_changes=breaking_changes,
|
|
432
|
+
changes=changes or [],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return await self.dispatch(event)
|
|
436
|
+
|
|
394
437
|
async def test_channel(self, channel_id: str) -> NotificationResult:
|
|
395
438
|
"""Send a test notification to a specific channel.
|
|
396
439
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Escalation engine for notification management.
|
|
2
|
+
|
|
3
|
+
This module provides a multi-level escalation system for managing
|
|
4
|
+
alerts that require attention at different organizational levels.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Multi-level escalation policies
|
|
8
|
+
- State machine for incident tracking
|
|
9
|
+
- Configurable delay between escalation levels
|
|
10
|
+
- Support for user, group, and on-call targets
|
|
11
|
+
- Auto-resolution on success
|
|
12
|
+
|
|
13
|
+
State Machine:
|
|
14
|
+
PENDING -> TRIGGERED -> ESCALATED -> RESOLVED
|
|
15
|
+
| ^
|
|
16
|
+
v |
|
|
17
|
+
ACKNOWLEDGED ------+
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
from truthound_dashboard.core.notifications.escalation import (
|
|
21
|
+
EscalationEngine,
|
|
22
|
+
EscalationPolicy,
|
|
23
|
+
EscalationLevel,
|
|
24
|
+
EscalationTarget,
|
|
25
|
+
TargetType,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
policy = EscalationPolicy(
|
|
29
|
+
name="critical_alerts",
|
|
30
|
+
levels=[
|
|
31
|
+
EscalationLevel(
|
|
32
|
+
level=1,
|
|
33
|
+
delay_minutes=0,
|
|
34
|
+
targets=[
|
|
35
|
+
EscalationTarget(type=TargetType.USER, identifier="team-lead", channel="slack")
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
EscalationLevel(
|
|
39
|
+
level=2,
|
|
40
|
+
delay_minutes=15,
|
|
41
|
+
targets=[
|
|
42
|
+
EscalationTarget(type=TargetType.GROUP, identifier="managers", channel="pagerduty")
|
|
43
|
+
],
|
|
44
|
+
),
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
engine = EscalationEngine(policy=policy)
|
|
49
|
+
await engine.trigger("incident-123", context={"severity": "critical"})
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from .engine import EscalationEngine, EscalationEngineConfig
|
|
53
|
+
from .models import (
|
|
54
|
+
EscalationEvent,
|
|
55
|
+
EscalationIncident,
|
|
56
|
+
EscalationLevel,
|
|
57
|
+
EscalationPolicy,
|
|
58
|
+
EscalationState,
|
|
59
|
+
EscalationTarget,
|
|
60
|
+
StateTransition,
|
|
61
|
+
TargetType,
|
|
62
|
+
)
|
|
63
|
+
from .backends import (
|
|
64
|
+
BackendType,
|
|
65
|
+
InMemorySchedulerBackend,
|
|
66
|
+
JobData,
|
|
67
|
+
JobExecutionResult,
|
|
68
|
+
JobState,
|
|
69
|
+
MisfirePolicy,
|
|
70
|
+
SchedulerBackend,
|
|
71
|
+
SchedulerBackendConfig,
|
|
72
|
+
SQLAlchemySchedulerBackend,
|
|
73
|
+
create_scheduler_backend,
|
|
74
|
+
)
|
|
75
|
+
from .scheduler import (
|
|
76
|
+
DefaultEscalationHandler,
|
|
77
|
+
EscalationHandler,
|
|
78
|
+
EscalationResult,
|
|
79
|
+
EscalationSchedulerConfig,
|
|
80
|
+
EscalationSchedulerService,
|
|
81
|
+
EscalationStrategy,
|
|
82
|
+
ImmediateEscalationStrategy,
|
|
83
|
+
LoggingEscalationHandler,
|
|
84
|
+
TimeBasedEscalationStrategy,
|
|
85
|
+
get_escalation_scheduler,
|
|
86
|
+
reset_escalation_scheduler,
|
|
87
|
+
start_escalation_scheduler,
|
|
88
|
+
stop_escalation_scheduler,
|
|
89
|
+
)
|
|
90
|
+
from .state_machine import EscalationStateMachine
|
|
91
|
+
from .stores import (
|
|
92
|
+
BaseEscalationStore,
|
|
93
|
+
EscalationMetrics,
|
|
94
|
+
EscalationStoreType,
|
|
95
|
+
InMemoryEscalationStore,
|
|
96
|
+
RedisEscalationStore,
|
|
97
|
+
SQLiteEscalationStore,
|
|
98
|
+
create_escalation_store,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
# Models
|
|
103
|
+
"EscalationState",
|
|
104
|
+
"TargetType",
|
|
105
|
+
"EscalationTarget",
|
|
106
|
+
"EscalationLevel",
|
|
107
|
+
"EscalationPolicy",
|
|
108
|
+
"EscalationIncident",
|
|
109
|
+
"EscalationEvent",
|
|
110
|
+
"StateTransition",
|
|
111
|
+
# State Machine
|
|
112
|
+
"EscalationStateMachine",
|
|
113
|
+
# Stores
|
|
114
|
+
"BaseEscalationStore",
|
|
115
|
+
"EscalationMetrics",
|
|
116
|
+
"EscalationStoreType",
|
|
117
|
+
"InMemoryEscalationStore",
|
|
118
|
+
"RedisEscalationStore",
|
|
119
|
+
"SQLiteEscalationStore",
|
|
120
|
+
"create_escalation_store",
|
|
121
|
+
# Engine
|
|
122
|
+
"EscalationEngine",
|
|
123
|
+
"EscalationEngineConfig",
|
|
124
|
+
# Scheduler Backends
|
|
125
|
+
"BackendType",
|
|
126
|
+
"JobState",
|
|
127
|
+
"MisfirePolicy",
|
|
128
|
+
"SchedulerBackendConfig",
|
|
129
|
+
"JobData",
|
|
130
|
+
"JobExecutionResult",
|
|
131
|
+
"SchedulerBackend",
|
|
132
|
+
"InMemorySchedulerBackend",
|
|
133
|
+
"SQLAlchemySchedulerBackend",
|
|
134
|
+
"create_scheduler_backend",
|
|
135
|
+
# Scheduler
|
|
136
|
+
"EscalationSchedulerService",
|
|
137
|
+
"EscalationSchedulerConfig",
|
|
138
|
+
"EscalationHandler",
|
|
139
|
+
"EscalationResult",
|
|
140
|
+
"EscalationStrategy",
|
|
141
|
+
"DefaultEscalationHandler",
|
|
142
|
+
"LoggingEscalationHandler",
|
|
143
|
+
"TimeBasedEscalationStrategy",
|
|
144
|
+
"ImmediateEscalationStrategy",
|
|
145
|
+
"get_escalation_scheduler",
|
|
146
|
+
"reset_escalation_scheduler",
|
|
147
|
+
"start_escalation_scheduler",
|
|
148
|
+
"stop_escalation_scheduler",
|
|
149
|
+
]
|