lucidicai 2.0.2__py3-none-any.whl → 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.
- lucidicai/__init__.py +350 -899
- lucidicai/api/__init__.py +1 -0
- lucidicai/api/client.py +218 -0
- lucidicai/api/resources/__init__.py +1 -0
- lucidicai/api/resources/dataset.py +192 -0
- lucidicai/api/resources/event.py +88 -0
- lucidicai/api/resources/session.py +126 -0
- lucidicai/core/__init__.py +1 -0
- lucidicai/core/config.py +223 -0
- lucidicai/core/errors.py +60 -0
- lucidicai/core/types.py +35 -0
- lucidicai/sdk/__init__.py +1 -0
- lucidicai/sdk/context.py +144 -0
- lucidicai/sdk/decorators.py +187 -0
- lucidicai/sdk/error_boundary.py +299 -0
- lucidicai/sdk/event.py +122 -0
- lucidicai/sdk/event_builder.py +304 -0
- lucidicai/sdk/features/__init__.py +1 -0
- lucidicai/sdk/features/dataset.py +605 -0
- lucidicai/sdk/features/feature_flag.py +383 -0
- lucidicai/sdk/init.py +271 -0
- lucidicai/sdk/shutdown_manager.py +302 -0
- lucidicai/telemetry/context_bridge.py +82 -0
- lucidicai/telemetry/context_capture_processor.py +25 -9
- lucidicai/telemetry/litellm_bridge.py +18 -24
- lucidicai/telemetry/lucidic_exporter.py +51 -36
- lucidicai/telemetry/utils/model_pricing.py +278 -0
- lucidicai/utils/__init__.py +1 -0
- lucidicai/utils/images.py +337 -0
- lucidicai/utils/logger.py +168 -0
- lucidicai/utils/queue.py +393 -0
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/METADATA +1 -1
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/RECORD +35 -8
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/WHEEL +0 -0
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Consolidated image handling utilities.
|
|
2
|
+
|
|
3
|
+
This module unifies all image-related functionality from:
|
|
4
|
+
- image_upload.py
|
|
5
|
+
- telemetry/utils/image_storage.py
|
|
6
|
+
- Various extraction functions scattered across the codebase
|
|
7
|
+
"""
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import List, Dict, Any, Optional, Tuple, Union
|
|
13
|
+
from PIL import Image
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("Lucidic")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ImageHandler:
|
|
20
|
+
"""Centralized image handling for the SDK."""
|
|
21
|
+
|
|
22
|
+
# Thread-local storage for images (from telemetry)
|
|
23
|
+
_thread_local = threading.local()
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def extract_base64_images(cls, data: Any) -> List[str]:
|
|
27
|
+
"""Extract base64 image URLs from various data structures.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: Can be a string, dict, list, or nested structure containing image data
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of base64 image data URLs (data:image/...)
|
|
34
|
+
"""
|
|
35
|
+
images = []
|
|
36
|
+
|
|
37
|
+
if isinstance(data, str):
|
|
38
|
+
if data.startswith('data:image'):
|
|
39
|
+
images.append(data)
|
|
40
|
+
elif isinstance(data, dict):
|
|
41
|
+
# Check for specific image fields
|
|
42
|
+
if 'image' in data and isinstance(data['image'], str):
|
|
43
|
+
if data['image'].startswith('data:image'):
|
|
44
|
+
images.append(data['image'])
|
|
45
|
+
|
|
46
|
+
# Check for image_url structures (OpenAI format)
|
|
47
|
+
if data.get('type') == 'image_url':
|
|
48
|
+
image_url = data.get('image_url', {})
|
|
49
|
+
if isinstance(image_url, dict) and 'url' in image_url:
|
|
50
|
+
url = image_url['url']
|
|
51
|
+
if url.startswith('data:image'):
|
|
52
|
+
images.append(url)
|
|
53
|
+
|
|
54
|
+
# Recursively check all values
|
|
55
|
+
for value in data.values():
|
|
56
|
+
images.extend(cls.extract_base64_images(value))
|
|
57
|
+
|
|
58
|
+
elif isinstance(data, list):
|
|
59
|
+
for item in data:
|
|
60
|
+
images.extend(cls.extract_base64_images(item))
|
|
61
|
+
|
|
62
|
+
return images
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def extract_images_from_messages(cls, messages: List[Dict[str, Any]]) -> List[str]:
|
|
66
|
+
"""Extract images from chat messages (OpenAI/Anthropic format).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
messages: List of message dictionaries
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of base64 image data URLs
|
|
73
|
+
"""
|
|
74
|
+
images = []
|
|
75
|
+
|
|
76
|
+
for message in messages:
|
|
77
|
+
if not isinstance(message, dict):
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
content = message.get('content', '')
|
|
81
|
+
|
|
82
|
+
# Handle multimodal content
|
|
83
|
+
if isinstance(content, list):
|
|
84
|
+
for item in content:
|
|
85
|
+
if isinstance(item, dict):
|
|
86
|
+
if item.get('type') == 'image_url':
|
|
87
|
+
image_url = item.get('image_url', {})
|
|
88
|
+
if isinstance(image_url, dict):
|
|
89
|
+
url = image_url.get('url', '')
|
|
90
|
+
if url.startswith('data:image'):
|
|
91
|
+
images.append(url)
|
|
92
|
+
elif item.get('type') == 'image':
|
|
93
|
+
# Anthropic format
|
|
94
|
+
source = item.get('source', {})
|
|
95
|
+
if isinstance(source, dict):
|
|
96
|
+
data = source.get('data', '')
|
|
97
|
+
if data:
|
|
98
|
+
media_type = source.get('media_type', 'image/jpeg')
|
|
99
|
+
images.append(f"data:{media_type};base64,{data}")
|
|
100
|
+
|
|
101
|
+
return images
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def store_image_thread_local(cls, image_base64: str) -> str:
|
|
105
|
+
"""Store image in thread-local storage and return placeholder.
|
|
106
|
+
|
|
107
|
+
Used for working around OpenTelemetry attribute size limits.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
image_base64: Base64 encoded image data
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Placeholder string for the stored image
|
|
114
|
+
"""
|
|
115
|
+
if not hasattr(cls._thread_local, 'images'):
|
|
116
|
+
cls._thread_local.images = []
|
|
117
|
+
|
|
118
|
+
cls._thread_local.images.append(image_base64)
|
|
119
|
+
placeholder = f"lucidic_image_{len(cls._thread_local.images) - 1}"
|
|
120
|
+
|
|
121
|
+
logger.debug(f"[ImageHandler] Stored image in thread-local, placeholder: {placeholder}")
|
|
122
|
+
return placeholder
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def get_stored_images(cls) -> List[str]:
|
|
126
|
+
"""Get all images stored in thread-local storage."""
|
|
127
|
+
if hasattr(cls._thread_local, 'images'):
|
|
128
|
+
return cls._thread_local.images
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def clear_stored_images(cls) -> None:
|
|
133
|
+
"""Clear thread-local image storage."""
|
|
134
|
+
if hasattr(cls._thread_local, 'images'):
|
|
135
|
+
cls._thread_local.images.clear()
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def get_image_by_placeholder(cls, placeholder: str) -> Optional[str]:
|
|
139
|
+
"""Retrieve image by its placeholder from thread-local storage."""
|
|
140
|
+
if hasattr(cls._thread_local, 'images') and placeholder.startswith('lucidic_image_'):
|
|
141
|
+
try:
|
|
142
|
+
index = int(placeholder.split('_')[-1])
|
|
143
|
+
if 0 <= index < len(cls._thread_local.images):
|
|
144
|
+
return cls._thread_local.images[index]
|
|
145
|
+
except (ValueError, IndexError):
|
|
146
|
+
pass
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def path_to_base64(cls, image_path: str, format: str = "JPEG") -> str:
|
|
151
|
+
"""Convert image file to base64 string.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
image_path: Path to the image file
|
|
155
|
+
format: Output format (JPEG or PNG)
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Base64 encoded image string
|
|
159
|
+
"""
|
|
160
|
+
img = Image.open(image_path)
|
|
161
|
+
|
|
162
|
+
if format == "JPEG":
|
|
163
|
+
# Convert to RGB if necessary
|
|
164
|
+
if img.mode in ("RGBA", "LA", "P"):
|
|
165
|
+
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
166
|
+
if img.mode == "RGBA" or img.mode == "LA":
|
|
167
|
+
alpha = img.split()[-1]
|
|
168
|
+
background.paste(img, mask=alpha)
|
|
169
|
+
else:
|
|
170
|
+
background.paste(img)
|
|
171
|
+
img = background
|
|
172
|
+
else:
|
|
173
|
+
img = img.convert("RGB")
|
|
174
|
+
|
|
175
|
+
buffered = io.BytesIO()
|
|
176
|
+
img.save(buffered, format=format)
|
|
177
|
+
img_bytes = buffered.getvalue()
|
|
178
|
+
|
|
179
|
+
return base64.b64encode(img_bytes).decode('utf-8')
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def base64_to_pil(cls, base64_str: str) -> Image.Image:
|
|
183
|
+
"""Convert base64 string to PIL Image.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
base64_str: Base64 encoded image (with or without data URI prefix)
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
PIL Image object
|
|
190
|
+
"""
|
|
191
|
+
# Remove data URI prefix if present
|
|
192
|
+
if base64_str.startswith('data:'):
|
|
193
|
+
base64_str = base64_str.split(',')[1] if ',' in base64_str else base64_str
|
|
194
|
+
|
|
195
|
+
image_data = base64.b64decode(base64_str)
|
|
196
|
+
image_stream = io.BytesIO(image_data)
|
|
197
|
+
return Image.open(image_stream)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def prepare_for_upload(cls, image_data: Union[str, bytes], format: str = "JPEG") -> Tuple[io.BytesIO, str]:
|
|
201
|
+
"""Prepare image data for upload to S3.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
image_data: Base64 string or raw bytes
|
|
205
|
+
format: Target format (JPEG or GIF)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Tuple of (BytesIO object, content-type)
|
|
209
|
+
"""
|
|
210
|
+
if format == "JPEG":
|
|
211
|
+
# Handle base64 string
|
|
212
|
+
if isinstance(image_data, str):
|
|
213
|
+
pil_image = cls.base64_to_pil(image_data)
|
|
214
|
+
else:
|
|
215
|
+
pil_image = Image.open(io.BytesIO(image_data))
|
|
216
|
+
|
|
217
|
+
# Convert to RGB
|
|
218
|
+
if pil_image.mode in ("RGBA", "LA"):
|
|
219
|
+
background = Image.new("RGB", pil_image.size, (255, 255, 255))
|
|
220
|
+
alpha = pil_image.split()[-1]
|
|
221
|
+
background.paste(pil_image, mask=alpha)
|
|
222
|
+
pil_image = background
|
|
223
|
+
else:
|
|
224
|
+
pil_image = pil_image.convert("RGB")
|
|
225
|
+
|
|
226
|
+
image_obj = io.BytesIO()
|
|
227
|
+
pil_image.save(image_obj, format="JPEG")
|
|
228
|
+
image_obj.seek(0)
|
|
229
|
+
content_type = "image/jpeg"
|
|
230
|
+
|
|
231
|
+
elif format == "GIF":
|
|
232
|
+
if isinstance(image_data, str):
|
|
233
|
+
image_data = base64.b64decode(image_data.split(',')[1] if ',' in image_data else image_data)
|
|
234
|
+
image_obj = io.BytesIO(image_data)
|
|
235
|
+
content_type = "image/gif"
|
|
236
|
+
|
|
237
|
+
else:
|
|
238
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
239
|
+
|
|
240
|
+
return image_obj, content_type
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ImageUploader:
|
|
244
|
+
"""Handle image uploads to S3."""
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def get_presigned_url(
|
|
248
|
+
agent_id: str,
|
|
249
|
+
session_id: Optional[str] = None,
|
|
250
|
+
event_id: Optional[str] = None,
|
|
251
|
+
nthscreenshot: Optional[int] = None
|
|
252
|
+
) -> Tuple[str, str, str]:
|
|
253
|
+
"""Get a presigned URL for uploading an image to S3.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
agent_id: The ID of the agent
|
|
257
|
+
session_id: Optional session ID for the image
|
|
258
|
+
event_id: Optional event ID for the image
|
|
259
|
+
nthscreenshot: Optional nth screenshot for the image
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Tuple of (presigned_url, bucket_name, object_key)
|
|
263
|
+
"""
|
|
264
|
+
# Import here to avoid circular dependency
|
|
265
|
+
from ..sdk.init import get_http
|
|
266
|
+
|
|
267
|
+
http = get_http()
|
|
268
|
+
if not http:
|
|
269
|
+
raise RuntimeError("SDK not initialized")
|
|
270
|
+
|
|
271
|
+
request_data = {"agent_id": agent_id}
|
|
272
|
+
|
|
273
|
+
if session_id:
|
|
274
|
+
request_data["session_id"] = session_id
|
|
275
|
+
|
|
276
|
+
if event_id:
|
|
277
|
+
request_data["event_id"] = event_id
|
|
278
|
+
if nthscreenshot is None:
|
|
279
|
+
raise ValueError("nth_screenshot is required when event_id is provided")
|
|
280
|
+
request_data["nth_screenshot"] = nthscreenshot
|
|
281
|
+
|
|
282
|
+
response = http.get('getpresigneduploadurl', params=request_data)
|
|
283
|
+
return response['presigned_url'], response['bucket_name'], response['object_key']
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def upload_to_s3(url: str, image_data: Union[str, bytes, io.BytesIO], format: str = "JPEG") -> None:
|
|
287
|
+
"""Upload an image to S3 using presigned URL.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
url: The presigned URL for the upload
|
|
291
|
+
image_data: Image data (base64 string, bytes, or BytesIO)
|
|
292
|
+
format: Format of the image (JPEG or GIF)
|
|
293
|
+
"""
|
|
294
|
+
# Prepare image for upload
|
|
295
|
+
if isinstance(image_data, io.BytesIO):
|
|
296
|
+
image_obj = image_data
|
|
297
|
+
content_type = "image/jpeg" if format == "JPEG" else "image/gif"
|
|
298
|
+
else:
|
|
299
|
+
image_obj, content_type = ImageHandler.prepare_for_upload(image_data, format)
|
|
300
|
+
|
|
301
|
+
# Upload to S3
|
|
302
|
+
upload_response = requests.put(
|
|
303
|
+
url,
|
|
304
|
+
data=image_obj.getvalue() if hasattr(image_obj, 'getvalue') else image_obj,
|
|
305
|
+
headers={"Content-Type": content_type}
|
|
306
|
+
)
|
|
307
|
+
upload_response.raise_for_status()
|
|
308
|
+
|
|
309
|
+
logger.debug(f"[ImageUploader] Successfully uploaded image to S3")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Convenience functions for backward compatibility
|
|
313
|
+
def extract_base64_images(data: Any) -> List[str]:
|
|
314
|
+
"""Extract base64 images from data (backward compatibility)."""
|
|
315
|
+
return ImageHandler.extract_base64_images(data)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def screenshot_path_to_jpeg(screenshot_path: str) -> str:
|
|
319
|
+
"""Convert screenshot to base64 JPEG (backward compatibility)."""
|
|
320
|
+
return ImageHandler.path_to_base64(screenshot_path, "JPEG")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def upload_image_to_s3(url: str, image: Union[str, bytes], format: str) -> None:
|
|
324
|
+
"""Upload image to S3 (backward compatibility)."""
|
|
325
|
+
ImageUploader.upload_to_s3(url, image, format)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_presigned_url(
|
|
329
|
+
agent_id: str,
|
|
330
|
+
step_id: Optional[str] = None,
|
|
331
|
+
session_id: Optional[str] = None,
|
|
332
|
+
event_id: Optional[str] = None,
|
|
333
|
+
nthscreenshot: Optional[int] = None
|
|
334
|
+
) -> Tuple[str, str, str]:
|
|
335
|
+
"""Get presigned URL (backward compatibility)."""
|
|
336
|
+
# Note: step_id parameter is deprecated
|
|
337
|
+
return ImageUploader.get_presigned_url(agent_id, session_id, event_id, nthscreenshot)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Centralized logging utilities for Lucidic SDK.
|
|
2
|
+
|
|
3
|
+
This module provides consistent logging functions that respect
|
|
4
|
+
LUCIDIC_DEBUG and LUCIDIC_VERBOSE environment variables.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
# Load environment variables from .env file
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
# Configure base logger
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
17
|
+
level=logging.WARNING # Default to WARNING, will be overridden by env vars
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Get the logger
|
|
21
|
+
logger = logging.getLogger("Lucidic")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _env_true(value: Optional[str]) -> bool:
|
|
25
|
+
"""Check if environment variable is truthy."""
|
|
26
|
+
if value is None:
|
|
27
|
+
return False
|
|
28
|
+
return value.lower() in ('true', '1', 'yes')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_debug() -> bool:
|
|
32
|
+
"""Check if debug mode is enabled."""
|
|
33
|
+
return _env_true(os.getenv('LUCIDIC_DEBUG'))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_verbose() -> bool:
|
|
37
|
+
"""Check if verbose mode is enabled."""
|
|
38
|
+
return _env_true(os.getenv('LUCIDIC_VERBOSE'))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def debug(message: str, *args: Any, **kwargs: Any) -> None:
|
|
42
|
+
"""Log debug message if LUCIDIC_DEBUG is enabled.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
message: Log message with optional formatting
|
|
46
|
+
*args: Positional arguments for message formatting
|
|
47
|
+
**kwargs: Keyword arguments for logging
|
|
48
|
+
"""
|
|
49
|
+
if is_debug():
|
|
50
|
+
logger.debug(f"[DEBUG] {message}", *args, **kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def info(message: str, *args: Any, **kwargs: Any) -> None:
|
|
54
|
+
"""Log info message (always visible).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
message: Log message with optional formatting
|
|
58
|
+
*args: Positional arguments for message formatting
|
|
59
|
+
**kwargs: Keyword arguments for logging
|
|
60
|
+
"""
|
|
61
|
+
logger.info(message, *args, **kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def warning(message: str, *args: Any, **kwargs: Any) -> None:
|
|
65
|
+
"""Log warning message (always visible).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
message: Log message with optional formatting
|
|
69
|
+
*args: Positional arguments for message formatting
|
|
70
|
+
**kwargs: Keyword arguments for logging
|
|
71
|
+
"""
|
|
72
|
+
logger.warning(message, *args, **kwargs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def error(message: str, *args: Any, **kwargs: Any) -> None:
|
|
76
|
+
"""Log error message (always visible).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
message: Log message with optional formatting
|
|
80
|
+
*args: Positional arguments for message formatting
|
|
81
|
+
**kwargs: Keyword arguments for logging
|
|
82
|
+
"""
|
|
83
|
+
logger.error(message, *args, **kwargs)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verbose(message: str, *args: Any, **kwargs: Any) -> None:
|
|
87
|
+
"""Log verbose message if LUCIDIC_VERBOSE or LUCIDIC_DEBUG is enabled.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
message: Log message with optional formatting
|
|
91
|
+
*args: Positional arguments for message formatting
|
|
92
|
+
**kwargs: Keyword arguments for logging
|
|
93
|
+
"""
|
|
94
|
+
if is_debug() or is_verbose():
|
|
95
|
+
logger.info(f"[VERBOSE] {message}", *args, **kwargs)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def truncate_id(id_str: Optional[str], length: int = 8) -> str:
|
|
99
|
+
"""Truncate UUID for logging.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
id_str: UUID string to truncate
|
|
103
|
+
length: Number of characters to keep
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Truncated ID with ellipsis
|
|
107
|
+
"""
|
|
108
|
+
if not id_str:
|
|
109
|
+
return "None"
|
|
110
|
+
if len(id_str) <= length:
|
|
111
|
+
return id_str
|
|
112
|
+
return f"{id_str[:length]}..."
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mask_sensitive(data: dict, sensitive_keys: set = None) -> dict:
|
|
116
|
+
"""Mask sensitive data in dictionary for logging.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
data: Dictionary potentially containing sensitive data
|
|
120
|
+
sensitive_keys: Set of keys to mask (default: common sensitive keys)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Dictionary with sensitive values masked
|
|
124
|
+
"""
|
|
125
|
+
if sensitive_keys is None:
|
|
126
|
+
sensitive_keys = {
|
|
127
|
+
'api_key', 'apikey', 'api-key',
|
|
128
|
+
'token', 'auth', 'authorization',
|
|
129
|
+
'password', 'secret', 'key',
|
|
130
|
+
'x-api-key', 'x-auth-token'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
masked = {}
|
|
134
|
+
for key, value in data.items():
|
|
135
|
+
if any(k in key.lower() for k in sensitive_keys):
|
|
136
|
+
if value:
|
|
137
|
+
# Show first few chars for debugging
|
|
138
|
+
masked[key] = f"{str(value)[:4]}...MASKED" if len(str(value)) > 4 else "MASKED"
|
|
139
|
+
else:
|
|
140
|
+
masked[key] = value
|
|
141
|
+
else:
|
|
142
|
+
masked[key] = value
|
|
143
|
+
return masked
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def truncate_data(data: Any, max_length: int = 500) -> str:
|
|
147
|
+
"""Truncate long data for logging.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
data: Data to truncate
|
|
151
|
+
max_length: Maximum length
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Truncated string representation
|
|
155
|
+
"""
|
|
156
|
+
str_data = str(data)
|
|
157
|
+
if len(str_data) <= max_length:
|
|
158
|
+
return str_data
|
|
159
|
+
return f"{str_data[:max_length]}... (truncated)"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Configure logger level based on environment
|
|
163
|
+
if is_debug():
|
|
164
|
+
logger.setLevel(logging.DEBUG)
|
|
165
|
+
elif is_verbose():
|
|
166
|
+
logger.setLevel(logging.INFO)
|
|
167
|
+
else:
|
|
168
|
+
logger.setLevel(logging.WARNING)
|