kryten-robot 0.6.9__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.
@@ -0,0 +1,209 @@
1
+ """Connection Watchdog for detecting stale CyTube connections.
2
+
3
+ This module provides a watchdog that monitors connection health by tracking
4
+ regular events from CyTube (like media updates). If no events are received
5
+ for a configured timeout period, the watchdog triggers a reconnection.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from collections.abc import Awaitable, Callable
11
+ from datetime import datetime
12
+
13
+
14
+ class ConnectionWatchdog:
15
+ """Monitors connection health using event heartbeats.
16
+
17
+ CyTube servers send media update events on a regular schedule (typically
18
+ every few seconds as media plays). This watchdog tracks these events and
19
+ triggers a reconnection if too much time passes without receiving them.
20
+
21
+ Attributes:
22
+ timeout: Seconds without events before triggering reconnection.
23
+ logger: Logger instance for structured logging.
24
+ on_timeout: Async callback to invoke when timeout occurs.
25
+
26
+ Examples:
27
+ >>> async def handle_timeout():
28
+ ... print("Connection stale, reconnecting...")
29
+ ... await connector.disconnect()
30
+ ... await connector.connect()
31
+ >>>
32
+ >>> watchdog = ConnectionWatchdog(
33
+ ... timeout=60.0,
34
+ ... on_timeout=handle_timeout,
35
+ ... logger=logger
36
+ ... )
37
+ >>> await watchdog.start()
38
+ >>>
39
+ >>> # Feed events to watchdog
40
+ >>> watchdog.pet() # Call on each received event
41
+ >>>
42
+ >>> await watchdog.stop()
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ timeout: float,
48
+ on_timeout: Callable[[], Awaitable[None]],
49
+ logger: logging.Logger,
50
+ enabled: bool = True
51
+ ):
52
+ """Initialize connection watchdog.
53
+
54
+ Args:
55
+ timeout: Seconds without events before triggering timeout.
56
+ on_timeout: Async callback to invoke on timeout.
57
+ logger: Logger for structured output.
58
+ enabled: Whether watchdog is enabled (default: True).
59
+ """
60
+ self.timeout = timeout
61
+ self.on_timeout = on_timeout
62
+ self.logger = logger
63
+ self.enabled = enabled
64
+
65
+ self._last_event: datetime | None = None
66
+ self._task: asyncio.Task | None = None
67
+ self._running = False
68
+ self._timeouts_triggered = 0
69
+
70
+ async def start(self) -> None:
71
+ """Start watchdog monitoring.
72
+
73
+ Launches background task that periodically checks event freshness.
74
+ """
75
+ if not self.enabled:
76
+ self.logger.info("Watchdog disabled in configuration")
77
+ return
78
+
79
+ if self._running:
80
+ self.logger.warning("Watchdog already running")
81
+ return
82
+
83
+ self._running = True
84
+ self._last_event = datetime.now()
85
+ self._task = asyncio.create_task(self._monitor_loop())
86
+
87
+ self.logger.info(
88
+ f"Connection watchdog started (timeout: {self.timeout}s)"
89
+ )
90
+
91
+ async def stop(self) -> None:
92
+ """Stop watchdog monitoring.
93
+
94
+ Cancels background task and cleans up resources.
95
+ """
96
+ if not self._running:
97
+ return
98
+
99
+ self._running = False
100
+
101
+ if self._task and not self._task.done():
102
+ self._task.cancel()
103
+ try:
104
+ await self._task
105
+ except asyncio.CancelledError:
106
+ pass
107
+ self._task = None
108
+
109
+ self.logger.info(
110
+ f"Connection watchdog stopped "
111
+ f"(timeouts triggered: {self._timeouts_triggered})"
112
+ )
113
+
114
+ def pet(self) -> None:
115
+ """Reset watchdog timer (called when event received).
116
+
117
+ Call this method whenever a relevant event is received from CyTube
118
+ to indicate the connection is still alive.
119
+
120
+ Examples:
121
+ >>> watchdog.pet() # Connection is healthy
122
+ """
123
+ if not self.enabled or not self._running:
124
+ return
125
+
126
+ self._last_event = datetime.now()
127
+
128
+ def is_stale(self) -> bool:
129
+ """Check if connection appears stale.
130
+
131
+ Returns:
132
+ True if timeout period has elapsed without events.
133
+ """
134
+ if not self.enabled or self._last_event is None:
135
+ return False
136
+
137
+ elapsed = (datetime.now() - self._last_event).total_seconds()
138
+ return elapsed >= self.timeout
139
+
140
+ def time_since_last_event(self) -> float:
141
+ """Get seconds since last event.
142
+
143
+ Returns:
144
+ Seconds since last event, or 0.0 if no events yet.
145
+ """
146
+ if self._last_event is None:
147
+ return 0.0
148
+
149
+ return (datetime.now() - self._last_event).total_seconds()
150
+
151
+ @property
152
+ def stats(self) -> dict:
153
+ """Get watchdog statistics.
154
+
155
+ Returns:
156
+ Dictionary with timeout and health metrics.
157
+ """
158
+ return {
159
+ 'enabled': self.enabled,
160
+ 'timeout_seconds': self.timeout,
161
+ 'timeouts_triggered': self._timeouts_triggered,
162
+ 'seconds_since_last_event': self.time_since_last_event(),
163
+ 'is_stale': self.is_stale()
164
+ }
165
+
166
+ async def _monitor_loop(self) -> None:
167
+ """Background task monitoring connection health.
168
+
169
+ Periodically checks if timeout has elapsed and triggers callback
170
+ if connection appears stale.
171
+ """
172
+ check_interval = min(5.0, self.timeout / 4) # Check 4x per timeout period
173
+
174
+ try:
175
+ while self._running:
176
+ await asyncio.sleep(check_interval)
177
+
178
+ if not self._running:
179
+ break
180
+
181
+ if self.is_stale():
182
+ self.logger.warning(
183
+ f"Connection watchdog timeout triggered "
184
+ f"({self.time_since_last_event():.1f}s since last event)"
185
+ )
186
+
187
+ self._timeouts_triggered += 1
188
+
189
+ # Invoke timeout callback
190
+ try:
191
+ await self.on_timeout()
192
+ except Exception as e:
193
+ self.logger.error(
194
+ f"Error in watchdog timeout handler: {e}",
195
+ exc_info=True
196
+ )
197
+
198
+ # Reset timer after callback
199
+ self._last_event = datetime.now()
200
+
201
+ except asyncio.CancelledError:
202
+ pass
203
+ except Exception as e:
204
+ self.logger.error(f"Watchdog monitor loop error: {e}", exc_info=True)
205
+ finally:
206
+ self.logger.debug("Watchdog monitor loop stopped")
207
+
208
+
209
+ __all__ = ["ConnectionWatchdog"]
kryten/correlation.py ADDED
@@ -0,0 +1,241 @@
1
+ """Correlation ID tracking for distributed tracing.
2
+
3
+ This module provides correlation ID generation and propagation throughout the
4
+ event pipeline, enabling distributed tracing and debugging across Kryten
5
+ components and downstream consumers.
6
+
7
+ Correlation IDs are UUID4 strings that uniquely identify an event's journey
8
+ through the system, from Socket.IO receipt through NATS publishing.
9
+
10
+ Examples:
11
+ Basic usage with context management:
12
+ >>> from bot.kryten.correlation import CorrelationContext
13
+ >>> with CorrelationContext() as corr_id:
14
+ ... logger.info("Processing event") # corr_id in log
15
+ ... process_event()
16
+
17
+ Manual context control:
18
+ >>> from bot.kryten.correlation import (
19
+ ... generate_correlation_id,
20
+ ... set_correlation_context,
21
+ ... get_correlation_context,
22
+ ... )
23
+ >>> correlation_id = generate_correlation_id()
24
+ >>> set_correlation_context(correlation_id)
25
+ >>> logger.info("Event received") # correlation_id in log
26
+
27
+ Async task isolation:
28
+ >>> async def task1():
29
+ ... set_correlation_context(generate_correlation_id())
30
+ ... await process()
31
+ ... # Each task has its own correlation ID
32
+ >>> await asyncio.gather(task1(), task1())
33
+
34
+ Note:
35
+ Correlation IDs must not contain PII (Personally Identifiable Information).
36
+ They are purely for operational tracing and debugging.
37
+ """
38
+
39
+ import logging
40
+ import uuid
41
+ from contextvars import ContextVar
42
+
43
+ # Context-local storage for correlation ID (thread-safe, async-aware)
44
+ _correlation_context: ContextVar[str | None] = ContextVar(
45
+ "correlation_id", default=None
46
+ )
47
+
48
+
49
+ def generate_correlation_id() -> str:
50
+ """Generate a new UUID4 correlation ID.
51
+
52
+ Returns a lowercase UUID4 string without hyphens for compactness.
53
+ Format: 32 hexadecimal characters (e.g., "550e8400e29b41d4a716446655440000").
54
+
55
+ Returns:
56
+ Lowercase UUID4 string without hyphens.
57
+
58
+ Examples:
59
+ >>> corr_id = generate_correlation_id()
60
+ >>> len(corr_id)
61
+ 32
62
+ >>> all(c in '0123456789abcdef' for c in corr_id)
63
+ True
64
+ """
65
+ return uuid.uuid4().hex
66
+
67
+
68
+ def set_correlation_context(correlation_id: str) -> None:
69
+ """Set correlation ID for the current async context.
70
+
71
+ The correlation ID will be accessible to the current task and any tasks
72
+ spawned from it, but isolated from other concurrent tasks.
73
+
74
+ Args:
75
+ correlation_id: Correlation ID to set in current context.
76
+
77
+ Examples:
78
+ >>> set_correlation_context("550e8400e29b41d4a716446655440000")
79
+ >>> get_correlation_context()
80
+ '550e8400e29b41d4a716446655440000'
81
+ """
82
+ _correlation_context.set(correlation_id)
83
+
84
+
85
+ def get_correlation_context() -> str | None:
86
+ """Get correlation ID from the current async context.
87
+
88
+ Returns None if no correlation ID has been set in the current context.
89
+
90
+ Returns:
91
+ Current correlation ID, or None if not set.
92
+
93
+ Examples:
94
+ >>> set_correlation_context("test-id")
95
+ >>> get_correlation_context()
96
+ 'test-id'
97
+ >>> # In a different task with no context set
98
+ >>> get_correlation_context()
99
+ None
100
+ """
101
+ return _correlation_context.get()
102
+
103
+
104
+ def clear_correlation_context() -> None:
105
+ """Clear correlation ID from the current async context.
106
+
107
+ Resets the context to None. Useful for cleanup after processing.
108
+
109
+ Examples:
110
+ >>> set_correlation_context("test-id")
111
+ >>> get_correlation_context()
112
+ 'test-id'
113
+ >>> clear_correlation_context()
114
+ >>> get_correlation_context()
115
+ None
116
+ """
117
+ _correlation_context.set(None)
118
+
119
+
120
+ class CorrelationContext:
121
+ """Context manager for correlation ID scope.
122
+
123
+ Automatically generates a correlation ID (or uses provided one) and sets
124
+ it in the context. Cleans up the context on exit.
125
+
126
+ Attributes:
127
+ correlation_id: The correlation ID for this context.
128
+
129
+ Examples:
130
+ Auto-generate correlation ID:
131
+ >>> with CorrelationContext() as corr_id:
132
+ ... print(f"Correlation ID: {corr_id}")
133
+ ... # Process event with correlation
134
+ Correlation ID: 550e8400e29b41d4a716446655440000
135
+
136
+ Use existing correlation ID:
137
+ >>> existing_id = "abc123def456"
138
+ >>> with CorrelationContext(existing_id) as corr_id:
139
+ ... assert corr_id == existing_id
140
+ ... # Process with existing correlation
141
+ """
142
+
143
+ def __init__(self, correlation_id: str | None = None):
144
+ """Initialize correlation context.
145
+
146
+ Args:
147
+ correlation_id: Optional correlation ID. If None, generates new one.
148
+ """
149
+ self.correlation_id = correlation_id or generate_correlation_id()
150
+ self._token = None
151
+
152
+ def __enter__(self) -> str:
153
+ """Enter context, setting correlation ID.
154
+
155
+ Returns:
156
+ The correlation ID for this context.
157
+ """
158
+ self._token = _correlation_context.set(self.correlation_id)
159
+ return self.correlation_id
160
+
161
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
162
+ """Exit context, cleaning up correlation ID.
163
+
164
+ Args:
165
+ exc_type: Exception type if raised.
166
+ exc_val: Exception value if raised.
167
+ exc_tb: Exception traceback if raised.
168
+ """
169
+ if self._token is not None:
170
+ _correlation_context.reset(self._token)
171
+
172
+ async def __aenter__(self) -> str:
173
+ """Async enter context, setting correlation ID.
174
+
175
+ Returns:
176
+ The correlation ID for this context.
177
+ """
178
+ return self.__enter__()
179
+
180
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
181
+ """Async exit context, cleaning up correlation ID.
182
+
183
+ Args:
184
+ exc_type: Exception type if raised.
185
+ exc_val: Exception value if raised.
186
+ exc_tb: Exception traceback if raised.
187
+ """
188
+ self.__exit__(exc_type, exc_val, exc_tb)
189
+
190
+
191
+ class CorrelationFilter(logging.Filter):
192
+ """Logging filter that injects correlation ID into log records.
193
+
194
+ Adds a 'correlation_id' attribute to each LogRecord with the current
195
+ context's correlation ID. If no correlation ID is set, uses 'N/A'.
196
+
197
+ Usage:
198
+ >>> import logging
199
+ >>> logger = logging.getLogger("myapp")
200
+ >>> logger.addFilter(CorrelationFilter())
201
+ >>> formatter = logging.Formatter(
202
+ ... '%(asctime)s - %(levelname)s - [correlation_id=%(correlation_id)s] - %(message)s'
203
+ ... )
204
+ >>> handler = logging.StreamHandler()
205
+ >>> handler.setFormatter(formatter)
206
+ >>> logger.addHandler(handler)
207
+ >>> with CorrelationContext():
208
+ ... logger.info("Event processed") # Includes correlation_id
209
+
210
+ Examples:
211
+ Setup logging with correlation:
212
+ >>> logger = logging.getLogger(__name__)
213
+ >>> logger.addFilter(CorrelationFilter())
214
+ >>> set_correlation_context("test-123")
215
+ >>> logger.info("Processing") # Log includes correlation_id=test-123
216
+
217
+ Without correlation context:
218
+ >>> logger.info("Starting") # Log includes correlation_id=N/A
219
+ """
220
+
221
+ def filter(self, record: logging.LogRecord) -> bool:
222
+ """Add correlation ID to log record.
223
+
224
+ Args:
225
+ record: Log record to modify.
226
+
227
+ Returns:
228
+ True (always allows record through).
229
+ """
230
+ record.correlation_id = get_correlation_context() or "N/A"
231
+ return True
232
+
233
+
234
+ __all__ = [
235
+ "generate_correlation_id",
236
+ "set_correlation_context",
237
+ "get_correlation_context",
238
+ "clear_correlation_context",
239
+ "CorrelationContext",
240
+ "CorrelationFilter",
241
+ ]