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.
- kryten/CONFIG.md +504 -0
- kryten/__init__.py +127 -0
- kryten/__main__.py +882 -0
- kryten/application_state.py +98 -0
- kryten/audit_logger.py +237 -0
- kryten/command_subscriber.py +341 -0
- kryten/config.example.json +35 -0
- kryten/config.py +510 -0
- kryten/connection_watchdog.py +209 -0
- kryten/correlation.py +241 -0
- kryten/cytube_connector.py +754 -0
- kryten/cytube_event_sender.py +1476 -0
- kryten/errors.py +161 -0
- kryten/event_publisher.py +416 -0
- kryten/health_monitor.py +482 -0
- kryten/lifecycle_events.py +274 -0
- kryten/logging_config.py +314 -0
- kryten/nats_client.py +468 -0
- kryten/raw_event.py +165 -0
- kryten/service_registry.py +371 -0
- kryten/shutdown_handler.py +383 -0
- kryten/socket_io.py +903 -0
- kryten/state_manager.py +711 -0
- kryten/state_query_handler.py +698 -0
- kryten/state_updater.py +314 -0
- kryten/stats_tracker.py +108 -0
- kryten/subject_builder.py +330 -0
- kryten_robot-0.6.9.dist-info/METADATA +469 -0
- kryten_robot-0.6.9.dist-info/RECORD +32 -0
- kryten_robot-0.6.9.dist-info/WHEEL +4 -0
- kryten_robot-0.6.9.dist-info/entry_points.txt +3 -0
- kryten_robot-0.6.9.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|