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,383 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Union, List, Dict, Any, Optional, overload, Tuple, Literal
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
from ..init import get_http
|
|
8
|
+
from ...core.errors import APIKeyVerificationError, FeatureFlagError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("Lucidic")
|
|
11
|
+
|
|
12
|
+
# Cache implementation
|
|
13
|
+
class FeatureFlagCache:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._cache: Dict[str, tuple[Any, float]] = {}
|
|
16
|
+
self._default_ttl = 300 # 5 minutes
|
|
17
|
+
|
|
18
|
+
def get(self, key: str) -> Optional[Any]:
|
|
19
|
+
if key in self._cache:
|
|
20
|
+
value, expiry = self._cache[key]
|
|
21
|
+
if time.time() < expiry:
|
|
22
|
+
return value
|
|
23
|
+
else:
|
|
24
|
+
del self._cache[key]
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def set(self, key: str, value: Any, ttl: int = None):
|
|
28
|
+
if ttl is None:
|
|
29
|
+
ttl = self._default_ttl
|
|
30
|
+
if ttl > 0:
|
|
31
|
+
self._cache[key] = (value, time.time() + ttl)
|
|
32
|
+
|
|
33
|
+
def clear(self):
|
|
34
|
+
self._cache.clear()
|
|
35
|
+
|
|
36
|
+
# Global cache instance
|
|
37
|
+
_flag_cache = FeatureFlagCache()
|
|
38
|
+
|
|
39
|
+
# Sentinel value to distinguish None from missing
|
|
40
|
+
MISSING = object()
|
|
41
|
+
|
|
42
|
+
# Function overloads for type safety
|
|
43
|
+
@overload
|
|
44
|
+
def get_feature_flag(
|
|
45
|
+
flag_name: str,
|
|
46
|
+
default: Any = ...,
|
|
47
|
+
*,
|
|
48
|
+
return_missing: Literal[False] = False,
|
|
49
|
+
cache_ttl: Optional[int] = 300,
|
|
50
|
+
api_key: Optional[str] = None,
|
|
51
|
+
agent_id: Optional[str] = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""Get a single feature flag."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
@overload
|
|
57
|
+
def get_feature_flag(
|
|
58
|
+
flag_name: str,
|
|
59
|
+
default: Any = ...,
|
|
60
|
+
*,
|
|
61
|
+
return_missing: Literal[True],
|
|
62
|
+
cache_ttl: Optional[int] = 300,
|
|
63
|
+
api_key: Optional[str] = None,
|
|
64
|
+
agent_id: Optional[str] = None,
|
|
65
|
+
) -> Tuple[Any, List[str]]:
|
|
66
|
+
"""Get a single feature flag with missing info."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def get_feature_flag(
|
|
71
|
+
flag_name: List[str],
|
|
72
|
+
defaults: Optional[Dict[str, Any]] = None,
|
|
73
|
+
*,
|
|
74
|
+
return_missing: Literal[False] = False,
|
|
75
|
+
cache_ttl: Optional[int] = 300,
|
|
76
|
+
api_key: Optional[str] = None,
|
|
77
|
+
agent_id: Optional[str] = None,
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
"""Get multiple feature flags."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
@overload
|
|
83
|
+
def get_feature_flag(
|
|
84
|
+
flag_name: List[str],
|
|
85
|
+
defaults: Optional[Dict[str, Any]] = None,
|
|
86
|
+
*,
|
|
87
|
+
return_missing: Literal[True],
|
|
88
|
+
cache_ttl: Optional[int] = 300,
|
|
89
|
+
api_key: Optional[str] = None,
|
|
90
|
+
agent_id: Optional[str] = None,
|
|
91
|
+
) -> Tuple[Dict[str, Any], List[str]]:
|
|
92
|
+
"""Get multiple feature flags with missing info."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
def get_feature_flag(
|
|
96
|
+
flag_name: Union[str, List[str]],
|
|
97
|
+
default_or_defaults: Any = MISSING,
|
|
98
|
+
*,
|
|
99
|
+
return_missing: bool = False,
|
|
100
|
+
cache_ttl: Optional[int] = 300,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
agent_id: Optional[str] = None,
|
|
103
|
+
) -> Union[Any, Tuple[Any, List[str]], Dict[str, Any], Tuple[Dict[str, Any], List[str]]]:
|
|
104
|
+
"""
|
|
105
|
+
Get feature flag(s) from backend. Raises FeatureFlagError on failure unless default provided.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
flag_name: Single flag name (str) or list of flag names
|
|
109
|
+
default_or_defaults:
|
|
110
|
+
- If flag_name is str: default value for that flag (optional)
|
|
111
|
+
- If flag_name is List[str]: dict of defaults {flag_name: default_value}
|
|
112
|
+
cache_ttl: Cache time-to-live in seconds (0 to disable, -1 for forever)
|
|
113
|
+
api_key: Optional API key
|
|
114
|
+
agent_id: Optional agent ID
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
- If flag_name is str: The flag value (or tuple with missing list if return_missing=True)
|
|
118
|
+
- If flag_name is List[str]: Dict mapping flag_name -> value (or tuple with missing list if return_missing=True)
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
122
|
+
APIKeyVerificationError: If credentials missing
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
# Single flag with default
|
|
126
|
+
retries = lai.get_feature_flag("max_retries", default=3)
|
|
127
|
+
|
|
128
|
+
# Single flag without default (can raise)
|
|
129
|
+
retries = lai.get_feature_flag("max_retries")
|
|
130
|
+
|
|
131
|
+
# Multiple flags
|
|
132
|
+
flags = lai.get_feature_flag(
|
|
133
|
+
["max_retries", "timeout"],
|
|
134
|
+
defaults={"max_retries": 3}
|
|
135
|
+
)
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
load_dotenv()
|
|
139
|
+
|
|
140
|
+
# Determine if single or batch
|
|
141
|
+
is_single = isinstance(flag_name, str)
|
|
142
|
+
flag_names = [flag_name] if is_single else flag_name
|
|
143
|
+
|
|
144
|
+
# Parse defaults
|
|
145
|
+
if is_single:
|
|
146
|
+
has_default = default_or_defaults is not MISSING
|
|
147
|
+
defaults = {flag_name: default_or_defaults} if has_default else {}
|
|
148
|
+
else:
|
|
149
|
+
defaults = default_or_defaults if default_or_defaults not in (None, MISSING) else {}
|
|
150
|
+
|
|
151
|
+
# Track missing flags
|
|
152
|
+
missing_flags = []
|
|
153
|
+
|
|
154
|
+
# Check cache first
|
|
155
|
+
uncached_flags = []
|
|
156
|
+
cached_results = {}
|
|
157
|
+
|
|
158
|
+
if cache_ttl != 0:
|
|
159
|
+
for name in flag_names:
|
|
160
|
+
cache_key = f"{agent_id}:{name}"
|
|
161
|
+
cached_value = _flag_cache.get(cache_key)
|
|
162
|
+
if cached_value is not None:
|
|
163
|
+
cached_results[name] = cached_value
|
|
164
|
+
else:
|
|
165
|
+
uncached_flags.append(name)
|
|
166
|
+
else:
|
|
167
|
+
uncached_flags = flag_names
|
|
168
|
+
|
|
169
|
+
# Fetch uncached flags if needed
|
|
170
|
+
if uncached_flags:
|
|
171
|
+
# Get credentials
|
|
172
|
+
if api_key is None:
|
|
173
|
+
api_key = os.getenv("LUCIDIC_API_KEY", None)
|
|
174
|
+
if api_key is None:
|
|
175
|
+
raise APIKeyVerificationError(
|
|
176
|
+
"Make sure to either pass your API key or set the LUCIDIC_API_KEY environment variable."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if agent_id is None:
|
|
180
|
+
agent_id = os.getenv("LUCIDIC_AGENT_ID", None)
|
|
181
|
+
if agent_id is None:
|
|
182
|
+
raise APIKeyVerificationError(
|
|
183
|
+
"Lucidic agent ID not specified. Make sure to either pass your agent ID or set the LUCIDIC_AGENT_ID environment variable."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Get HTTP client
|
|
187
|
+
http = get_http()
|
|
188
|
+
if not http:
|
|
189
|
+
from ..init import init
|
|
190
|
+
init(api_key=api_key, agent_id=agent_id)
|
|
191
|
+
http = get_http()
|
|
192
|
+
|
|
193
|
+
# check for active session
|
|
194
|
+
from ..init import get_session_id
|
|
195
|
+
session_id = get_session_id()
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
if len(uncached_flags) == 1:
|
|
199
|
+
# Single flag evaluation
|
|
200
|
+
if session_id:
|
|
201
|
+
# Use session-based evaluation for consistency
|
|
202
|
+
response = http.post('evaluatefeatureflag', {
|
|
203
|
+
'session_id': session_id,
|
|
204
|
+
'flag_name': uncached_flags[0],
|
|
205
|
+
'context': {},
|
|
206
|
+
'default': defaults.get(uncached_flags[0])
|
|
207
|
+
})
|
|
208
|
+
else:
|
|
209
|
+
# Use stateless evaluation as fallback
|
|
210
|
+
response = http.post('evaluatefeatureflagstateless', {
|
|
211
|
+
'agent_id': agent_id,
|
|
212
|
+
'flag_name': uncached_flags[0],
|
|
213
|
+
'context': {},
|
|
214
|
+
'default': defaults.get(uncached_flags[0])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
# Extract value from response
|
|
218
|
+
if 'value' in response:
|
|
219
|
+
value = response['value']
|
|
220
|
+
cached_results[uncached_flags[0]] = value
|
|
221
|
+
|
|
222
|
+
# Cache the result
|
|
223
|
+
if cache_ttl != 0:
|
|
224
|
+
cache_key = f"{agent_id}:{uncached_flags[0]}"
|
|
225
|
+
_flag_cache.set(cache_key, value, ttl=cache_ttl if cache_ttl > 0 else None)
|
|
226
|
+
elif 'error' in response:
|
|
227
|
+
# Flag not found or error
|
|
228
|
+
logger.warning(f"Feature flag error: {response['error']}")
|
|
229
|
+
missing_flags.append(uncached_flags[0])
|
|
230
|
+
|
|
231
|
+
else:
|
|
232
|
+
# Batch evaluation
|
|
233
|
+
if session_id:
|
|
234
|
+
# Use session-based batch evaluation
|
|
235
|
+
response = http.post('evaluatebatchfeatureflags', {
|
|
236
|
+
'session_id': session_id,
|
|
237
|
+
'flag_names': uncached_flags,
|
|
238
|
+
'context': {},
|
|
239
|
+
'defaults': {k: v for k, v in defaults.items() if k in uncached_flags}
|
|
240
|
+
})
|
|
241
|
+
else:
|
|
242
|
+
# Use stateless batch evaluation
|
|
243
|
+
response = http.post('evaluatebatchfeatureflagsstateless', {
|
|
244
|
+
'agent_id': agent_id,
|
|
245
|
+
'flag_names': uncached_flags,
|
|
246
|
+
'context': {},
|
|
247
|
+
'defaults': {k: v for k, v in defaults.items() if k in uncached_flags}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
# Process batch response
|
|
251
|
+
if 'flags' in response:
|
|
252
|
+
for name in uncached_flags:
|
|
253
|
+
flag_data = response['flags'].get(name)
|
|
254
|
+
if flag_data and 'value' in flag_data:
|
|
255
|
+
value = flag_data['value']
|
|
256
|
+
cached_results[name] = value
|
|
257
|
+
|
|
258
|
+
# Cache it
|
|
259
|
+
if cache_ttl != 0:
|
|
260
|
+
cache_key = f"{agent_id}:{name}"
|
|
261
|
+
_flag_cache.set(cache_key, value, ttl=cache_ttl if cache_ttl > 0 else None)
|
|
262
|
+
else:
|
|
263
|
+
missing_flags.append(name)
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
# HTTP client raises on errors, fall back to defaults
|
|
267
|
+
logger.error(f"Failed to fetch feature flags: {e}")
|
|
268
|
+
|
|
269
|
+
# Use defaults for all uncached flags
|
|
270
|
+
for name in uncached_flags:
|
|
271
|
+
if name in defaults:
|
|
272
|
+
cached_results[name] = defaults[name]
|
|
273
|
+
else:
|
|
274
|
+
missing_flags.append(name)
|
|
275
|
+
if is_single and not return_missing:
|
|
276
|
+
raise FeatureFlagError(f"'{name}': {e}") from e
|
|
277
|
+
|
|
278
|
+
# Build final result
|
|
279
|
+
result = {}
|
|
280
|
+
for name in flag_names:
|
|
281
|
+
if name in cached_results:
|
|
282
|
+
result[name] = cached_results[name]
|
|
283
|
+
elif name in defaults:
|
|
284
|
+
result[name] = defaults[name]
|
|
285
|
+
else:
|
|
286
|
+
# No value and no default
|
|
287
|
+
missing_flags.append(name)
|
|
288
|
+
if is_single and not return_missing:
|
|
289
|
+
raise FeatureFlagError(f"'{name}' not found and no default provided")
|
|
290
|
+
else:
|
|
291
|
+
result[name] = None
|
|
292
|
+
|
|
293
|
+
# Return based on input type and return_missing flag
|
|
294
|
+
if return_missing:
|
|
295
|
+
return (result[flag_names[0]] if is_single else result, missing_flags)
|
|
296
|
+
else:
|
|
297
|
+
return result[flag_names[0]] if is_single else result
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# Typed convenience functions
|
|
301
|
+
def get_bool_flag(flag_name: str, default: Optional[bool] = None, **kwargs) -> bool:
|
|
302
|
+
"""
|
|
303
|
+
Get a boolean feature flag with type validation.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
307
|
+
TypeError: If flag value is not a boolean
|
|
308
|
+
"""
|
|
309
|
+
value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
|
|
310
|
+
if not isinstance(value, bool):
|
|
311
|
+
if default is not None:
|
|
312
|
+
logger.warning(f"Feature flag '{flag_name}' is not a boolean, using default")
|
|
313
|
+
return default
|
|
314
|
+
raise TypeError(f"Feature flag '{flag_name}' expected boolean, got {type(value).__name__}")
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_int_flag(flag_name: str, default: Optional[int] = None, **kwargs) -> int:
|
|
319
|
+
"""
|
|
320
|
+
Get an integer feature flag with type validation.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
324
|
+
TypeError: If flag value is not an integer
|
|
325
|
+
"""
|
|
326
|
+
value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
|
|
327
|
+
if not isinstance(value, int) or isinstance(value, bool): # bool is subclass of int
|
|
328
|
+
if default is not None:
|
|
329
|
+
logger.warning(f"Feature flag '{flag_name}' is not an integer, using default")
|
|
330
|
+
return default
|
|
331
|
+
raise TypeError(f"Feature flag '{flag_name}' expected integer, got {type(value).__name__}")
|
|
332
|
+
return value
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_float_flag(flag_name: str, default: Optional[float] = None, **kwargs) -> float:
|
|
336
|
+
"""
|
|
337
|
+
Get a float feature flag with type validation.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
341
|
+
TypeError: If flag value is not a float
|
|
342
|
+
"""
|
|
343
|
+
value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
|
|
344
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
345
|
+
if default is not None:
|
|
346
|
+
logger.warning(f"Feature flag '{flag_name}' is not a float, using default")
|
|
347
|
+
return default
|
|
348
|
+
raise TypeError(f"Feature flag '{flag_name}' expected float, got {type(value).__name__}")
|
|
349
|
+
return float(value)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_string_flag(flag_name: str, default: Optional[str] = None, **kwargs) -> str:
|
|
353
|
+
"""
|
|
354
|
+
Get a string feature flag with type validation.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
358
|
+
TypeError: If flag value is not a string
|
|
359
|
+
"""
|
|
360
|
+
value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
|
|
361
|
+
if not isinstance(value, str):
|
|
362
|
+
if default is not None:
|
|
363
|
+
logger.warning(f"Feature flag '{flag_name}' is not a string, using default")
|
|
364
|
+
return default
|
|
365
|
+
raise TypeError(f"Feature flag '{flag_name}' expected string, got {type(value).__name__}")
|
|
366
|
+
return value
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_json_flag(flag_name: str, default: Optional[dict] = None, **kwargs) -> dict:
|
|
370
|
+
"""
|
|
371
|
+
Get a JSON object feature flag.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
FeatureFlagError: If fetch fails and no default provided
|
|
375
|
+
"""
|
|
376
|
+
value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
|
|
377
|
+
return value
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def clear_feature_flag_cache():
|
|
381
|
+
"""Clear the feature flag cache."""
|
|
382
|
+
_flag_cache.clear()
|
|
383
|
+
logger.debug("Feature flag cache cleared")
|
lucidicai/sdk/init.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""SDK initialization module.
|
|
2
|
+
|
|
3
|
+
This module handles SDK initialization, separating concerns from the main __init__.py
|
|
4
|
+
"""
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from ..api.client import HttpClient
|
|
9
|
+
from ..api.resources.event import EventResource
|
|
10
|
+
from ..api.resources.session import SessionResource
|
|
11
|
+
from ..api.resources.dataset import DatasetResource
|
|
12
|
+
from ..core.config import SDKConfig, get_config, set_config
|
|
13
|
+
from ..utils.queue import EventQueue
|
|
14
|
+
from ..utils.logger import debug, info, warning, error, truncate_id
|
|
15
|
+
from .context import set_active_session, current_session_id
|
|
16
|
+
from .error_boundary import register_cleanup_handler
|
|
17
|
+
from .shutdown_manager import get_shutdown_manager, SessionState
|
|
18
|
+
from ..telemetry.telemetry_init import instrument_providers
|
|
19
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SDKState:
|
|
23
|
+
"""Container for SDK runtime state."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.http: Optional[HttpClient] = None
|
|
27
|
+
self.event_queue: Optional[EventQueue] = None
|
|
28
|
+
self.session_id: Optional[str] = None
|
|
29
|
+
self.tracer_provider: Optional[TracerProvider] = None
|
|
30
|
+
self.resources = {}
|
|
31
|
+
|
|
32
|
+
def reset(self):
|
|
33
|
+
"""Reset SDK state."""
|
|
34
|
+
# Shutdown telemetry first to ensure all spans are exported
|
|
35
|
+
if self.tracer_provider:
|
|
36
|
+
try:
|
|
37
|
+
# Force flush all pending spans with 5 second timeout
|
|
38
|
+
debug("[SDK] Flushing OpenTelemetry spans...")
|
|
39
|
+
self.tracer_provider.force_flush(timeout_millis=5000)
|
|
40
|
+
# Shutdown the tracer provider and all processors
|
|
41
|
+
self.tracer_provider.shutdown()
|
|
42
|
+
debug("[SDK] TracerProvider shutdown complete")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
error(f"[SDK] Error shutting down TracerProvider: {e}")
|
|
45
|
+
|
|
46
|
+
if self.event_queue:
|
|
47
|
+
self.event_queue.shutdown()
|
|
48
|
+
if self.http:
|
|
49
|
+
self.http.close()
|
|
50
|
+
|
|
51
|
+
self.http = None
|
|
52
|
+
self.event_queue = None
|
|
53
|
+
self.session_id = None
|
|
54
|
+
self.tracer_provider = None
|
|
55
|
+
self.resources = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Global SDK state
|
|
59
|
+
_sdk_state = SDKState()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def init(
|
|
63
|
+
session_name: Optional[str] = None,
|
|
64
|
+
session_id: Optional[str] = None,
|
|
65
|
+
api_key: Optional[str] = None,
|
|
66
|
+
agent_id: Optional[str] = None,
|
|
67
|
+
task: Optional[str] = None,
|
|
68
|
+
providers: Optional[List[str]] = None,
|
|
69
|
+
production_monitoring: bool = False,
|
|
70
|
+
experiment_id: Optional[str] = None,
|
|
71
|
+
evaluators: Optional[List] = None,
|
|
72
|
+
tags: Optional[List] = None,
|
|
73
|
+
datasetitem_id: Optional[str] = None,
|
|
74
|
+
masking_function: Optional[callable] = None,
|
|
75
|
+
auto_end: bool = True,
|
|
76
|
+
capture_uncaught: bool = True,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Initialize the Lucidic SDK.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
session_name: Name for the session
|
|
82
|
+
session_id: Custom session ID (optional)
|
|
83
|
+
api_key: API key (uses env if not provided)
|
|
84
|
+
agent_id: Agent ID (uses env if not provided)
|
|
85
|
+
task: Task description
|
|
86
|
+
providers: List of telemetry providers to instrument
|
|
87
|
+
production_monitoring: Enable production monitoring
|
|
88
|
+
experiment_id: Experiment ID to associate with session
|
|
89
|
+
evaluators: Ealuators to use
|
|
90
|
+
tags: Session tags
|
|
91
|
+
datasetitem_id: Dataset item ID
|
|
92
|
+
masking_function: Function to mask sensitive data
|
|
93
|
+
auto_end: Automatically end session on exit
|
|
94
|
+
capture_uncaught: Capture uncaught exceptions
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Session ID
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
APIKeyVerificationError: If API credentials are invalid
|
|
101
|
+
"""
|
|
102
|
+
global _sdk_state
|
|
103
|
+
|
|
104
|
+
# Create or update configuration
|
|
105
|
+
config = SDKConfig.from_env(
|
|
106
|
+
api_key=api_key,
|
|
107
|
+
agent_id=agent_id,
|
|
108
|
+
auto_end=auto_end,
|
|
109
|
+
production_monitoring=production_monitoring
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if providers:
|
|
113
|
+
config.telemetry.providers = providers
|
|
114
|
+
|
|
115
|
+
config.error_handling.capture_uncaught = capture_uncaught
|
|
116
|
+
|
|
117
|
+
# Validate configuration
|
|
118
|
+
errors = config.validate()
|
|
119
|
+
if errors:
|
|
120
|
+
raise ValueError(f"Invalid configuration: {', '.join(errors)}")
|
|
121
|
+
|
|
122
|
+
# Set global config
|
|
123
|
+
set_config(config)
|
|
124
|
+
|
|
125
|
+
# Initialize HTTP client
|
|
126
|
+
if not _sdk_state.http:
|
|
127
|
+
debug("[SDK] Initializing HTTP client")
|
|
128
|
+
_sdk_state.http = HttpClient(config)
|
|
129
|
+
|
|
130
|
+
# Initialize resources
|
|
131
|
+
if not _sdk_state.resources:
|
|
132
|
+
_sdk_state.resources = {
|
|
133
|
+
'events': EventResource(_sdk_state.http),
|
|
134
|
+
'sessions': SessionResource(_sdk_state.http),
|
|
135
|
+
'datasets': DatasetResource(_sdk_state.http)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Initialize event queue
|
|
139
|
+
if not _sdk_state.event_queue:
|
|
140
|
+
debug("[SDK] Initializing event queue")
|
|
141
|
+
# Create a mock client object for backward compatibility
|
|
142
|
+
# The queue needs a client with make_request method
|
|
143
|
+
class ClientAdapter:
|
|
144
|
+
def make_request(self, endpoint, method, data):
|
|
145
|
+
return _sdk_state.http.request(method, endpoint, json=data)
|
|
146
|
+
|
|
147
|
+
_sdk_state.event_queue = EventQueue(ClientAdapter())
|
|
148
|
+
|
|
149
|
+
# Register cleanup handler
|
|
150
|
+
register_cleanup_handler(lambda: _sdk_state.event_queue.force_flush())
|
|
151
|
+
debug("[SDK] Event queue initialized and cleanup handler registered")
|
|
152
|
+
|
|
153
|
+
# Create or retrieve session
|
|
154
|
+
if session_id:
|
|
155
|
+
# Use provided session ID
|
|
156
|
+
real_session_id = session_id
|
|
157
|
+
else:
|
|
158
|
+
# Create new session
|
|
159
|
+
real_session_id = str(uuid.uuid4())
|
|
160
|
+
|
|
161
|
+
# Create session via API - only send non-None values
|
|
162
|
+
session_params = {
|
|
163
|
+
'session_id': real_session_id,
|
|
164
|
+
'session_name': session_name or 'Unnamed Session',
|
|
165
|
+
'agent_id': config.agent_id,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Only add optional fields if they have values
|
|
169
|
+
if task:
|
|
170
|
+
session_params['task'] = task
|
|
171
|
+
if tags:
|
|
172
|
+
session_params['tags'] = tags
|
|
173
|
+
if experiment_id:
|
|
174
|
+
session_params['experiment_id'] = experiment_id
|
|
175
|
+
if datasetitem_id:
|
|
176
|
+
session_params['datasetitem_id'] = datasetitem_id
|
|
177
|
+
if evaluators:
|
|
178
|
+
session_params['evaluators'] = evaluators
|
|
179
|
+
if production_monitoring:
|
|
180
|
+
session_params['production_monitoring'] = production_monitoring
|
|
181
|
+
|
|
182
|
+
debug(f"[SDK] Creating session with params: {session_params}")
|
|
183
|
+
session_resource = _sdk_state.resources['sessions']
|
|
184
|
+
session_data = session_resource.create_session(session_params)
|
|
185
|
+
|
|
186
|
+
# Use the session_id returned by the backend
|
|
187
|
+
real_session_id = session_data.get('session_id', real_session_id)
|
|
188
|
+
_sdk_state.session_id = real_session_id
|
|
189
|
+
|
|
190
|
+
info(f"[SDK] Session created: {truncate_id(real_session_id)} (name: {session_name or 'Unnamed Session'})")
|
|
191
|
+
|
|
192
|
+
# Set active session in context
|
|
193
|
+
set_active_session(real_session_id)
|
|
194
|
+
|
|
195
|
+
# Register session with shutdown manager
|
|
196
|
+
debug(f"[SDK] Registering session with shutdown manager (auto_end={auto_end})")
|
|
197
|
+
shutdown_manager = get_shutdown_manager()
|
|
198
|
+
session_state = SessionState(
|
|
199
|
+
session_id=real_session_id,
|
|
200
|
+
http_client=_sdk_state.resources, # Pass resources dict which has sessions
|
|
201
|
+
event_queue=_sdk_state.event_queue,
|
|
202
|
+
auto_end=auto_end
|
|
203
|
+
)
|
|
204
|
+
shutdown_manager.register_session(real_session_id, session_state)
|
|
205
|
+
|
|
206
|
+
# Initialize telemetry if providers specified
|
|
207
|
+
if providers:
|
|
208
|
+
debug(f"[SDK] Initializing telemetry for providers: {providers}")
|
|
209
|
+
_initialize_telemetry(providers)
|
|
210
|
+
|
|
211
|
+
return real_session_id
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _initialize_telemetry(providers: List[str]) -> None:
|
|
215
|
+
"""Initialize telemetry providers.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
providers: List of provider names
|
|
219
|
+
"""
|
|
220
|
+
global _sdk_state
|
|
221
|
+
|
|
222
|
+
if not _sdk_state.tracer_provider:
|
|
223
|
+
# Import here to avoid circular dependency
|
|
224
|
+
from ..telemetry.lucidic_exporter import LucidicSpanExporter
|
|
225
|
+
from ..telemetry.context_capture_processor import ContextCaptureProcessor
|
|
226
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
227
|
+
|
|
228
|
+
# Create tracer provider with our processors
|
|
229
|
+
_sdk_state.tracer_provider = TracerProvider()
|
|
230
|
+
|
|
231
|
+
# Add context capture processor FIRST to capture context before export
|
|
232
|
+
context_processor = ContextCaptureProcessor()
|
|
233
|
+
_sdk_state.tracer_provider.add_span_processor(context_processor)
|
|
234
|
+
|
|
235
|
+
# Add exporter processor
|
|
236
|
+
exporter = LucidicSpanExporter()
|
|
237
|
+
export_processor = BatchSpanProcessor(exporter)
|
|
238
|
+
_sdk_state.tracer_provider.add_span_processor(export_processor)
|
|
239
|
+
|
|
240
|
+
# Instrument providers
|
|
241
|
+
instrument_providers(providers, _sdk_state.tracer_provider, {})
|
|
242
|
+
|
|
243
|
+
info(f"[Telemetry] Initialized for providers: {providers}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_session_id() -> Optional[str]:
|
|
247
|
+
"""Get the current session ID."""
|
|
248
|
+
return _sdk_state.session_id or current_session_id.get()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_http() -> Optional[HttpClient]:
|
|
252
|
+
"""Get the HTTP client instance."""
|
|
253
|
+
return _sdk_state.http
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_event_queue() -> Optional[EventQueue]:
|
|
257
|
+
"""Get the event queue instance."""
|
|
258
|
+
return _sdk_state.event_queue
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_resources() -> dict:
|
|
262
|
+
"""Get API resource instances."""
|
|
263
|
+
return _sdk_state.resources
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def clear_state() -> None:
|
|
267
|
+
"""Clear SDK state (for testing)."""
|
|
268
|
+
global _sdk_state
|
|
269
|
+
debug("[SDK] Clearing SDK state")
|
|
270
|
+
_sdk_state.reset()
|
|
271
|
+
_sdk_state = SDKState()
|