dory-sdk 2.1.0__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.
- dory/__init__.py +70 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +290 -0
- dory/cli/templates.py +333 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +50 -0
- dory/config/loader.py +361 -0
- dory/config/presets.py +325 -0
- dory/config/schema.py +152 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +404 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +654 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/errors/__init__.py +117 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +495 -0
- dory/health/__init__.py +10 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +306 -0
- dory/k8s/__init__.py +11 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +175 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +36 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +321 -0
- dory/middleware/request_tracker.py +501 -0
- dory/migration/__init__.py +11 -0
- dory/migration/configmap.py +260 -0
- dory/migration/serialization.py +167 -0
- dory/migration/state_manager.py +301 -0
- dory/monitoring/__init__.py +23 -0
- dory/monitoring/opentelemetry.py +462 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +480 -0
- dory/recovery/golden_snapshot.py +561 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +479 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +187 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +454 -0
- dory/resilience/retry.py +389 -0
- dory/sidecar/__init__.py +6 -0
- dory/sidecar/main.py +75 -0
- dory/sidecar/server.py +329 -0
- dory/simple.py +342 -0
- dory/types.py +75 -0
- dory/utils/__init__.py +25 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_sdk-2.1.0.dist-info/METADATA +663 -0
- dory_sdk-2.1.0.dist-info/RECORD +69 -0
- dory_sdk-2.1.0.dist-info/WHEEL +5 -0
- dory_sdk-2.1.0.dist-info/entry_points.txt +3 -0
- dory_sdk-2.1.0.dist-info/top_level.txt +1 -0
dory/k8s/client.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kubernetes client wrapper.
|
|
3
|
+
|
|
4
|
+
Provides simplified interface to Kubernetes API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from dory.utils.errors import DoryK8sError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Optional kubernetes import
|
|
15
|
+
try:
|
|
16
|
+
from kubernetes import client, config
|
|
17
|
+
from kubernetes.client.rest import ApiException
|
|
18
|
+
K8S_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
K8S_AVAILABLE = False
|
|
21
|
+
client = None
|
|
22
|
+
config = None
|
|
23
|
+
ApiException = Exception
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class K8sClient:
|
|
27
|
+
"""
|
|
28
|
+
Kubernetes API client wrapper.
|
|
29
|
+
|
|
30
|
+
Handles configuration loading and provides
|
|
31
|
+
simplified access to common operations.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, namespace: str | None = None):
|
|
35
|
+
"""
|
|
36
|
+
Initialize Kubernetes client.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
namespace: Kubernetes namespace (auto-detected if not provided)
|
|
40
|
+
"""
|
|
41
|
+
self._namespace = namespace
|
|
42
|
+
self._core_api: Any = None
|
|
43
|
+
self._initialized = False
|
|
44
|
+
|
|
45
|
+
def _ensure_initialized(self) -> None:
|
|
46
|
+
"""Initialize Kubernetes client if not already done."""
|
|
47
|
+
if self._initialized:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if not K8S_AVAILABLE:
|
|
51
|
+
raise DoryK8sError(
|
|
52
|
+
"Kubernetes client not available. "
|
|
53
|
+
"Install with: pip install kubernetes"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Try in-cluster config first
|
|
58
|
+
config.load_incluster_config()
|
|
59
|
+
logger.debug("Using in-cluster Kubernetes config")
|
|
60
|
+
except config.ConfigException:
|
|
61
|
+
try:
|
|
62
|
+
# Fall back to kubeconfig
|
|
63
|
+
config.load_kube_config()
|
|
64
|
+
logger.debug("Using kubeconfig")
|
|
65
|
+
except config.ConfigException as e:
|
|
66
|
+
raise DoryK8sError(f"Failed to load Kubernetes config: {e}", cause=e)
|
|
67
|
+
|
|
68
|
+
self._core_api = client.CoreV1Api()
|
|
69
|
+
|
|
70
|
+
# Auto-detect namespace if not provided
|
|
71
|
+
if not self._namespace:
|
|
72
|
+
import os
|
|
73
|
+
self._namespace = os.environ.get("POD_NAMESPACE", "default")
|
|
74
|
+
|
|
75
|
+
self._initialized = True
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def namespace(self) -> str:
|
|
79
|
+
"""Get current namespace."""
|
|
80
|
+
self._ensure_initialized()
|
|
81
|
+
return self._namespace
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def core_api(self):
|
|
85
|
+
"""Get CoreV1Api client."""
|
|
86
|
+
self._ensure_initialized()
|
|
87
|
+
return self._core_api
|
|
88
|
+
|
|
89
|
+
async def get_pod(self, name: str) -> dict[str, Any]:
|
|
90
|
+
"""
|
|
91
|
+
Get pod details.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name: Pod name
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Pod details as dictionary
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
DoryK8sError: If operation fails
|
|
101
|
+
"""
|
|
102
|
+
self._ensure_initialized()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
pod = self._core_api.read_namespaced_pod(
|
|
106
|
+
name=name,
|
|
107
|
+
namespace=self._namespace,
|
|
108
|
+
)
|
|
109
|
+
return pod.to_dict()
|
|
110
|
+
|
|
111
|
+
except ApiException as e:
|
|
112
|
+
if e.status == 404:
|
|
113
|
+
raise DoryK8sError(f"Pod not found: {name}", cause=e)
|
|
114
|
+
raise DoryK8sError(f"Failed to get pod {name}: {e}", cause=e)
|
|
115
|
+
|
|
116
|
+
async def get_pod_annotations(self, name: str) -> dict[str, str]:
|
|
117
|
+
"""
|
|
118
|
+
Get pod annotations.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name: Pod name
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Annotations dictionary
|
|
125
|
+
"""
|
|
126
|
+
pod = await self.get_pod(name)
|
|
127
|
+
return pod.get("metadata", {}).get("annotations", {})
|
|
128
|
+
|
|
129
|
+
async def patch_pod_annotations(
|
|
130
|
+
self,
|
|
131
|
+
name: str,
|
|
132
|
+
annotations: dict[str, str],
|
|
133
|
+
) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Patch pod annotations.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
name: Pod name
|
|
139
|
+
annotations: Annotations to add/update
|
|
140
|
+
"""
|
|
141
|
+
self._ensure_initialized()
|
|
142
|
+
|
|
143
|
+
body = {
|
|
144
|
+
"metadata": {
|
|
145
|
+
"annotations": annotations,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
self._core_api.patch_namespaced_pod(
|
|
151
|
+
name=name,
|
|
152
|
+
namespace=self._namespace,
|
|
153
|
+
body=body,
|
|
154
|
+
)
|
|
155
|
+
logger.debug(f"Patched annotations on pod {name}")
|
|
156
|
+
|
|
157
|
+
except ApiException as e:
|
|
158
|
+
raise DoryK8sError(f"Failed to patch pod {name}: {e}", cause=e)
|
|
159
|
+
|
|
160
|
+
async def get_configmap(self, name: str) -> dict[str, str] | None:
|
|
161
|
+
"""
|
|
162
|
+
Get ConfigMap data.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
name: ConfigMap name
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
ConfigMap data, or None if not found
|
|
169
|
+
"""
|
|
170
|
+
self._ensure_initialized()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
cm = self._core_api.read_namespaced_config_map(
|
|
174
|
+
name=name,
|
|
175
|
+
namespace=self._namespace,
|
|
176
|
+
)
|
|
177
|
+
return cm.data or {}
|
|
178
|
+
|
|
179
|
+
except ApiException as e:
|
|
180
|
+
if e.status == 404:
|
|
181
|
+
return None
|
|
182
|
+
raise DoryK8sError(f"Failed to get ConfigMap {name}: {e}", cause=e)
|
|
183
|
+
|
|
184
|
+
async def create_or_update_configmap(
|
|
185
|
+
self,
|
|
186
|
+
name: str,
|
|
187
|
+
data: dict[str, str],
|
|
188
|
+
labels: dict[str, str] | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Create or update a ConfigMap.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
name: ConfigMap name
|
|
195
|
+
data: ConfigMap data
|
|
196
|
+
labels: Optional labels
|
|
197
|
+
"""
|
|
198
|
+
self._ensure_initialized()
|
|
199
|
+
|
|
200
|
+
configmap = client.V1ConfigMap(
|
|
201
|
+
metadata=client.V1ObjectMeta(
|
|
202
|
+
name=name,
|
|
203
|
+
namespace=self._namespace,
|
|
204
|
+
labels=labels,
|
|
205
|
+
),
|
|
206
|
+
data=data,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Try create first
|
|
211
|
+
self._core_api.create_namespaced_config_map(
|
|
212
|
+
namespace=self._namespace,
|
|
213
|
+
body=configmap,
|
|
214
|
+
)
|
|
215
|
+
logger.debug(f"Created ConfigMap {name}")
|
|
216
|
+
|
|
217
|
+
except ApiException as e:
|
|
218
|
+
if e.status == 409:
|
|
219
|
+
# Already exists, update
|
|
220
|
+
self._core_api.replace_namespaced_config_map(
|
|
221
|
+
name=name,
|
|
222
|
+
namespace=self._namespace,
|
|
223
|
+
body=configmap,
|
|
224
|
+
)
|
|
225
|
+
logger.debug(f"Updated ConfigMap {name}")
|
|
226
|
+
else:
|
|
227
|
+
raise DoryK8sError(f"Failed to create ConfigMap {name}: {e}", cause=e)
|
|
228
|
+
|
|
229
|
+
async def delete_configmap(self, name: str) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Delete a ConfigMap.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
name: ConfigMap name
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
True if deleted, False if not found
|
|
238
|
+
"""
|
|
239
|
+
self._ensure_initialized()
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
self._core_api.delete_namespaced_config_map(
|
|
243
|
+
name=name,
|
|
244
|
+
namespace=self._namespace,
|
|
245
|
+
)
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
except ApiException as e:
|
|
249
|
+
if e.status == 404:
|
|
250
|
+
return False
|
|
251
|
+
raise DoryK8sError(f"Failed to delete ConfigMap {name}: {e}", cause=e)
|
dory/k8s/pod_metadata.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pod metadata extraction from Kubernetes environment.
|
|
3
|
+
|
|
4
|
+
Retrieves pod information from:
|
|
5
|
+
1. Downward API environment variables
|
|
6
|
+
2. Kubernetes API
|
|
7
|
+
3. /etc/podinfo files
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PodMetadata:
|
|
21
|
+
"""
|
|
22
|
+
Pod metadata extracted from environment.
|
|
23
|
+
|
|
24
|
+
Populated from:
|
|
25
|
+
- Environment variables (POD_NAME, POD_NAMESPACE, etc.)
|
|
26
|
+
- Downward API files (/etc/podinfo/)
|
|
27
|
+
- Kubernetes API (if available)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Core identification
|
|
31
|
+
name: str = ""
|
|
32
|
+
namespace: str = "default"
|
|
33
|
+
uid: str = ""
|
|
34
|
+
|
|
35
|
+
# Node information
|
|
36
|
+
node_name: str = ""
|
|
37
|
+
service_account: str = ""
|
|
38
|
+
|
|
39
|
+
# Labels and annotations
|
|
40
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
annotations: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
# Resource info
|
|
44
|
+
cpu_request: str = ""
|
|
45
|
+
cpu_limit: str = ""
|
|
46
|
+
memory_request: str = ""
|
|
47
|
+
memory_limit: str = ""
|
|
48
|
+
|
|
49
|
+
# Container info
|
|
50
|
+
container_name: str = ""
|
|
51
|
+
image: str = ""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_environment(cls) -> "PodMetadata":
|
|
55
|
+
"""
|
|
56
|
+
Create PodMetadata from environment.
|
|
57
|
+
|
|
58
|
+
Reads from environment variables and downward API files.
|
|
59
|
+
"""
|
|
60
|
+
metadata = cls()
|
|
61
|
+
|
|
62
|
+
# Read from environment variables
|
|
63
|
+
metadata.name = os.environ.get("POD_NAME", "")
|
|
64
|
+
metadata.namespace = os.environ.get("POD_NAMESPACE", "default")
|
|
65
|
+
metadata.uid = os.environ.get("POD_UID", "")
|
|
66
|
+
metadata.node_name = os.environ.get("NODE_NAME", "")
|
|
67
|
+
metadata.service_account = os.environ.get("SERVICE_ACCOUNT", "")
|
|
68
|
+
metadata.container_name = os.environ.get("CONTAINER_NAME", "")
|
|
69
|
+
|
|
70
|
+
# Try reading from downward API files
|
|
71
|
+
metadata._read_downward_api_files()
|
|
72
|
+
|
|
73
|
+
# Parse labels/annotations from environment
|
|
74
|
+
metadata._parse_labels_from_env()
|
|
75
|
+
|
|
76
|
+
logger.debug(f"Pod metadata: {metadata}")
|
|
77
|
+
return metadata
|
|
78
|
+
|
|
79
|
+
def _read_downward_api_files(self) -> None:
|
|
80
|
+
"""Read metadata from downward API volume mounts."""
|
|
81
|
+
podinfo_path = Path("/etc/podinfo")
|
|
82
|
+
|
|
83
|
+
if not podinfo_path.exists():
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Read labels file
|
|
87
|
+
labels_file = podinfo_path / "labels"
|
|
88
|
+
if labels_file.exists():
|
|
89
|
+
self.labels = self._parse_labels_file(labels_file)
|
|
90
|
+
|
|
91
|
+
# Read annotations file
|
|
92
|
+
annotations_file = podinfo_path / "annotations"
|
|
93
|
+
if annotations_file.exists():
|
|
94
|
+
self.annotations = self._parse_labels_file(annotations_file)
|
|
95
|
+
|
|
96
|
+
# Read individual files
|
|
97
|
+
for attr, filename in [
|
|
98
|
+
("name", "name"),
|
|
99
|
+
("namespace", "namespace"),
|
|
100
|
+
("uid", "uid"),
|
|
101
|
+
("node_name", "nodeName"),
|
|
102
|
+
]:
|
|
103
|
+
file_path = podinfo_path / filename
|
|
104
|
+
if file_path.exists():
|
|
105
|
+
try:
|
|
106
|
+
value = file_path.read_text().strip()
|
|
107
|
+
if value:
|
|
108
|
+
setattr(self, attr, value)
|
|
109
|
+
except IOError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def _parse_labels_file(self, path: Path) -> dict[str, str]:
|
|
113
|
+
"""
|
|
114
|
+
Parse labels/annotations file.
|
|
115
|
+
|
|
116
|
+
Format: key="value"
|
|
117
|
+
"""
|
|
118
|
+
result = {}
|
|
119
|
+
try:
|
|
120
|
+
content = path.read_text()
|
|
121
|
+
for line in content.strip().split("\n"):
|
|
122
|
+
if "=" in line:
|
|
123
|
+
key, value = line.split("=", 1)
|
|
124
|
+
# Remove quotes
|
|
125
|
+
value = value.strip('"')
|
|
126
|
+
result[key] = value
|
|
127
|
+
except IOError:
|
|
128
|
+
pass
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def _parse_labels_from_env(self) -> None:
|
|
132
|
+
"""Parse labels from POD_LABELS environment variable."""
|
|
133
|
+
labels_env = os.environ.get("POD_LABELS", "")
|
|
134
|
+
if labels_env:
|
|
135
|
+
# Format: key1=value1,key2=value2
|
|
136
|
+
for pair in labels_env.split(","):
|
|
137
|
+
if "=" in pair:
|
|
138
|
+
key, value = pair.split("=", 1)
|
|
139
|
+
self.labels[key] = value
|
|
140
|
+
|
|
141
|
+
def get_processor_id(self) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Get processor ID from metadata.
|
|
144
|
+
|
|
145
|
+
Uses label if present, otherwise pod name.
|
|
146
|
+
"""
|
|
147
|
+
# Try label first
|
|
148
|
+
processor_id = self.labels.get("dory.io/processor-id")
|
|
149
|
+
if processor_id:
|
|
150
|
+
return processor_id
|
|
151
|
+
|
|
152
|
+
# Fall back to pod name with suffix stripped
|
|
153
|
+
name = self.name
|
|
154
|
+
if name:
|
|
155
|
+
# Strip deployment suffix (e.g., myapp-7f8d9c6b-x4h2j -> myapp)
|
|
156
|
+
parts = name.rsplit("-", 2)
|
|
157
|
+
if len(parts) >= 2:
|
|
158
|
+
return parts[0]
|
|
159
|
+
|
|
160
|
+
return name or "unknown"
|
|
161
|
+
|
|
162
|
+
def is_migration(self) -> bool:
|
|
163
|
+
"""Check if this is a migration restart."""
|
|
164
|
+
return self.annotations.get("dory.io/migration") == "true"
|
|
165
|
+
|
|
166
|
+
def get_previous_pod(self) -> str | None:
|
|
167
|
+
"""Get previous pod name if this is a migration."""
|
|
168
|
+
return self.annotations.get("dory.io/previous-pod")
|
|
169
|
+
|
|
170
|
+
def to_dict(self) -> dict[str, Any]:
|
|
171
|
+
"""Convert to dictionary."""
|
|
172
|
+
return {
|
|
173
|
+
"name": self.name,
|
|
174
|
+
"namespace": self.namespace,
|
|
175
|
+
"uid": self.uid,
|
|
176
|
+
"node_name": self.node_name,
|
|
177
|
+
"service_account": self.service_account,
|
|
178
|
+
"labels": self.labels,
|
|
179
|
+
"annotations": self.annotations,
|
|
180
|
+
"container_name": self.container_name,
|
|
181
|
+
"processor_id": self.get_processor_id(),
|
|
182
|
+
}
|
dory/logging/__init__.py
ADDED
dory/logging/logger.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging for Dory SDK.
|
|
3
|
+
|
|
4
|
+
Provides JSON-formatted logging suitable for Kubernetes
|
|
5
|
+
and log aggregation systems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from dory.types import LogFormat
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonFormatter(logging.Formatter):
|
|
18
|
+
"""
|
|
19
|
+
JSON log formatter for structured logging.
|
|
20
|
+
|
|
21
|
+
Output format:
|
|
22
|
+
{
|
|
23
|
+
"timestamp": "2024-01-15T10:30:45.123456Z",
|
|
24
|
+
"level": "INFO",
|
|
25
|
+
"logger": "dory.core.app",
|
|
26
|
+
"message": "Processor starting",
|
|
27
|
+
"extra": {...}
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
32
|
+
"""Format log record as JSON."""
|
|
33
|
+
log_dict = {
|
|
34
|
+
"timestamp": self._format_timestamp(record.created),
|
|
35
|
+
"level": record.levelname,
|
|
36
|
+
"logger": record.name,
|
|
37
|
+
"message": record.getMessage(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Add exception info if present
|
|
41
|
+
if record.exc_info:
|
|
42
|
+
log_dict["exception"] = self.formatException(record.exc_info)
|
|
43
|
+
|
|
44
|
+
# Add extra fields
|
|
45
|
+
extra = {}
|
|
46
|
+
for key, value in record.__dict__.items():
|
|
47
|
+
if key not in (
|
|
48
|
+
"name", "msg", "args", "created", "levelname", "levelno",
|
|
49
|
+
"pathname", "filename", "module", "exc_info", "exc_text",
|
|
50
|
+
"stack_info", "lineno", "funcName", "relativeCreated",
|
|
51
|
+
"thread", "threadName", "processName", "process", "message",
|
|
52
|
+
"msecs", "taskName",
|
|
53
|
+
):
|
|
54
|
+
extra[key] = self._serialize_value(value)
|
|
55
|
+
|
|
56
|
+
if extra:
|
|
57
|
+
log_dict["extra"] = extra
|
|
58
|
+
|
|
59
|
+
return json.dumps(log_dict)
|
|
60
|
+
|
|
61
|
+
def _format_timestamp(self, created: float) -> str:
|
|
62
|
+
"""Format timestamp as ISO 8601."""
|
|
63
|
+
return time.strftime(
|
|
64
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
65
|
+
time.gmtime(created)
|
|
66
|
+
) + f".{int((created % 1) * 1000000):06d}Z"
|
|
67
|
+
|
|
68
|
+
def _serialize_value(self, value: Any) -> Any:
|
|
69
|
+
"""Serialize value for JSON."""
|
|
70
|
+
if isinstance(value, (str, int, float, bool, type(None))):
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, (list, tuple)):
|
|
73
|
+
return [self._serialize_value(v) for v in value]
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return {k: self._serialize_value(v) for k, v in value.items()}
|
|
76
|
+
return str(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TextFormatter(logging.Formatter):
|
|
80
|
+
"""
|
|
81
|
+
Human-readable text formatter.
|
|
82
|
+
|
|
83
|
+
Output format:
|
|
84
|
+
2024-01-15 10:30:45.123 [INFO] dory.core.app: Processor starting
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
super().__init__(
|
|
89
|
+
fmt="%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s",
|
|
90
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DoryLoggerAdapter(logging.LoggerAdapter):
|
|
95
|
+
"""
|
|
96
|
+
Logger adapter that adds context to all log messages.
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
logger = DoryLoggerAdapter(
|
|
100
|
+
logging.getLogger(__name__),
|
|
101
|
+
{"processor_id": "my-processor", "pod": "my-pod-abc123"}
|
|
102
|
+
)
|
|
103
|
+
logger.info("Processing started")
|
|
104
|
+
# Output includes processor_id and pod in extra fields
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
|
|
108
|
+
"""Add extra context to log kwargs."""
|
|
109
|
+
extra = kwargs.get("extra", {})
|
|
110
|
+
extra.update(self.extra)
|
|
111
|
+
kwargs["extra"] = extra
|
|
112
|
+
return msg, kwargs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def setup_logging(
|
|
116
|
+
level: str = "INFO",
|
|
117
|
+
format: str | LogFormat = LogFormat.JSON,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Setup logging configuration for Dory SDK.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
124
|
+
format: Log format (json or text)
|
|
125
|
+
"""
|
|
126
|
+
# Convert enum to string if needed
|
|
127
|
+
if isinstance(format, LogFormat):
|
|
128
|
+
format = format.value
|
|
129
|
+
|
|
130
|
+
# Get root logger
|
|
131
|
+
root_logger = logging.getLogger()
|
|
132
|
+
root_logger.setLevel(getattr(logging, level.upper()))
|
|
133
|
+
|
|
134
|
+
# Remove existing handlers
|
|
135
|
+
for handler in root_logger.handlers[:]:
|
|
136
|
+
root_logger.removeHandler(handler)
|
|
137
|
+
|
|
138
|
+
# Create console handler
|
|
139
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
140
|
+
handler.setLevel(getattr(logging, level.upper()))
|
|
141
|
+
|
|
142
|
+
# Set formatter based on format
|
|
143
|
+
if format == "json":
|
|
144
|
+
handler.setFormatter(JsonFormatter())
|
|
145
|
+
else:
|
|
146
|
+
handler.setFormatter(TextFormatter())
|
|
147
|
+
|
|
148
|
+
root_logger.addHandler(handler)
|
|
149
|
+
|
|
150
|
+
# Set levels for noisy libraries
|
|
151
|
+
logging.getLogger("kubernetes").setLevel(logging.WARNING)
|
|
152
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
153
|
+
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_logger(
|
|
157
|
+
name: str,
|
|
158
|
+
extra: dict[str, Any] | None = None,
|
|
159
|
+
) -> logging.Logger | DoryLoggerAdapter:
|
|
160
|
+
"""
|
|
161
|
+
Get a logger with optional extra context.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: Logger name (usually __name__)
|
|
165
|
+
extra: Optional extra context to include in all logs
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Logger or LoggerAdapter
|
|
169
|
+
"""
|
|
170
|
+
logger = logging.getLogger(name)
|
|
171
|
+
|
|
172
|
+
if extra:
|
|
173
|
+
return DoryLoggerAdapter(logger, extra)
|
|
174
|
+
|
|
175
|
+
return logger
|