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,274 @@
1
+ """Lifecycle Event Publisher for Kryten Services.
2
+
3
+ This module provides lifecycle event publishing for Kryten services, including:
4
+ - Service startup/shutdown events
5
+ - Connection/disconnection events
6
+ - Groupwide restart coordination
7
+
8
+ These events allow other Kryten services to monitor system health and coordinate
9
+ restarts across the service group.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import socket
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ from .nats_client import NatsClient
19
+
20
+
21
+ class LifecycleEventPublisher:
22
+ """Publisher for service lifecycle events.
23
+
24
+ Publishes events for service startup, shutdown, connection changes, and
25
+ subscribes to groupwide restart notices.
26
+
27
+ Subject patterns:
28
+ - kryten.lifecycle.{service}.startup
29
+ - kryten.lifecycle.{service}.shutdown
30
+ - kryten.lifecycle.{service}.connected
31
+ - kryten.lifecycle.{service}.disconnected
32
+ - kryten.lifecycle.group.restart (broadcast to all services)
33
+
34
+ Attributes:
35
+ service_name: Name of this service (e.g., "robot", "userstats")
36
+ nats_client: NATS client for publishing events.
37
+ logger: Logger instance.
38
+
39
+ Examples:
40
+ >>> lifecycle = LifecycleEventPublisher("robot", nats_client, logger)
41
+ >>> await lifecycle.start()
42
+ >>> await lifecycle.publish_startup()
43
+ >>> # ... service runs ...
44
+ >>> await lifecycle.publish_shutdown()
45
+ >>> await lifecycle.stop()
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ service_name: str,
51
+ nats_client: NatsClient,
52
+ logger: logging.Logger,
53
+ version: str = "unknown",
54
+ ):
55
+ """Initialize lifecycle event publisher.
56
+
57
+ Args:
58
+ service_name: Name of this service (robot, userstats, etc.).
59
+ nats_client: NATS client for event publishing.
60
+ logger: Logger for structured output.
61
+ version: Service version string.
62
+ """
63
+ self._service_name = service_name
64
+ self._nats = nats_client
65
+ self._logger = logger
66
+ self._version = version
67
+ self._running = False
68
+ self._subscription = None
69
+ self._restart_callback: callable | None = None
70
+
71
+ # Service metadata
72
+ self._hostname = socket.gethostname()
73
+ self._start_time: datetime | None = None
74
+
75
+ @property
76
+ def is_running(self) -> bool:
77
+ """Check if lifecycle publisher is running."""
78
+ return self._running
79
+
80
+ async def start(self) -> None:
81
+ """Start lifecycle event publisher and subscribe to group events."""
82
+ if self._running:
83
+ self._logger.warning("Lifecycle event publisher already running")
84
+ return
85
+
86
+ self._running = True
87
+ self._start_time = datetime.now(UTC)
88
+
89
+ # Subscribe to groupwide restart notices
90
+ try:
91
+ self._subscription = await self._nats.subscribe(
92
+ "kryten.lifecycle.group.restart",
93
+ callback=self._handle_restart_notice
94
+ )
95
+ self._logger.info("Subscribed to groupwide restart notices")
96
+ except Exception as e:
97
+ self._logger.error(f"Failed to subscribe to restart notices: {e}", exc_info=True)
98
+
99
+ async def stop(self) -> None:
100
+ """Stop lifecycle event publisher."""
101
+ if not self._running:
102
+ return
103
+
104
+ if self._subscription:
105
+ try:
106
+ await self._subscription.unsubscribe()
107
+ except Exception as e:
108
+ self._logger.warning(f"Error unsubscribing from restart notices: {e}")
109
+
110
+ self._subscription = None
111
+ self._running = False
112
+
113
+ def on_restart_notice(self, callback: callable) -> None:
114
+ """Register callback for groupwide restart notices.
115
+
116
+ Args:
117
+ callback: Async function to call when restart notice received.
118
+ Signature: async def callback(data: dict) -> None
119
+ """
120
+ self._restart_callback = callback
121
+
122
+ async def _handle_restart_notice(self, msg) -> None:
123
+ """Handle incoming groupwide restart notice."""
124
+ try:
125
+ data = json.loads(msg.data.decode('utf-8'))
126
+
127
+ # Extract restart parameters
128
+ initiator = data.get('initiator', 'unknown')
129
+ reason = data.get('reason', 'No reason provided')
130
+ delay_seconds = data.get('delay_seconds', 5)
131
+
132
+ self._logger.warning(
133
+ f"Groupwide restart notice received from {initiator}: {reason} "
134
+ f"(restarting in {delay_seconds}s)"
135
+ )
136
+
137
+ # Call registered callback if any
138
+ if self._restart_callback:
139
+ try:
140
+ await self._restart_callback(data)
141
+ except Exception as e:
142
+ self._logger.error(f"Error in restart callback: {e}", exc_info=True)
143
+
144
+ except json.JSONDecodeError as e:
145
+ self._logger.error(f"Invalid restart notice JSON: {e}")
146
+ except Exception as e:
147
+ self._logger.error(f"Error handling restart notice: {e}", exc_info=True)
148
+
149
+ def _build_base_payload(self) -> dict[str, Any]:
150
+ """Build base event payload with common metadata."""
151
+ uptime = None
152
+ if self._start_time:
153
+ uptime = (datetime.now(UTC) - self._start_time).total_seconds()
154
+
155
+ return {
156
+ "service": self._service_name,
157
+ "version": self._version,
158
+ "hostname": self._hostname,
159
+ "timestamp": datetime.now(UTC).isoformat(),
160
+ "uptime_seconds": uptime,
161
+ }
162
+
163
+ async def publish_startup(self, **extra_data) -> None:
164
+ """Publish service startup event.
165
+
166
+ Args:
167
+ **extra_data: Additional key-value pairs to include in event.
168
+ """
169
+ subject = f"kryten.lifecycle.{self._service_name}.startup"
170
+ payload = self._build_base_payload()
171
+ payload.update(extra_data)
172
+
173
+ try:
174
+ data_bytes = json.dumps(payload).encode('utf-8')
175
+ await self._nats.publish(subject, data_bytes)
176
+ self._logger.info(f"Published startup event to {subject}")
177
+ except Exception as e:
178
+ self._logger.error(f"Failed to publish startup event: {e}", exc_info=True)
179
+
180
+ async def publish_shutdown(self, reason: str = "Normal shutdown", **extra_data) -> None:
181
+ """Publish service shutdown event.
182
+
183
+ Args:
184
+ reason: Reason for shutdown.
185
+ **extra_data: Additional key-value pairs to include in event.
186
+ """
187
+ subject = f"kryten.lifecycle.{self._service_name}.shutdown"
188
+ payload = self._build_base_payload()
189
+ payload["reason"] = reason
190
+ payload.update(extra_data)
191
+
192
+ try:
193
+ data_bytes = json.dumps(payload).encode('utf-8')
194
+ await self._nats.publish(subject, data_bytes)
195
+ self._logger.info(f"Published shutdown event to {subject}")
196
+ except Exception as e:
197
+ self._logger.error(f"Failed to publish shutdown event: {e}", exc_info=True)
198
+
199
+ async def publish_connected(self, target: str, **extra_data) -> None:
200
+ """Publish connection established event.
201
+
202
+ Args:
203
+ target: Connection target (e.g., "CyTube", "NATS", "Database").
204
+ **extra_data: Additional key-value pairs to include in event.
205
+ """
206
+ subject = f"kryten.lifecycle.{self._service_name}.connected"
207
+ payload = self._build_base_payload()
208
+ payload["target"] = target
209
+ payload.update(extra_data)
210
+
211
+ try:
212
+ data_bytes = json.dumps(payload).encode('utf-8')
213
+ await self._nats.publish(subject, data_bytes)
214
+ self._logger.debug(f"Published connected event to {subject}")
215
+ except Exception as e:
216
+ self._logger.error(f"Failed to publish connected event: {e}", exc_info=True)
217
+
218
+ async def publish_disconnected(self, target: str, reason: str = "Unknown", **extra_data) -> None:
219
+ """Publish connection lost event.
220
+
221
+ Args:
222
+ target: Connection target (e.g., "CyTube", "NATS").
223
+ reason: Reason for disconnection.
224
+ **extra_data: Additional key-value pairs to include in event.
225
+ """
226
+ subject = f"kryten.lifecycle.{self._service_name}.disconnected"
227
+ payload = self._build_base_payload()
228
+ payload["target"] = target
229
+ payload["reason"] = reason
230
+ payload.update(extra_data)
231
+
232
+ try:
233
+ data_bytes = json.dumps(payload).encode('utf-8')
234
+ await self._nats.publish(subject, data_bytes)
235
+ self._logger.warning(f"Published disconnected event to {subject}")
236
+ except Exception as e:
237
+ self._logger.error(f"Failed to publish disconnected event: {e}", exc_info=True)
238
+
239
+ async def publish_group_restart(
240
+ self,
241
+ reason: str,
242
+ delay_seconds: int = 5,
243
+ initiator: str | None = None,
244
+ **extra_data
245
+ ) -> None:
246
+ """Publish groupwide restart notice to all Kryten services.
247
+
248
+ Args:
249
+ reason: Reason for restart (e.g., "Configuration update").
250
+ delay_seconds: Seconds to wait before restarting.
251
+ initiator: Service/user initiating restart.
252
+ **extra_data: Additional key-value pairs to include in event.
253
+ """
254
+ subject = "kryten.lifecycle.group.restart"
255
+ payload = {
256
+ "initiator": initiator or self._service_name,
257
+ "reason": reason,
258
+ "delay_seconds": delay_seconds,
259
+ "timestamp": datetime.now(UTC).isoformat(),
260
+ }
261
+ payload.update(extra_data)
262
+
263
+ try:
264
+ data_bytes = json.dumps(payload).encode('utf-8')
265
+ await self._nats.publish(subject, data_bytes)
266
+ self._logger.warning(
267
+ f"Published groupwide restart notice: {reason} "
268
+ f"(delay: {delay_seconds}s)"
269
+ )
270
+ except Exception as e:
271
+ self._logger.error(f"Failed to publish restart notice: {e}", exc_info=True)
272
+
273
+
274
+ __all__ = ["LifecycleEventPublisher"]
@@ -0,0 +1,314 @@
1
+ """Structured logging configuration for Kryten.
2
+
3
+ This module provides logging configuration with structured output (JSON or text),
4
+ correlation ID tracking, sensitive data redaction, and configurable verbosity
5
+ for development and production environments.
6
+
7
+ Examples:
8
+ Development setup with text format:
9
+ >>> from bot.kryten.logging_config import LoggingConfig, setup_logging
10
+ >>> config = LoggingConfig(level="DEBUG", format="text", output="console")
11
+ >>> setup_logging(config)
12
+ >>> logger = get_logger(__name__)
13
+ >>> logger.debug("Debug information")
14
+
15
+ Production setup with JSON format:
16
+ >>> config = LoggingConfig(
17
+ ... level="INFO",
18
+ ... format="json",
19
+ ... output="file",
20
+ ... file_path="/var/log/kryten/kryten.log",
21
+ ... component_levels={"nats_client": "DEBUG"}
22
+ ... )
23
+ >>> setup_logging(config)
24
+
25
+ Component-specific log levels:
26
+ >>> config = LoggingConfig(
27
+ ... level="INFO",
28
+ ... component_levels={"bot.kryten.nats_client": "DEBUG"}
29
+ ... )
30
+ >>> setup_logging(config)
31
+ >>> # nats_client logs DEBUG, others log INFO+
32
+
33
+ Note:
34
+ Sensitive data (passwords, tokens, API keys) are automatically redacted.
35
+ """
36
+
37
+ import json
38
+ import logging
39
+ import logging.config
40
+ import logging.handlers
41
+ import re
42
+ from dataclasses import dataclass, field
43
+ from datetime import UTC, datetime
44
+
45
+ from .correlation import CorrelationFilter
46
+
47
+
48
+ @dataclass
49
+ class LoggingConfig:
50
+ """Configuration for Kryten logging system.
51
+
52
+ Attributes:
53
+ level: Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
54
+ format: Output format ("json" or "text").
55
+ output: Output destination ("console" or "file").
56
+ file_path: Path to log file (required if output="file").
57
+ max_bytes: Maximum log file size before rotation (default 10MB).
58
+ backup_count: Number of backup files to keep (default 5).
59
+ component_levels: Per-component log levels (e.g., {"nats_client": "DEBUG"}).
60
+
61
+ Examples:
62
+ >>> config = LoggingConfig(level="INFO", format="json")
63
+ >>> config = LoggingConfig(
64
+ ... level="WARNING",
65
+ ... format="text",
66
+ ... output="file",
67
+ ... file_path="/var/log/kryten.log"
68
+ ... )
69
+ """
70
+
71
+ level: str = "INFO"
72
+ format: str = "json" # or "text"
73
+ output: str = "console" # or "file"
74
+ file_path: str | None = None
75
+ max_bytes: int = 10_485_760 # 10MB
76
+ backup_count: int = 5
77
+ component_levels: dict[str, str] = field(default_factory=dict)
78
+
79
+
80
+ class SensitiveDataFilter(logging.Filter):
81
+ """Filter that redacts sensitive data from log messages.
82
+
83
+ Redacts patterns matching passwords, tokens, API keys, and other
84
+ sensitive values to prevent credential leakage.
85
+
86
+ Examples:
87
+ >>> logger.addFilter(SensitiveDataFilter())
88
+ >>> logger.info("password=secret123")
89
+ # Output: "password=***REDACTED***"
90
+ """
91
+
92
+ # Patterns for sensitive data
93
+ SENSITIVE_PATTERNS = [
94
+ (re.compile(r"password\s*[=:]\s*\S+", re.IGNORECASE), "password=***REDACTED***"),
95
+ (re.compile(r"token\s*[=:]\s*\S+", re.IGNORECASE), "token=***REDACTED***"),
96
+ (re.compile(r"api[_-]?key\s*[=:]\s*\S+", re.IGNORECASE), "api_key=***REDACTED***"),
97
+ (re.compile(r"secret\s*[=:]\s*\S+", re.IGNORECASE), "secret=***REDACTED***"),
98
+ (re.compile(r"auth\s*[=:]\s*\S+", re.IGNORECASE), "auth=***REDACTED***"),
99
+ ]
100
+
101
+ def filter(self, record: logging.LogRecord) -> bool:
102
+ """Redact sensitive data from log record message.
103
+
104
+ Args:
105
+ record: Log record to filter.
106
+
107
+ Returns:
108
+ True (always allows record through after redaction).
109
+ """
110
+ if hasattr(record, "msg"):
111
+ msg = str(record.msg)
112
+ for pattern, replacement in self.SENSITIVE_PATTERNS:
113
+ msg = pattern.sub(replacement, msg)
114
+ record.msg = msg
115
+
116
+ return True
117
+
118
+
119
+ class JSONFormatter(logging.Formatter):
120
+ """Formatter that outputs logs as single-line JSON.
121
+
122
+ Includes standard fields: timestamp, level, logger, message, correlation_id,
123
+ component, service.
124
+
125
+ Examples:
126
+ >>> formatter = JSONFormatter()
127
+ >>> handler.setFormatter(formatter)
128
+ """
129
+
130
+ def format(self, record: logging.LogRecord) -> str:
131
+ """Format log record as JSON.
132
+
133
+ Args:
134
+ record: Log record to format.
135
+
136
+ Returns:
137
+ Single-line JSON string.
138
+ """
139
+ # Extract component from logger name (e.g., "bot.kryten.nats_client" -> "nats_client")
140
+ component = record.name.split(".")[-1] if "." in record.name else record.name
141
+
142
+ log_data = {
143
+ "timestamp": datetime.fromtimestamp(record.created, tz=UTC).strftime("%Y-%m-%d %H:%M:%S"),
144
+ "level": record.levelname,
145
+ "logger": record.name,
146
+ "message": record.getMessage(),
147
+ "correlation_id": getattr(record, "correlation_id", "N/A"),
148
+ "component": component,
149
+ "service": "kryten",
150
+ }
151
+
152
+ # Include source location in DEBUG mode
153
+ if record.levelno == logging.DEBUG:
154
+ log_data["file"] = record.filename
155
+ log_data["line"] = record.lineno
156
+ log_data["function"] = record.funcName
157
+
158
+ # Include exception info if present
159
+ if record.exc_info:
160
+ log_data["exception"] = self.formatException(record.exc_info)
161
+
162
+ return json.dumps(log_data)
163
+
164
+
165
+ class TextFormatter(logging.Formatter):
166
+ """Formatter that outputs logs as human-readable text.
167
+
168
+ Format: timestamp LEVEL [component] [correlation_id=ID] message
169
+
170
+ Examples:
171
+ >>> formatter = TextFormatter()
172
+ >>> handler.setFormatter(formatter)
173
+ """
174
+
175
+ def format(self, record: logging.LogRecord) -> str:
176
+ """Format log record as text.
177
+
178
+ Args:
179
+ record: Log record to format.
180
+
181
+ Returns:
182
+ Formatted text string.
183
+ """
184
+ # Extract component from logger name
185
+ component = record.name.split(".")[-1] if "." in record.name else record.name
186
+
187
+ # Format timestamp
188
+ timestamp = datetime.fromtimestamp(record.created, tz=UTC).strftime("%Y-%m-%d %H:%M:%S")
189
+
190
+ # Get correlation ID
191
+ correlation_id = getattr(record, "correlation_id", "N/A")
192
+
193
+ # Build message
194
+ message = f"{timestamp} {record.levelname} [{component}] [correlation_id={correlation_id}] {record.getMessage()}"
195
+
196
+ # Include source location in DEBUG mode
197
+ if record.levelno == logging.DEBUG:
198
+ message += f" ({record.filename}:{record.lineno})"
199
+
200
+ # Include exception info if present
201
+ if record.exc_info:
202
+ message += "\n" + self.formatException(record.exc_info)
203
+
204
+ return message
205
+
206
+
207
+ def setup_logging(config: LoggingConfig) -> None:
208
+ """Configure Python logging system with structured output.
209
+
210
+ Sets up handlers, formatters, and filters based on configuration.
211
+ Applies CorrelationFilter and SensitiveDataFilter to all loggers.
212
+
213
+ Args:
214
+ config: Logging configuration.
215
+
216
+ Raises:
217
+ ValueError: If output="file" but file_path not provided.
218
+
219
+ Examples:
220
+ >>> config = LoggingConfig(level="INFO", format="json")
221
+ >>> setup_logging(config)
222
+ >>> logger = get_logger(__name__)
223
+ >>> logger.info("Application started")
224
+ """
225
+ # Validate configuration
226
+ if config.output == "file" and not config.file_path:
227
+ raise ValueError("file_path required when output='file'")
228
+
229
+ # Choose formatter
230
+ if config.format == "json":
231
+ formatter = JSONFormatter()
232
+ else:
233
+ formatter = TextFormatter()
234
+
235
+ # Create handlers
236
+ handlers = {}
237
+
238
+ if config.output == "console":
239
+ # Console output: INFO+ to stdout, WARNING+ to stderr
240
+ stdout_handler = logging.StreamHandler()
241
+ stdout_handler.setLevel(logging.INFO)
242
+ stdout_handler.setFormatter(formatter)
243
+ stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING)
244
+ handlers["stdout"] = stdout_handler
245
+
246
+ stderr_handler = logging.StreamHandler()
247
+ stderr_handler.setLevel(logging.WARNING)
248
+ stderr_handler.setFormatter(formatter)
249
+ handlers["stderr"] = stderr_handler
250
+
251
+ else: # file
252
+ # File output with rotation
253
+ file_handler = logging.handlers.RotatingFileHandler(
254
+ config.file_path,
255
+ maxBytes=config.max_bytes,
256
+ backupCount=config.backup_count,
257
+ )
258
+ file_handler.setLevel(logging.DEBUG)
259
+ file_handler.setFormatter(formatter)
260
+ handlers["file"] = file_handler
261
+
262
+ # Configure root logger
263
+ root_logger = logging.getLogger()
264
+ root_logger.setLevel(logging.DEBUG) # Allow all, filter at handler level
265
+
266
+ # Remove existing handlers
267
+ for handler in root_logger.handlers[:]:
268
+ root_logger.removeHandler(handler)
269
+
270
+ # Add new handlers
271
+ for handler in handlers.values():
272
+ handler.addFilter(CorrelationFilter())
273
+ handler.addFilter(SensitiveDataFilter())
274
+ root_logger.addHandler(handler)
275
+
276
+ # Set global level
277
+ logging.getLogger().setLevel(getattr(logging, config.level.upper()))
278
+
279
+ # Set component-specific levels
280
+ for component, level in config.component_levels.items():
281
+ logger_name = f"bot.kryten.{component}" if not component.startswith("bot.") else component
282
+ logging.getLogger(logger_name).setLevel(getattr(logging, level.upper()))
283
+
284
+
285
+ def get_logger(name: str) -> logging.Logger:
286
+ """Get logger with standard configuration.
287
+
288
+ Returns a logger that inherits from the root logger configuration,
289
+ including CorrelationFilter and SensitiveDataFilter.
290
+
291
+ Args:
292
+ name: Logger name (typically __name__).
293
+
294
+ Returns:
295
+ Configured logger instance.
296
+
297
+ Examples:
298
+ >>> logger = get_logger(__name__)
299
+ >>> logger.info("Event processed")
300
+
301
+ >>> logger = get_logger("bot.kryten.connector")
302
+ >>> logger.debug("Connection details")
303
+ """
304
+ return logging.getLogger(name)
305
+
306
+
307
+ __all__ = [
308
+ "LoggingConfig",
309
+ "setup_logging",
310
+ "get_logger",
311
+ "SensitiveDataFilter",
312
+ "JSONFormatter",
313
+ "TextFormatter",
314
+ ]