miso-client 0.1.0__py3-none-any.whl → 3.7.2__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.
- miso_client/__init__.py +523 -130
- miso_client/api/__init__.py +35 -0
- miso_client/api/auth_api.py +367 -0
- miso_client/api/logs_api.py +91 -0
- miso_client/api/permissions_api.py +88 -0
- miso_client/api/roles_api.py +88 -0
- miso_client/api/types/__init__.py +75 -0
- miso_client/api/types/auth_types.py +183 -0
- miso_client/api/types/logs_types.py +71 -0
- miso_client/api/types/permissions_types.py +31 -0
- miso_client/api/types/roles_types.py +31 -0
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +275 -72
- miso_client/models/error_response.py +39 -0
- miso_client/models/filter.py +255 -0
- miso_client/models/pagination.py +44 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/__init__.py +6 -5
- miso_client/services/auth.py +496 -87
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +467 -328
- miso_client/services/logger_chain.py +288 -0
- miso_client/services/permission.py +130 -67
- miso_client/services/redis.py +28 -23
- miso_client/services/role.py +145 -62
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/audit_log_queue.py +222 -0
- miso_client/utils/auth_strategy.py +88 -0
- miso_client/utils/auth_utils.py +65 -0
- miso_client/utils/circuit_breaker.py +125 -0
- miso_client/utils/client_token_manager.py +244 -0
- miso_client/utils/config_loader.py +88 -17
- miso_client/utils/controller_url_resolver.py +80 -0
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/environment_token.py +126 -0
- miso_client/utils/error_utils.py +216 -0
- miso_client/utils/fastapi_endpoints.py +166 -0
- miso_client/utils/filter.py +364 -0
- miso_client/utils/filter_applier.py +143 -0
- miso_client/utils/filter_parser.py +110 -0
- miso_client/utils/flask_endpoints.py +169 -0
- miso_client/utils/http_client.py +494 -262
- miso_client/utils/http_client_logging.py +352 -0
- miso_client/utils/http_client_logging_helpers.py +197 -0
- miso_client/utils/http_client_query_helpers.py +138 -0
- miso_client/utils/http_error_handler.py +92 -0
- miso_client/utils/http_log_formatter.py +115 -0
- miso_client/utils/http_log_masker.py +203 -0
- miso_client/utils/internal_http_client.py +435 -0
- miso_client/utils/jwt_tools.py +125 -16
- miso_client/utils/logger_helpers.py +206 -0
- miso_client/utils/logging_helpers.py +70 -0
- miso_client/utils/origin_validator.py +128 -0
- miso_client/utils/pagination.py +275 -0
- miso_client/utils/request_context.py +285 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- miso_client/utils/token_utils.py +114 -0
- miso_client/utils/url_validator.py +66 -0
- miso_client/utils/user_token_refresh.py +245 -0
- miso_client-3.7.2.dist-info/METADATA +1021 -0
- miso_client-3.7.2.dist-info/RECORD +68 -0
- miso_client-0.1.0.dist-info/METADATA +0 -551
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
miso_client/services/logger.py
CHANGED
|
@@ -2,200 +2,135 @@
|
|
|
2
2
|
Logger service for application logging and audit events.
|
|
3
3
|
|
|
4
4
|
This module provides structured logging with Redis queuing and HTTP fallback.
|
|
5
|
-
Includes JWT context extraction, data masking, correlation IDs
|
|
5
|
+
Includes JWT context extraction, data masking, and correlation IDs.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import inspect
|
|
9
9
|
import random
|
|
10
|
-
import sys
|
|
11
10
|
from datetime import datetime
|
|
12
|
-
from typing import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, cast
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
# Avoid import at runtime for frameworks not installed
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
from ..models.config import ClientLoggingOptions, LogEntry
|
|
17
18
|
from ..services.redis import RedisService
|
|
18
|
-
from ..utils.
|
|
19
|
-
from ..utils.
|
|
20
|
-
from ..utils.
|
|
19
|
+
from ..utils.audit_log_queue import AuditLogQueue
|
|
20
|
+
from ..utils.circuit_breaker import CircuitBreaker
|
|
21
|
+
from ..utils.internal_http_client import InternalHttpClient
|
|
22
|
+
from ..utils.logger_helpers import build_log_entry, extract_metadata
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..api import ApiClient
|
|
26
|
+
from ..api.types.logs_types import LogRequest
|
|
27
|
+
|
|
28
|
+
# Import LoggerChain at runtime to avoid circular dependency
|
|
29
|
+
from .logger_chain import LoggerChain
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
class LoggerService:
|
|
24
33
|
"""Logger service for application logging and audit events."""
|
|
25
|
-
|
|
26
|
-
def __init__(
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
internal_http_client: InternalHttpClient,
|
|
38
|
+
redis: RedisService,
|
|
39
|
+
http_client: Optional[Any] = None,
|
|
40
|
+
api_client: Optional["ApiClient"] = None,
|
|
41
|
+
):
|
|
27
42
|
"""
|
|
28
43
|
Initialize logger service.
|
|
29
|
-
|
|
44
|
+
|
|
30
45
|
Args:
|
|
31
|
-
|
|
46
|
+
internal_http_client: Internal HTTP client instance (used for log sending)
|
|
32
47
|
redis: Redis service instance
|
|
48
|
+
http_client: Optional HttpClient instance for audit log queue (if available)
|
|
49
|
+
api_client: Optional API client instance (for typed API calls, use with caution to avoid circular dependency)
|
|
33
50
|
"""
|
|
34
|
-
self.config =
|
|
35
|
-
self.
|
|
51
|
+
self.config = internal_http_client.config
|
|
52
|
+
self.internal_http_client = internal_http_client
|
|
36
53
|
self.redis = redis
|
|
54
|
+
self.api_client = api_client
|
|
37
55
|
self.mask_sensitive_data = True # Default: mask sensitive data
|
|
38
56
|
self.correlation_counter = 0
|
|
39
|
-
self.
|
|
40
|
-
|
|
57
|
+
self.audit_log_queue: Optional[AuditLogQueue] = None
|
|
58
|
+
|
|
59
|
+
# Initialize circuit breaker for HTTP logging
|
|
60
|
+
circuit_breaker_config = self.config.audit.circuitBreaker if self.config.audit else None
|
|
61
|
+
self.circuit_breaker = CircuitBreaker(circuit_breaker_config)
|
|
62
|
+
|
|
63
|
+
# Event emission mode: list of callbacks for log events
|
|
64
|
+
# Callbacks receive (log_entry: LogEntry) as argument
|
|
65
|
+
self._event_listeners: List[Callable[[LogEntry], None]] = []
|
|
66
|
+
|
|
67
|
+
# Audit log queue will be initialized later by MisoClient after http_client is created
|
|
68
|
+
# This avoids circular dependency issues
|
|
69
|
+
|
|
41
70
|
def set_masking(self, enabled: bool) -> None:
|
|
42
71
|
"""
|
|
43
72
|
Enable or disable sensitive data masking.
|
|
44
|
-
|
|
73
|
+
|
|
45
74
|
Args:
|
|
46
75
|
enabled: Whether to enable data masking
|
|
47
76
|
"""
|
|
48
77
|
self.mask_sensitive_data = enabled
|
|
49
|
-
|
|
78
|
+
|
|
79
|
+
def on(self, callback: Callable[[LogEntry], None]) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Register an event listener for log events.
|
|
82
|
+
|
|
83
|
+
When `emit_events=True` in config, logs are emitted as events instead of
|
|
84
|
+
being sent via HTTP/Redis. Registered callbacks receive LogEntry objects.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
callback: Async or sync function that receives LogEntry as argument
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> async def log_handler(log_entry: LogEntry):
|
|
91
|
+
... print(f"Log: {log_entry.level} - {log_entry.message}")
|
|
92
|
+
>>> logger.on(log_handler)
|
|
93
|
+
"""
|
|
94
|
+
if callback not in self._event_listeners:
|
|
95
|
+
self._event_listeners.append(callback)
|
|
96
|
+
|
|
97
|
+
def off(self, callback: Callable[[LogEntry], None]) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Unregister an event listener.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
callback: Callback function to remove from listeners
|
|
103
|
+
"""
|
|
104
|
+
if callback in self._event_listeners:
|
|
105
|
+
self._event_listeners.remove(callback)
|
|
106
|
+
|
|
50
107
|
def _generate_correlation_id(self) -> str:
|
|
51
108
|
"""
|
|
52
109
|
Generate unique correlation ID for request tracking.
|
|
53
|
-
|
|
110
|
+
|
|
54
111
|
Format: {clientId[0:10]}-{timestamp}-{counter}-{random}
|
|
55
|
-
|
|
112
|
+
|
|
56
113
|
Returns:
|
|
57
114
|
Correlation ID string
|
|
58
115
|
"""
|
|
59
116
|
self.correlation_counter = (self.correlation_counter + 1) % 10000
|
|
60
117
|
timestamp = int(datetime.now().timestamp() * 1000)
|
|
61
|
-
random_part =
|
|
62
|
-
client_prefix =
|
|
118
|
+
random_part = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=6))
|
|
119
|
+
client_prefix = (
|
|
120
|
+
self.config.client_id[:10] if len(self.config.client_id) > 10 else self.config.client_id
|
|
121
|
+
)
|
|
63
122
|
return f"{client_prefix}-{timestamp}-{self.correlation_counter}-{random_part}"
|
|
64
|
-
|
|
65
|
-
def _extract_jwt_context(self, token: Optional[str]) -> Dict[str, Any]:
|
|
66
|
-
"""
|
|
67
|
-
Extract JWT token information.
|
|
68
|
-
|
|
69
|
-
Args:
|
|
70
|
-
token: JWT token string
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
Dictionary with userId, applicationId, sessionId, roles, permissions
|
|
74
|
-
"""
|
|
75
|
-
if not token:
|
|
76
|
-
return {}
|
|
77
|
-
|
|
78
|
-
try:
|
|
79
|
-
decoded = decode_token(token)
|
|
80
|
-
if not decoded:
|
|
81
|
-
return {}
|
|
82
|
-
|
|
83
|
-
# Extract roles - handle different formats
|
|
84
|
-
roles = []
|
|
85
|
-
if "roles" in decoded:
|
|
86
|
-
roles = decoded["roles"] if isinstance(decoded["roles"], list) else []
|
|
87
|
-
elif "realm_access" in decoded and isinstance(decoded["realm_access"], dict):
|
|
88
|
-
roles = decoded["realm_access"].get("roles", [])
|
|
89
|
-
|
|
90
|
-
# Extract permissions - handle different formats
|
|
91
|
-
permissions = []
|
|
92
|
-
if "permissions" in decoded:
|
|
93
|
-
permissions = decoded["permissions"] if isinstance(decoded["permissions"], list) else []
|
|
94
|
-
elif "scope" in decoded and isinstance(decoded["scope"], str):
|
|
95
|
-
permissions = decoded["scope"].split()
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
"userId": decoded.get("sub") or decoded.get("userId") or decoded.get("user_id"),
|
|
99
|
-
"applicationId": decoded.get("applicationId") or decoded.get("app_id"),
|
|
100
|
-
"sessionId": decoded.get("sessionId") or decoded.get("sid"),
|
|
101
|
-
"roles": roles,
|
|
102
|
-
"permissions": permissions,
|
|
103
|
-
}
|
|
104
|
-
except Exception:
|
|
105
|
-
# JWT parsing failed, return empty context
|
|
106
|
-
return {}
|
|
107
|
-
|
|
108
|
-
def _extract_metadata(self) -> Dict[str, Any]:
|
|
109
|
-
"""
|
|
110
|
-
Extract metadata from environment (browser or Node.js).
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
Dictionary with hostname, userAgent, etc.
|
|
114
|
-
"""
|
|
115
|
-
metadata: Dict[str, Any] = {}
|
|
116
|
-
|
|
117
|
-
# Try to extract Node.js/Python metadata
|
|
118
|
-
if hasattr(os, "environ"):
|
|
119
|
-
metadata["hostname"] = os.environ.get("HOSTNAME", "unknown")
|
|
120
|
-
|
|
121
|
-
# In Python, we don't have browser metadata like in TypeScript
|
|
122
|
-
# But we can capture some environment info
|
|
123
|
-
metadata["platform"] = sys.platform
|
|
124
|
-
metadata["python_version"] = sys.version
|
|
125
|
-
|
|
126
|
-
return metadata
|
|
127
|
-
|
|
128
|
-
def start_performance_tracking(self, operation_id: str) -> None:
|
|
129
|
-
"""
|
|
130
|
-
Start performance tracking.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
operation_id: Unique identifier for this operation
|
|
134
|
-
"""
|
|
135
|
-
try:
|
|
136
|
-
import psutil
|
|
137
|
-
process = psutil.Process()
|
|
138
|
-
memory_info = process.memory_info()
|
|
139
|
-
memory_usage = {
|
|
140
|
-
"rss": memory_info.rss,
|
|
141
|
-
"heapTotal": memory_info.rss, # Approximation
|
|
142
|
-
"heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
|
|
143
|
-
"external": 0,
|
|
144
|
-
"arrayBuffers": 0,
|
|
145
|
-
}
|
|
146
|
-
except ImportError:
|
|
147
|
-
# psutil not available
|
|
148
|
-
memory_usage = None
|
|
149
|
-
|
|
150
|
-
self.performance_metrics[operation_id] = {
|
|
151
|
-
"startTime": int(datetime.now().timestamp() * 1000),
|
|
152
|
-
"memoryUsage": memory_usage,
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
def end_performance_tracking(self, operation_id: str) -> Optional[Dict[str, Any]]:
|
|
156
|
-
"""
|
|
157
|
-
End performance tracking and get metrics.
|
|
158
|
-
|
|
159
|
-
Args:
|
|
160
|
-
operation_id: Unique identifier for this operation
|
|
161
|
-
|
|
162
|
-
Returns:
|
|
163
|
-
Performance metrics dictionary or None if not found
|
|
164
|
-
"""
|
|
165
|
-
if operation_id not in self.performance_metrics:
|
|
166
|
-
return None
|
|
167
|
-
|
|
168
|
-
metrics = self.performance_metrics[operation_id]
|
|
169
|
-
metrics["endTime"] = int(datetime.now().timestamp() * 1000)
|
|
170
|
-
metrics["duration"] = metrics["endTime"] - metrics["startTime"]
|
|
171
|
-
|
|
172
|
-
try:
|
|
173
|
-
import psutil
|
|
174
|
-
process = psutil.Process()
|
|
175
|
-
memory_info = process.memory_info()
|
|
176
|
-
metrics["memoryUsage"] = {
|
|
177
|
-
"rss": memory_info.rss,
|
|
178
|
-
"heapTotal": memory_info.rss,
|
|
179
|
-
"heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
|
|
180
|
-
"external": 0,
|
|
181
|
-
"arrayBuffers": 0,
|
|
182
|
-
}
|
|
183
|
-
except (ImportError, Exception):
|
|
184
|
-
pass # psutil not available or error getting memory info
|
|
185
|
-
|
|
186
|
-
del self.performance_metrics[operation_id]
|
|
187
|
-
return metrics
|
|
188
|
-
|
|
123
|
+
|
|
189
124
|
async def error(
|
|
190
125
|
self,
|
|
191
126
|
message: str,
|
|
192
127
|
context: Optional[Dict[str, Any]] = None,
|
|
193
128
|
stack_trace: Optional[str] = None,
|
|
194
|
-
options: Optional[ClientLoggingOptions] = None
|
|
129
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
195
130
|
) -> None:
|
|
196
131
|
"""
|
|
197
132
|
Log error message with optional stack trace and enhanced options.
|
|
198
|
-
|
|
133
|
+
|
|
199
134
|
Args:
|
|
200
135
|
message: Error message
|
|
201
136
|
context: Additional context data
|
|
@@ -203,55 +138,51 @@ class LoggerService:
|
|
|
203
138
|
options: Logging options
|
|
204
139
|
"""
|
|
205
140
|
await self._log("error", message, context, stack_trace, options)
|
|
206
|
-
|
|
141
|
+
|
|
207
142
|
async def audit(
|
|
208
143
|
self,
|
|
209
144
|
action: str,
|
|
210
145
|
resource: str,
|
|
211
146
|
context: Optional[Dict[str, Any]] = None,
|
|
212
|
-
options: Optional[ClientLoggingOptions] = None
|
|
147
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
213
148
|
) -> None:
|
|
214
149
|
"""
|
|
215
150
|
Log audit event with enhanced options.
|
|
216
|
-
|
|
151
|
+
|
|
217
152
|
Args:
|
|
218
153
|
action: Action performed
|
|
219
154
|
resource: Resource affected
|
|
220
155
|
context: Additional context data
|
|
221
156
|
options: Logging options
|
|
222
157
|
"""
|
|
223
|
-
audit_context = {
|
|
224
|
-
"action": action,
|
|
225
|
-
"resource": resource,
|
|
226
|
-
**(context or {})
|
|
227
|
-
}
|
|
158
|
+
audit_context = {"action": action, "resource": resource, **(context or {})}
|
|
228
159
|
await self._log("audit", f"Audit: {action} on {resource}", audit_context, None, options)
|
|
229
|
-
|
|
160
|
+
|
|
230
161
|
async def info(
|
|
231
162
|
self,
|
|
232
163
|
message: str,
|
|
233
164
|
context: Optional[Dict[str, Any]] = None,
|
|
234
|
-
options: Optional[ClientLoggingOptions] = None
|
|
165
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
235
166
|
) -> None:
|
|
236
167
|
"""
|
|
237
168
|
Log info message with enhanced options.
|
|
238
|
-
|
|
169
|
+
|
|
239
170
|
Args:
|
|
240
171
|
message: Info message
|
|
241
172
|
context: Additional context data
|
|
242
173
|
options: Logging options
|
|
243
174
|
"""
|
|
244
175
|
await self._log("info", message, context, None, options)
|
|
245
|
-
|
|
176
|
+
|
|
246
177
|
async def debug(
|
|
247
178
|
self,
|
|
248
179
|
message: str,
|
|
249
180
|
context: Optional[Dict[str, Any]] = None,
|
|
250
|
-
options: Optional[ClientLoggingOptions] = None
|
|
181
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
251
182
|
) -> None:
|
|
252
183
|
"""
|
|
253
184
|
Log debug message with enhanced options.
|
|
254
|
-
|
|
185
|
+
|
|
255
186
|
Args:
|
|
256
187
|
message: Debug message
|
|
257
188
|
context: Additional context data
|
|
@@ -259,18 +190,101 @@ class LoggerService:
|
|
|
259
190
|
"""
|
|
260
191
|
if self.config.log_level == "debug":
|
|
261
192
|
await self._log("debug", message, context, None, options)
|
|
262
|
-
|
|
193
|
+
|
|
194
|
+
async def _emit_log_event(self, log_entry: LogEntry) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Emit log entry as event if event emission is enabled.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
log_entry: LogEntry to emit
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if event was emitted, False otherwise
|
|
203
|
+
"""
|
|
204
|
+
if not (self.config.emit_events and self._event_listeners):
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
for callback in self._event_listeners:
|
|
208
|
+
try:
|
|
209
|
+
if inspect.iscoroutinefunction(callback):
|
|
210
|
+
await callback(log_entry)
|
|
211
|
+
else:
|
|
212
|
+
callback(log_entry)
|
|
213
|
+
except Exception:
|
|
214
|
+
# Silently fail to avoid breaking application flow
|
|
215
|
+
pass
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
async def _queue_audit_log(self, log_entry: LogEntry) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Queue audit log entry if audit queue is available.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
log_entry: LogEntry to queue
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if queued, False otherwise
|
|
227
|
+
"""
|
|
228
|
+
if log_entry.level == "audit" and self.audit_log_queue:
|
|
229
|
+
await self.audit_log_queue.add(log_entry)
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
async def _queue_redis_log(self, log_entry: LogEntry) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Queue log entry in Redis if available.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
log_entry: LogEntry to queue
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if queued, False otherwise
|
|
242
|
+
"""
|
|
243
|
+
if not self.redis.is_connected():
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
queue_name = f"logs:{self.config.client_id}"
|
|
247
|
+
success = await self.redis.rpush(queue_name, log_entry.model_dump_json())
|
|
248
|
+
return success
|
|
249
|
+
|
|
250
|
+
async def _send_http_log(self, log_entry: LogEntry) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Send log entry via HTTP to controller.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
log_entry: LogEntry to send
|
|
256
|
+
"""
|
|
257
|
+
# Check circuit breaker before attempting HTTP logging
|
|
258
|
+
if self.circuit_breaker.is_open():
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
if self.api_client:
|
|
263
|
+
log_request = self._transform_log_entry_to_request(log_entry)
|
|
264
|
+
await self.api_client.logs.send_log(log_request)
|
|
265
|
+
else:
|
|
266
|
+
log_payload = log_entry.model_dump(
|
|
267
|
+
exclude={"environment", "application"}, exclude_none=True
|
|
268
|
+
)
|
|
269
|
+
await self.internal_http_client.request("POST", "/api/v1/logs", log_payload)
|
|
270
|
+
self.circuit_breaker.record_success()
|
|
271
|
+
except Exception:
|
|
272
|
+
# Failed to send log to controller
|
|
273
|
+
self.circuit_breaker.record_failure()
|
|
274
|
+
# Silently fail to avoid infinite logging loops
|
|
275
|
+
pass
|
|
276
|
+
|
|
263
277
|
async def _log(
|
|
264
278
|
self,
|
|
265
279
|
level: Literal["error", "audit", "info", "debug"],
|
|
266
280
|
message: str,
|
|
267
281
|
context: Optional[Dict[str, Any]] = None,
|
|
268
282
|
stack_trace: Optional[str] = None,
|
|
269
|
-
options: Optional[ClientLoggingOptions] = None
|
|
283
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
270
284
|
) -> None:
|
|
271
285
|
"""
|
|
272
|
-
|
|
273
|
-
|
|
286
|
+
Core logging method with Redis queuing and HTTP fallback.
|
|
287
|
+
|
|
274
288
|
Args:
|
|
275
289
|
level: Log level
|
|
276
290
|
message: Log message
|
|
@@ -278,180 +292,305 @@ class LoggerService:
|
|
|
278
292
|
stack_trace: Stack trace for errors
|
|
279
293
|
options: Logging options
|
|
280
294
|
"""
|
|
281
|
-
#
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
+
# Build log entry
|
|
296
|
+
correlation_id = (
|
|
297
|
+
options.correlationId if options else None
|
|
298
|
+
) or self._generate_correlation_id()
|
|
299
|
+
|
|
300
|
+
log_entry = build_log_entry(
|
|
301
|
+
level=level,
|
|
302
|
+
message=message,
|
|
303
|
+
context=context,
|
|
304
|
+
config_client_id=self.config.client_id,
|
|
305
|
+
correlation_id=correlation_id,
|
|
306
|
+
jwt_token=options.token if options else None,
|
|
307
|
+
stack_trace=stack_trace,
|
|
308
|
+
options=options,
|
|
309
|
+
metadata=extract_metadata(),
|
|
310
|
+
mask_sensitive=self.mask_sensitive_data,
|
|
295
311
|
)
|
|
296
|
-
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
**(enhanced_context or {}),
|
|
306
|
-
"performance": {
|
|
307
|
-
"memoryUsage": {
|
|
308
|
-
"rss": memory_info.rss,
|
|
309
|
-
"heapTotal": memory_info.rss,
|
|
310
|
-
"heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
|
|
311
|
-
},
|
|
312
|
-
"uptime": psutil.boot_time() if hasattr(psutil, "boot_time") else 0,
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
except (ImportError, Exception):
|
|
316
|
-
pass # psutil not available or error getting memory info
|
|
317
|
-
|
|
318
|
-
log_entry_data = {
|
|
319
|
-
"timestamp": datetime.utcnow().isoformat(),
|
|
320
|
-
"level": level,
|
|
321
|
-
"environment": "unknown", # Backend extracts from client credentials
|
|
322
|
-
"application": self.config.client_id, # Use clientId as application identifier
|
|
323
|
-
"applicationId": options.applicationId if options else None,
|
|
324
|
-
"message": message,
|
|
325
|
-
"context": enhanced_context,
|
|
326
|
-
"stackTrace": stack_trace,
|
|
327
|
-
"correlationId": correlation_id,
|
|
328
|
-
"userId": (options.userId if options else None) or jwt_context.get("userId"),
|
|
329
|
-
"sessionId": (options.sessionId if options else None) or jwt_context.get("sessionId"),
|
|
330
|
-
"requestId": options.requestId if options else None,
|
|
331
|
-
**metadata
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
# Remove None values
|
|
335
|
-
log_entry_data = {k: v for k, v in log_entry_data.items() if v is not None}
|
|
336
|
-
|
|
337
|
-
log_entry = LogEntry(**log_entry_data)
|
|
338
|
-
|
|
312
|
+
|
|
313
|
+
# Event emission mode: emit events instead of sending via HTTP/Redis
|
|
314
|
+
if await self._emit_log_event(log_entry):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
# Use batch queue for audit logs if available
|
|
318
|
+
if await self._queue_audit_log(log_entry):
|
|
319
|
+
return
|
|
320
|
+
|
|
339
321
|
# Try Redis first (if available)
|
|
340
|
-
if self.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
322
|
+
if await self._queue_redis_log(log_entry):
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# Fallback to HTTP logging
|
|
326
|
+
await self._send_http_log(log_entry)
|
|
327
|
+
|
|
328
|
+
def _transform_log_entry_to_request(self, log_entry: LogEntry) -> "LogRequest":
|
|
329
|
+
"""
|
|
330
|
+
Transform LogEntry to LogRequest format for API layer.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
log_entry: LogEntry to transform
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
LogRequest with appropriate type and data
|
|
337
|
+
"""
|
|
338
|
+
from ..api.types.logs_types import AuditLogData, GeneralLogData, LogRequest
|
|
339
|
+
|
|
340
|
+
context = log_entry.context or {}
|
|
341
|
+
|
|
342
|
+
if log_entry.level == "audit":
|
|
343
|
+
# Transform to AuditLogData
|
|
344
|
+
audit_data = AuditLogData(
|
|
345
|
+
entityType=context.get("entityType", context.get("resource", "unknown")),
|
|
346
|
+
entityId=context.get("entityId", context.get("resourceId", "unknown")),
|
|
347
|
+
action=context.get("action", "unknown"),
|
|
348
|
+
oldValues=context.get("oldValues"),
|
|
349
|
+
newValues=context.get("newValues"),
|
|
350
|
+
correlationId=log_entry.correlationId,
|
|
351
|
+
)
|
|
352
|
+
return LogRequest(type="audit", data=audit_data)
|
|
353
|
+
else:
|
|
354
|
+
# Transform to GeneralLogData
|
|
355
|
+
# Map level: "error" -> "error", others -> "general"
|
|
356
|
+
log_type = cast(
|
|
357
|
+
Literal["error", "general"], "error" if log_entry.level == "error" else "general"
|
|
358
|
+
)
|
|
359
|
+
general_data = GeneralLogData(
|
|
360
|
+
level=log_entry.level if log_entry.level != "error" else "error", # type: ignore
|
|
361
|
+
message=log_entry.message,
|
|
362
|
+
context=context,
|
|
363
|
+
correlationId=log_entry.correlationId,
|
|
364
|
+
)
|
|
365
|
+
return LogRequest(type=log_type, data=general_data)
|
|
366
|
+
|
|
358
367
|
def with_context(self, context: Dict[str, Any]) -> "LoggerChain":
|
|
359
368
|
"""Create logger chain with context."""
|
|
360
369
|
return LoggerChain(self, context, ClientLoggingOptions())
|
|
361
|
-
|
|
370
|
+
|
|
362
371
|
def with_token(self, token: str) -> "LoggerChain":
|
|
363
372
|
"""Create logger chain with token."""
|
|
364
373
|
return LoggerChain(self, {}, ClientLoggingOptions(token=token))
|
|
365
|
-
|
|
366
|
-
def with_performance(self) -> "LoggerChain":
|
|
367
|
-
"""Create logger chain with performance metrics."""
|
|
368
|
-
opts = ClientLoggingOptions()
|
|
369
|
-
opts.performanceMetrics = True
|
|
370
|
-
return LoggerChain(self, {}, opts)
|
|
371
|
-
|
|
374
|
+
|
|
372
375
|
def without_masking(self) -> "LoggerChain":
|
|
373
376
|
"""Create logger chain without data masking."""
|
|
374
377
|
opts = ClientLoggingOptions()
|
|
375
378
|
opts.maskSensitiveData = False
|
|
376
379
|
return LoggerChain(self, {}, opts)
|
|
377
380
|
|
|
381
|
+
def for_request(self, request: Any) -> "LoggerChain":
|
|
382
|
+
"""
|
|
383
|
+
Create logger chain with request context pre-populated.
|
|
384
|
+
|
|
385
|
+
Shortcut for: logger.with_context({}).with_request(request)
|
|
378
386
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
387
|
+
Args:
|
|
388
|
+
request: HTTP request object (FastAPI, Flask, Starlette)
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
LoggerChain with request context
|
|
392
|
+
|
|
393
|
+
Example:
|
|
394
|
+
>>> await logger.for_request(request).info("Processing")
|
|
395
|
+
"""
|
|
396
|
+
return LoggerChain(self, {}, ClientLoggingOptions()).with_request(request)
|
|
397
|
+
|
|
398
|
+
def get_log_with_request(
|
|
383
399
|
self,
|
|
384
|
-
|
|
400
|
+
request: Any,
|
|
401
|
+
message: str,
|
|
402
|
+
level: Literal["error", "audit", "info", "debug"] = "info",
|
|
385
403
|
context: Optional[Dict[str, Any]] = None,
|
|
386
|
-
|
|
387
|
-
):
|
|
404
|
+
stack_trace: Optional[str] = None,
|
|
405
|
+
) -> LogEntry:
|
|
388
406
|
"""
|
|
389
|
-
|
|
390
|
-
|
|
407
|
+
Get LogEntry object with auto-extracted request context.
|
|
408
|
+
|
|
409
|
+
Extracts IP, method, path, userAgent, correlationId, userId from request.
|
|
410
|
+
Returns LogEntry object ready for use in other projects' logger tables.
|
|
411
|
+
|
|
391
412
|
Args:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
413
|
+
request: HTTP request object (FastAPI, Flask, Starlette)
|
|
414
|
+
message: Log message
|
|
415
|
+
level: Log level (default: "info")
|
|
416
|
+
context: Additional context data (optional)
|
|
417
|
+
stack_trace: Stack trace for errors (optional)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
LogEntry object with request context extracted
|
|
421
|
+
|
|
422
|
+
Example:
|
|
423
|
+
>>> log_entry = logger.get_log_with_request(request, "Processing request")
|
|
424
|
+
>>> # Use log_entry in your own logger table
|
|
425
|
+
"""
|
|
426
|
+
from ..utils.request_context import extract_request_context
|
|
427
|
+
|
|
428
|
+
# Extract request context
|
|
429
|
+
ctx = extract_request_context(request)
|
|
430
|
+
|
|
431
|
+
# Build options from extracted context
|
|
432
|
+
options = ClientLoggingOptions()
|
|
433
|
+
if ctx.user_id:
|
|
434
|
+
options.userId = ctx.user_id
|
|
435
|
+
if ctx.session_id:
|
|
436
|
+
options.sessionId = ctx.session_id
|
|
437
|
+
if ctx.correlation_id:
|
|
438
|
+
options.correlationId = ctx.correlation_id
|
|
439
|
+
if ctx.request_id:
|
|
440
|
+
options.requestId = ctx.request_id
|
|
441
|
+
if ctx.ip_address:
|
|
442
|
+
options.ipAddress = ctx.ip_address
|
|
443
|
+
if ctx.user_agent:
|
|
444
|
+
options.userAgent = ctx.user_agent
|
|
445
|
+
|
|
446
|
+
# Merge request info into context
|
|
447
|
+
request_context = context or {}
|
|
448
|
+
if ctx.method:
|
|
449
|
+
request_context["method"] = ctx.method
|
|
450
|
+
if ctx.path:
|
|
451
|
+
request_context["path"] = ctx.path
|
|
452
|
+
if ctx.referer:
|
|
453
|
+
request_context["referer"] = ctx.referer
|
|
454
|
+
if ctx.request_size:
|
|
455
|
+
request_context["requestSize"] = ctx.request_size
|
|
456
|
+
|
|
457
|
+
# Create log entry using helper function
|
|
458
|
+
correlation_id = (
|
|
459
|
+
options.correlationId if options else None
|
|
460
|
+
) or self._generate_correlation_id()
|
|
461
|
+
return build_log_entry(
|
|
462
|
+
level=level,
|
|
463
|
+
message=message,
|
|
464
|
+
context=request_context,
|
|
465
|
+
config_client_id=self.config.client_id,
|
|
466
|
+
correlation_id=correlation_id,
|
|
467
|
+
jwt_token=options.token if options else None,
|
|
468
|
+
stack_trace=stack_trace,
|
|
469
|
+
options=options,
|
|
470
|
+
metadata=extract_metadata(),
|
|
471
|
+
mask_sensitive=self.mask_sensitive_data,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def get_with_context(
|
|
475
|
+
self,
|
|
476
|
+
context: Dict[str, Any],
|
|
477
|
+
message: str,
|
|
478
|
+
level: Literal["error", "audit", "info", "debug"] = "info",
|
|
479
|
+
stack_trace: Optional[str] = None,
|
|
480
|
+
options: Optional[ClientLoggingOptions] = None,
|
|
481
|
+
) -> LogEntry:
|
|
482
|
+
"""
|
|
483
|
+
Get LogEntry object with custom context.
|
|
484
|
+
|
|
485
|
+
Adds custom context and returns LogEntry object.
|
|
486
|
+
Allows projects to add their own context while leveraging MisoClient defaults.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
context: Custom context data
|
|
490
|
+
message: Log message
|
|
491
|
+
level: Log level (default: "info")
|
|
492
|
+
stack_trace: Stack trace for errors (optional)
|
|
493
|
+
options: Optional logging options (optional)
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
LogEntry object with custom context
|
|
497
|
+
|
|
498
|
+
Example:
|
|
499
|
+
>>> log_entry = logger.get_with_context(
|
|
500
|
+
... {"customField": "value"},
|
|
501
|
+
... "Custom log",
|
|
502
|
+
... level="info"
|
|
503
|
+
... )
|
|
504
|
+
"""
|
|
505
|
+
final_options = options or ClientLoggingOptions()
|
|
506
|
+
correlation_id = (
|
|
507
|
+
final_options.correlationId if final_options else None
|
|
508
|
+
) or self._generate_correlation_id()
|
|
509
|
+
return build_log_entry(
|
|
510
|
+
level=level,
|
|
511
|
+
message=message,
|
|
512
|
+
context=context,
|
|
513
|
+
config_client_id=self.config.client_id,
|
|
514
|
+
correlation_id=correlation_id,
|
|
515
|
+
jwt_token=final_options.token if final_options else None,
|
|
516
|
+
stack_trace=stack_trace,
|
|
517
|
+
options=final_options,
|
|
518
|
+
metadata=extract_metadata(),
|
|
519
|
+
mask_sensitive=self.mask_sensitive_data,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def get_with_token(
|
|
523
|
+
self,
|
|
524
|
+
token: str,
|
|
525
|
+
message: str,
|
|
526
|
+
level: Literal["error", "audit", "info", "debug"] = "info",
|
|
527
|
+
context: Optional[Dict[str, Any]] = None,
|
|
528
|
+
stack_trace: Optional[str] = None,
|
|
529
|
+
) -> LogEntry:
|
|
530
|
+
"""
|
|
531
|
+
Get LogEntry object with JWT token context extracted.
|
|
532
|
+
|
|
533
|
+
Extracts userId, sessionId from JWT token.
|
|
534
|
+
Returns LogEntry with user context extracted.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
token: JWT token string
|
|
538
|
+
message: Log message
|
|
539
|
+
level: Log level (default: "info")
|
|
540
|
+
context: Additional context data (optional)
|
|
541
|
+
stack_trace: Stack trace for errors (optional)
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
LogEntry object with user context extracted
|
|
545
|
+
|
|
546
|
+
Example:
|
|
547
|
+
>>> log_entry = logger.get_with_token(
|
|
548
|
+
... "jwt-token",
|
|
549
|
+
... "User action",
|
|
550
|
+
... level="audit"
|
|
551
|
+
... )
|
|
552
|
+
"""
|
|
553
|
+
options = ClientLoggingOptions(token=token)
|
|
554
|
+
correlation_id = (
|
|
555
|
+
options.correlationId if options else None
|
|
556
|
+
) or self._generate_correlation_id()
|
|
557
|
+
return build_log_entry(
|
|
558
|
+
level=level,
|
|
559
|
+
message=message,
|
|
560
|
+
context=context,
|
|
561
|
+
config_client_id=self.config.client_id,
|
|
562
|
+
correlation_id=correlation_id,
|
|
563
|
+
jwt_token=token,
|
|
564
|
+
stack_trace=stack_trace,
|
|
565
|
+
options=options,
|
|
566
|
+
metadata=extract_metadata(),
|
|
567
|
+
mask_sensitive=self.mask_sensitive_data,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def get_for_request(
|
|
571
|
+
self,
|
|
572
|
+
request: Any,
|
|
573
|
+
message: str,
|
|
574
|
+
level: Literal["error", "audit", "info", "debug"] = "info",
|
|
575
|
+
context: Optional[Dict[str, Any]] = None,
|
|
576
|
+
stack_trace: Optional[str] = None,
|
|
577
|
+
) -> LogEntry:
|
|
578
|
+
"""
|
|
579
|
+
Get LogEntry object with request context (alias for get_log_with_request).
|
|
580
|
+
|
|
581
|
+
Same functionality as get_log_with_request() for convenience.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
request: HTTP request object (FastAPI, Flask, Starlette)
|
|
585
|
+
message: Log message
|
|
586
|
+
level: Log level (default: "info")
|
|
587
|
+
context: Additional context data (optional)
|
|
588
|
+
stack_trace: Stack trace for errors (optional)
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
LogEntry object with request context extracted
|
|
592
|
+
|
|
593
|
+
Example:
|
|
594
|
+
>>> log_entry = logger.get_for_request(request, "Request processed")
|
|
595
|
+
"""
|
|
596
|
+
return self.get_log_with_request(request, message, level, context, stack_trace)
|