truthound-dashboard 1.3.0__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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.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
+ ]