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,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"]
|
kryten/logging_config.py
ADDED
|
@@ -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
|
+
]
|