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.
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.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
  162. truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,583 @@
1
+ """Specialized metrics collectors for notification subsystems.
2
+
3
+ This module provides thread-safe metrics collectors for:
4
+ - DeduplicationMetrics: Track deduplication rates and active fingerprints
5
+ - ThrottlingMetrics: Track throttling rates and window counts
6
+ - EscalationMetrics: Track incidents by state and resolution times
7
+
8
+ All collectors use asyncio.Lock for thread-safe operations.
9
+
10
+ Example:
11
+ # Create metrics collector
12
+ dedup_metrics = DeduplicationMetrics()
13
+
14
+ # Record events
15
+ await dedup_metrics.record_received()
16
+ if is_duplicate:
17
+ await dedup_metrics.record_deduplicated()
18
+ else:
19
+ await dedup_metrics.record_passed()
20
+
21
+ # Get statistics
22
+ stats = await dedup_metrics.get_stats()
23
+ print(f"Deduplication rate: {stats['dedup_rate']:.2%}")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import Any
32
+
33
+
34
+ @dataclass
35
+ class DeduplicationStats:
36
+ """Statistics from deduplication metrics.
37
+
38
+ Attributes:
39
+ total_received: Total notifications received.
40
+ total_deduplicated: Total notifications deduplicated (skipped).
41
+ total_passed: Total notifications passed through.
42
+ dedup_rate: Deduplication rate (0.0 to 1.0).
43
+ active_fingerprints: Number of active fingerprints being tracked.
44
+ """
45
+
46
+ total_received: int
47
+ total_deduplicated: int
48
+ total_passed: int
49
+ dedup_rate: float
50
+ active_fingerprints: int
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ """Serialize to dictionary."""
54
+ return {
55
+ "total_received": self.total_received,
56
+ "total_deduplicated": self.total_deduplicated,
57
+ "total_passed": self.total_passed,
58
+ "dedup_rate": self.dedup_rate,
59
+ "active_fingerprints": self.active_fingerprints,
60
+ }
61
+
62
+
63
+ class DeduplicationMetrics:
64
+ """Thread-safe metrics collector for deduplication.
65
+
66
+ Tracks total notifications received, deduplicated, and passed through
67
+ the deduplication service.
68
+
69
+ Example:
70
+ metrics = DeduplicationMetrics()
71
+
72
+ # Record a received notification
73
+ await metrics.record_received()
74
+
75
+ # Record deduplication decision
76
+ if is_duplicate:
77
+ await metrics.record_deduplicated()
78
+ else:
79
+ await metrics.record_passed()
80
+
81
+ # Get current stats
82
+ stats = await metrics.get_stats()
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ """Initialize deduplication metrics."""
87
+ self._lock = asyncio.Lock()
88
+ self._total_received: int = 0
89
+ self._total_deduplicated: int = 0
90
+ self._total_passed: int = 0
91
+ self._active_fingerprints: set[str] = set()
92
+
93
+ async def record_received(self, fingerprint: str | None = None) -> None:
94
+ """Record a notification received for deduplication check.
95
+
96
+ Args:
97
+ fingerprint: Optional fingerprint to track as active.
98
+ """
99
+ async with self._lock:
100
+ self._total_received += 1
101
+ if fingerprint:
102
+ self._active_fingerprints.add(fingerprint)
103
+
104
+ async def record_deduplicated(self, fingerprint: str | None = None) -> None:
105
+ """Record a notification that was deduplicated (skipped).
106
+
107
+ Args:
108
+ fingerprint: Optional fingerprint that was deduplicated.
109
+ """
110
+ async with self._lock:
111
+ self._total_deduplicated += 1
112
+
113
+ async def record_passed(self, fingerprint: str | None = None) -> None:
114
+ """Record a notification that passed deduplication.
115
+
116
+ Args:
117
+ fingerprint: Optional fingerprint that passed.
118
+ """
119
+ async with self._lock:
120
+ self._total_passed += 1
121
+
122
+ async def remove_fingerprint(self, fingerprint: str) -> None:
123
+ """Remove a fingerprint from active tracking.
124
+
125
+ Args:
126
+ fingerprint: Fingerprint to remove.
127
+ """
128
+ async with self._lock:
129
+ self._active_fingerprints.discard(fingerprint)
130
+
131
+ async def clear_fingerprints(self) -> None:
132
+ """Clear all active fingerprints."""
133
+ async with self._lock:
134
+ self._active_fingerprints.clear()
135
+
136
+ async def get_stats(self) -> DeduplicationStats:
137
+ """Get current deduplication statistics.
138
+
139
+ Returns:
140
+ DeduplicationStats with current metrics.
141
+ """
142
+ async with self._lock:
143
+ total_received = self._total_received
144
+ total_deduplicated = self._total_deduplicated
145
+ total_passed = self._total_passed
146
+ active_fingerprints = len(self._active_fingerprints)
147
+
148
+ # Calculate dedup rate
149
+ dedup_rate = 0.0
150
+ if total_received > 0:
151
+ dedup_rate = total_deduplicated / total_received
152
+
153
+ return DeduplicationStats(
154
+ total_received=total_received,
155
+ total_deduplicated=total_deduplicated,
156
+ total_passed=total_passed,
157
+ dedup_rate=dedup_rate,
158
+ active_fingerprints=active_fingerprints,
159
+ )
160
+
161
+ async def reset(self) -> None:
162
+ """Reset all counters and fingerprints."""
163
+ async with self._lock:
164
+ self._total_received = 0
165
+ self._total_deduplicated = 0
166
+ self._total_passed = 0
167
+ self._active_fingerprints.clear()
168
+
169
+
170
+ @dataclass
171
+ class ThrottlingStats:
172
+ """Statistics from throttling metrics.
173
+
174
+ Attributes:
175
+ total_received: Total requests received.
176
+ total_throttled: Total requests throttled (rejected).
177
+ total_passed: Total requests passed through.
178
+ throttle_rate: Throttle rate (0.0 to 1.0).
179
+ current_window_count: Number of requests in current window.
180
+ """
181
+
182
+ total_received: int
183
+ total_throttled: int
184
+ total_passed: int
185
+ throttle_rate: float
186
+ current_window_count: int
187
+
188
+ def to_dict(self) -> dict[str, Any]:
189
+ """Serialize to dictionary."""
190
+ return {
191
+ "total_received": self.total_received,
192
+ "total_throttled": self.total_throttled,
193
+ "total_passed": self.total_passed,
194
+ "throttle_rate": self.throttle_rate,
195
+ "current_window_count": self.current_window_count,
196
+ }
197
+
198
+
199
+ @dataclass
200
+ class WindowCount:
201
+ """Tracks counts within a time window.
202
+
203
+ Attributes:
204
+ window_start: Start timestamp of the window.
205
+ count: Number of requests in this window.
206
+ """
207
+
208
+ window_start: datetime
209
+ count: int = 0
210
+
211
+
212
+ class ThrottlingMetrics:
213
+ """Thread-safe metrics collector for throttling.
214
+
215
+ Tracks total requests received, throttled, and passed through
216
+ the throttling service. Also tracks current window counts.
217
+
218
+ Example:
219
+ metrics = ThrottlingMetrics(window_seconds=60)
220
+
221
+ # Record a received request
222
+ await metrics.record_received()
223
+
224
+ # Record throttling decision
225
+ if is_throttled:
226
+ await metrics.record_throttled()
227
+ else:
228
+ await metrics.record_passed()
229
+
230
+ # Get current stats
231
+ stats = await metrics.get_stats()
232
+ """
233
+
234
+ def __init__(self, window_seconds: int = 60) -> None:
235
+ """Initialize throttling metrics.
236
+
237
+ Args:
238
+ window_seconds: Window duration for current_window_count tracking.
239
+ """
240
+ self._lock = asyncio.Lock()
241
+ self._total_received: int = 0
242
+ self._total_throttled: int = 0
243
+ self._total_passed: int = 0
244
+ self._window_seconds = window_seconds
245
+ self._current_window: WindowCount | None = None
246
+
247
+ def _get_window_start(self, now: datetime) -> datetime:
248
+ """Get the start of the current window.
249
+
250
+ Args:
251
+ now: Current timestamp.
252
+
253
+ Returns:
254
+ Window start timestamp.
255
+ """
256
+ # Align to window boundaries
257
+ timestamp = now.timestamp()
258
+ window_start_ts = int(timestamp // self._window_seconds) * self._window_seconds
259
+ return datetime.fromtimestamp(window_start_ts)
260
+
261
+ async def record_received(self) -> None:
262
+ """Record a request received for throttle check."""
263
+ async with self._lock:
264
+ self._total_received += 1
265
+ self._increment_window_count()
266
+
267
+ async def record_throttled(self) -> None:
268
+ """Record a request that was throttled (rejected)."""
269
+ async with self._lock:
270
+ self._total_throttled += 1
271
+
272
+ async def record_passed(self) -> None:
273
+ """Record a request that passed throttling."""
274
+ async with self._lock:
275
+ self._total_passed += 1
276
+
277
+ def _increment_window_count(self) -> None:
278
+ """Increment the current window count (must be called with lock held)."""
279
+ now = datetime.utcnow()
280
+ window_start = self._get_window_start(now)
281
+
282
+ if self._current_window is None or self._current_window.window_start != window_start:
283
+ # Start new window
284
+ self._current_window = WindowCount(window_start=window_start, count=1)
285
+ else:
286
+ # Increment existing window
287
+ self._current_window.count += 1
288
+
289
+ async def get_stats(self) -> ThrottlingStats:
290
+ """Get current throttling statistics.
291
+
292
+ Returns:
293
+ ThrottlingStats with current metrics.
294
+ """
295
+ async with self._lock:
296
+ total_received = self._total_received
297
+ total_throttled = self._total_throttled
298
+ total_passed = self._total_passed
299
+
300
+ # Get current window count
301
+ now = datetime.utcnow()
302
+ window_start = self._get_window_start(now)
303
+ current_window_count = 0
304
+ if (
305
+ self._current_window is not None
306
+ and self._current_window.window_start == window_start
307
+ ):
308
+ current_window_count = self._current_window.count
309
+
310
+ # Calculate throttle rate
311
+ throttle_rate = 0.0
312
+ if total_received > 0:
313
+ throttle_rate = total_throttled / total_received
314
+
315
+ return ThrottlingStats(
316
+ total_received=total_received,
317
+ total_throttled=total_throttled,
318
+ total_passed=total_passed,
319
+ throttle_rate=throttle_rate,
320
+ current_window_count=current_window_count,
321
+ )
322
+
323
+ async def reset(self) -> None:
324
+ """Reset all counters."""
325
+ async with self._lock:
326
+ self._total_received = 0
327
+ self._total_throttled = 0
328
+ self._total_passed = 0
329
+ self._current_window = None
330
+
331
+
332
+ @dataclass
333
+ class EscalationStats:
334
+ """Statistics from escalation metrics.
335
+
336
+ Attributes:
337
+ total_incidents: Total incidents tracked.
338
+ by_state: Count of incidents by state.
339
+ active_count: Number of active (non-resolved) incidents.
340
+ avg_resolution_time: Average resolution time in seconds.
341
+ """
342
+
343
+ total_incidents: int
344
+ by_state: dict[str, int]
345
+ active_count: int
346
+ avg_resolution_time: float
347
+
348
+ def to_dict(self) -> dict[str, Any]:
349
+ """Serialize to dictionary."""
350
+ return {
351
+ "total_incidents": self.total_incidents,
352
+ "by_state": self.by_state,
353
+ "active_count": self.active_count,
354
+ "avg_resolution_time": self.avg_resolution_time,
355
+ }
356
+
357
+
358
+ @dataclass
359
+ class IncidentRecord:
360
+ """Record of an incident for metrics tracking.
361
+
362
+ Attributes:
363
+ incident_id: Unique incident identifier.
364
+ state: Current incident state.
365
+ created_at: When incident was created.
366
+ resolved_at: When incident was resolved (if applicable).
367
+ """
368
+
369
+ incident_id: str
370
+ state: str
371
+ created_at: datetime
372
+ resolved_at: datetime | None = None
373
+
374
+
375
+ class EscalationMetrics:
376
+ """Thread-safe metrics collector for escalation.
377
+
378
+ Tracks incidents by state and calculates resolution times.
379
+
380
+ Example:
381
+ metrics = EscalationMetrics()
382
+
383
+ # Record incident creation
384
+ await metrics.record_incident_created("incident-1")
385
+
386
+ # Update incident state
387
+ await metrics.record_state_change("incident-1", "triggered")
388
+ await metrics.record_state_change("incident-1", "acknowledged")
389
+
390
+ # Record resolution
391
+ await metrics.record_incident_resolved("incident-1")
392
+
393
+ # Get current stats
394
+ stats = await metrics.get_stats()
395
+ """
396
+
397
+ def __init__(self) -> None:
398
+ """Initialize escalation metrics."""
399
+ self._lock = asyncio.Lock()
400
+ self._incidents: dict[str, IncidentRecord] = {}
401
+ self._resolution_times: list[float] = []
402
+
403
+ async def record_incident_created(
404
+ self,
405
+ incident_id: str,
406
+ initial_state: str = "pending",
407
+ ) -> None:
408
+ """Record a new incident creation.
409
+
410
+ Args:
411
+ incident_id: Unique incident identifier.
412
+ initial_state: Initial state of the incident.
413
+ """
414
+ async with self._lock:
415
+ self._incidents[incident_id] = IncidentRecord(
416
+ incident_id=incident_id,
417
+ state=initial_state,
418
+ created_at=datetime.utcnow(),
419
+ )
420
+
421
+ async def record_state_change(
422
+ self,
423
+ incident_id: str,
424
+ new_state: str,
425
+ ) -> None:
426
+ """Record an incident state change.
427
+
428
+ Args:
429
+ incident_id: Incident identifier.
430
+ new_state: New state of the incident.
431
+ """
432
+ async with self._lock:
433
+ if incident_id in self._incidents:
434
+ self._incidents[incident_id].state = new_state
435
+
436
+ async def record_incident_resolved(
437
+ self,
438
+ incident_id: str,
439
+ ) -> None:
440
+ """Record an incident resolution.
441
+
442
+ Args:
443
+ incident_id: Incident identifier.
444
+ """
445
+ async with self._lock:
446
+ if incident_id in self._incidents:
447
+ incident = self._incidents[incident_id]
448
+ incident.state = "resolved"
449
+ incident.resolved_at = datetime.utcnow()
450
+
451
+ # Calculate resolution time
452
+ resolution_time = (
453
+ incident.resolved_at - incident.created_at
454
+ ).total_seconds()
455
+ self._resolution_times.append(resolution_time)
456
+
457
+ async def remove_incident(self, incident_id: str) -> None:
458
+ """Remove an incident from tracking.
459
+
460
+ Args:
461
+ incident_id: Incident identifier.
462
+ """
463
+ async with self._lock:
464
+ self._incidents.pop(incident_id, None)
465
+
466
+ async def get_stats(self) -> EscalationStats:
467
+ """Get current escalation statistics.
468
+
469
+ Returns:
470
+ EscalationStats with current metrics.
471
+ """
472
+ async with self._lock:
473
+ total_incidents = len(self._incidents)
474
+
475
+ # Count by state
476
+ by_state: dict[str, int] = {}
477
+ active_count = 0
478
+ for incident in self._incidents.values():
479
+ state = incident.state
480
+ by_state[state] = by_state.get(state, 0) + 1
481
+ if state != "resolved":
482
+ active_count += 1
483
+
484
+ # Calculate average resolution time
485
+ avg_resolution_time = 0.0
486
+ if self._resolution_times:
487
+ avg_resolution_time = sum(self._resolution_times) / len(
488
+ self._resolution_times
489
+ )
490
+
491
+ return EscalationStats(
492
+ total_incidents=total_incidents,
493
+ by_state=by_state,
494
+ active_count=active_count,
495
+ avg_resolution_time=avg_resolution_time,
496
+ )
497
+
498
+ async def reset(self) -> None:
499
+ """Reset all incident tracking and resolution times."""
500
+ async with self._lock:
501
+ self._incidents.clear()
502
+ self._resolution_times.clear()
503
+
504
+
505
+ # Convenience alias for combined metrics
506
+ @dataclass
507
+ class NotificationMetrics:
508
+ """Combined metrics from all notification subsystems.
509
+
510
+ Attributes:
511
+ deduplication: Deduplication statistics.
512
+ throttling: Throttling statistics.
513
+ escalation: Escalation statistics.
514
+ """
515
+
516
+ deduplication: DeduplicationStats
517
+ throttling: ThrottlingStats
518
+ escalation: EscalationStats
519
+
520
+ def to_dict(self) -> dict[str, Any]:
521
+ """Serialize to dictionary."""
522
+ return {
523
+ "deduplication": self.deduplication.to_dict(),
524
+ "throttling": self.throttling.to_dict(),
525
+ "escalation": self.escalation.to_dict(),
526
+ }
527
+
528
+
529
+ class MetricsCollector:
530
+ """Aggregated metrics collector for notification subsystems.
531
+
532
+ Provides a single interface to collect metrics from deduplication,
533
+ throttling, and escalation services.
534
+
535
+ Example:
536
+ collector = MetricsCollector()
537
+
538
+ # Record deduplication metrics
539
+ await collector.deduplication.record_received()
540
+ await collector.deduplication.record_deduplicated()
541
+
542
+ # Record throttling metrics
543
+ await collector.throttling.record_received()
544
+ await collector.throttling.record_passed()
545
+
546
+ # Record escalation metrics
547
+ await collector.escalation.record_incident_created("inc-1")
548
+
549
+ # Get combined stats
550
+ stats = await collector.get_all_stats()
551
+ """
552
+
553
+ def __init__(self, throttling_window_seconds: int = 60) -> None:
554
+ """Initialize metrics collector.
555
+
556
+ Args:
557
+ throttling_window_seconds: Window duration for throttling metrics.
558
+ """
559
+ self.deduplication = DeduplicationMetrics()
560
+ self.throttling = ThrottlingMetrics(window_seconds=throttling_window_seconds)
561
+ self.escalation = EscalationMetrics()
562
+
563
+ async def get_all_stats(self) -> NotificationMetrics:
564
+ """Get combined statistics from all collectors.
565
+
566
+ Returns:
567
+ NotificationMetrics with stats from all subsystems.
568
+ """
569
+ dedup_stats = await self.deduplication.get_stats()
570
+ throttle_stats = await self.throttling.get_stats()
571
+ escalation_stats = await self.escalation.get_stats()
572
+
573
+ return NotificationMetrics(
574
+ deduplication=dedup_stats,
575
+ throttling=throttle_stats,
576
+ escalation=escalation_stats,
577
+ )
578
+
579
+ async def reset_all(self) -> None:
580
+ """Reset all metrics collectors."""
581
+ await self.deduplication.reset()
582
+ await self.throttling.reset()
583
+ await self.escalation.reset()