lucidicai 2.1.0__tar.gz → 2.1.2__tar.gz
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-2.1.0 → lucidicai-2.1.2}/PKG-INFO +1 -1
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/__init__.py +28 -7
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/config.py +2 -2
- lucidicai-2.1.2/lucidicai/sdk/context.py +273 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/event.py +9 -5
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/init.py +100 -5
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/litellm_bridge.py +2 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/lucidic_exporter.py +63 -26
- lucidicai-2.1.2/lucidicai/telemetry/openai_patch.py +425 -0
- lucidicai-2.1.2/lucidicai/telemetry/openai_uninstrument.py +87 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/telemetry_init.py +16 -1
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/queue.py +38 -6
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/PKG-INFO +1 -1
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/SOURCES.txt +2 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/setup.py +1 -1
- lucidicai-2.1.0/lucidicai/sdk/context.py +0 -144
- {lucidicai-2.1.0 → lucidicai-2.1.2}/README.md +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/client.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/dataset.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/event.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/session.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/errors.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/types.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/decorators.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/error_boundary.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/event_builder.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/dataset.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/feature_flag.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/shutdown_manager.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/context_bridge.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/context_capture_processor.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/extract.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/openai_agents_instrumentor.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/utils/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/utils/model_pricing.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/__init__.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/images.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/logger.py +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/dependency_links.txt +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/requires.txt +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/top_level.txt +0 -0
- {lucidicai-2.1.0 → lucidicai-2.1.2}/setup.cfg +0 -0
|
@@ -14,6 +14,10 @@ from .sdk.init import (
|
|
|
14
14
|
init as _init,
|
|
15
15
|
get_session_id as _get_session_id,
|
|
16
16
|
clear_state as _clear_state,
|
|
17
|
+
# Thread-local session management (advanced users)
|
|
18
|
+
set_thread_session,
|
|
19
|
+
clear_thread_session,
|
|
20
|
+
get_thread_session,
|
|
17
21
|
)
|
|
18
22
|
|
|
19
23
|
from .sdk.event import (
|
|
@@ -32,6 +36,7 @@ from .sdk.context import (
|
|
|
32
36
|
session_async,
|
|
33
37
|
run_session,
|
|
34
38
|
run_in_session,
|
|
39
|
+
thread_worker_with_session, # Thread isolation helper
|
|
35
40
|
current_session_id,
|
|
36
41
|
current_parent_event_id,
|
|
37
42
|
)
|
|
@@ -55,12 +60,15 @@ def _update_session(
|
|
|
55
60
|
session_eval=None,
|
|
56
61
|
session_eval_reason=None,
|
|
57
62
|
is_successful=None,
|
|
58
|
-
is_successful_reason=None
|
|
63
|
+
is_successful_reason=None,
|
|
64
|
+
session_id=None # Accept explicit session_id
|
|
59
65
|
):
|
|
60
66
|
"""Update the current session."""
|
|
61
67
|
from .sdk.init import get_resources, get_session_id
|
|
62
|
-
|
|
63
|
-
session_id
|
|
68
|
+
|
|
69
|
+
# Use provided session_id or fall back to context
|
|
70
|
+
if not session_id:
|
|
71
|
+
session_id = get_session_id()
|
|
64
72
|
if not session_id:
|
|
65
73
|
return
|
|
66
74
|
|
|
@@ -87,12 +95,16 @@ def _end_session(
|
|
|
87
95
|
session_eval_reason=None,
|
|
88
96
|
is_successful=None,
|
|
89
97
|
is_successful_reason=None,
|
|
90
|
-
wait_for_flush=True
|
|
98
|
+
wait_for_flush=True,
|
|
99
|
+
session_id=None # Accept explicit session_id
|
|
91
100
|
):
|
|
92
101
|
"""End the current session."""
|
|
93
102
|
from .sdk.init import get_resources, get_session_id, get_event_queue
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
from .sdk.shutdown_manager import get_shutdown_manager
|
|
104
|
+
|
|
105
|
+
# Use provided session_id or fall back to context
|
|
106
|
+
if not session_id:
|
|
107
|
+
session_id = get_session_id()
|
|
96
108
|
if not session_id:
|
|
97
109
|
return
|
|
98
110
|
|
|
@@ -114,6 +126,9 @@ def _end_session(
|
|
|
114
126
|
# Clear session context
|
|
115
127
|
clear_active_session()
|
|
116
128
|
|
|
129
|
+
# unregister from shutdown manager
|
|
130
|
+
get_shutdown_manager().unregister_session(session_id)
|
|
131
|
+
|
|
117
132
|
|
|
118
133
|
def _get_session():
|
|
119
134
|
"""Get the current session object."""
|
|
@@ -282,7 +297,7 @@ get_error_history = error_boundary.get_error_history
|
|
|
282
297
|
clear_error_history = error_boundary.clear_error_history
|
|
283
298
|
|
|
284
299
|
# Version
|
|
285
|
-
__version__ = "2.
|
|
300
|
+
__version__ = "2.1.2"
|
|
286
301
|
|
|
287
302
|
# Apply error boundary wrapping to all SDK functions
|
|
288
303
|
from .sdk.error_boundary import wrap_sdk_function
|
|
@@ -371,8 +386,14 @@ __all__ = [
|
|
|
371
386
|
'session_async',
|
|
372
387
|
'run_session',
|
|
373
388
|
'run_in_session',
|
|
389
|
+
'thread_worker_with_session',
|
|
374
390
|
'current_session_id',
|
|
375
391
|
'current_parent_event_id',
|
|
392
|
+
|
|
393
|
+
# Thread-local session management (advanced)
|
|
394
|
+
'set_thread_session',
|
|
395
|
+
'clear_thread_session',
|
|
396
|
+
'get_thread_session',
|
|
376
397
|
|
|
377
398
|
# Error types
|
|
378
399
|
'LucidicError',
|
|
@@ -19,7 +19,7 @@ class Environment(Enum):
|
|
|
19
19
|
@dataclass
|
|
20
20
|
class NetworkConfig:
|
|
21
21
|
"""Network and connection settings"""
|
|
22
|
-
base_url: str = "https://
|
|
22
|
+
base_url: str = "https://backend.lucidic.ai/api"
|
|
23
23
|
timeout: int = 30
|
|
24
24
|
max_retries: int = 3
|
|
25
25
|
backoff_factor: float = 0.5
|
|
@@ -31,7 +31,7 @@ class NetworkConfig:
|
|
|
31
31
|
"""Load network configuration from environment variables"""
|
|
32
32
|
debug = os.getenv("LUCIDIC_DEBUG", "False").lower() == "true"
|
|
33
33
|
return cls(
|
|
34
|
-
base_url="http://localhost:8000/api" if debug else "https://
|
|
34
|
+
base_url="http://localhost:8000/api" if debug else "https://backend.lucidic.ai/api",
|
|
35
35
|
timeout=int(os.getenv("LUCIDIC_TIMEOUT", "30")),
|
|
36
36
|
max_retries=int(os.getenv("LUCIDIC_MAX_RETRIES", "3")),
|
|
37
37
|
backoff_factor=float(os.getenv("LUCIDIC_BACKOFF_FACTOR", "0.5")),
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Async-safe and thread-safe context helpers for session (and step, extensible).
|
|
2
|
+
|
|
3
|
+
This module exposes context variables and helpers to bind a Lucidic
|
|
4
|
+
session to the current execution context (threads/async tasks), so
|
|
5
|
+
OpenTelemetry spans can be deterministically attributed to the correct
|
|
6
|
+
session under concurrency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager, asynccontextmanager
|
|
10
|
+
import contextvars
|
|
11
|
+
from typing import Optional, Iterator, AsyncIterator, Callable, Any, Dict
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Context variable for the active Lucidic session id
|
|
18
|
+
current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
19
|
+
"lucidic.session_id", default=None
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# NEW: Context variable for parent event nesting
|
|
24
|
+
current_parent_event_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
25
|
+
"lucidic.parent_event_id", default=None
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_active_session(session_id: Optional[str]) -> None:
|
|
30
|
+
"""Bind the given session id to the current execution context.
|
|
31
|
+
|
|
32
|
+
Sets both contextvar and thread-local storage when in a thread.
|
|
33
|
+
"""
|
|
34
|
+
from .init import set_thread_session, is_main_thread
|
|
35
|
+
|
|
36
|
+
current_session_id.set(session_id)
|
|
37
|
+
|
|
38
|
+
# Also set thread-local storage if we're in a non-main thread
|
|
39
|
+
if session_id and not is_main_thread():
|
|
40
|
+
set_thread_session(session_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clear_active_session() -> None:
|
|
44
|
+
"""Clear any active session binding in the current execution context.
|
|
45
|
+
|
|
46
|
+
Clears both contextvar and thread-local storage when in a thread.
|
|
47
|
+
"""
|
|
48
|
+
from .init import clear_thread_session, is_main_thread
|
|
49
|
+
|
|
50
|
+
current_session_id.set(None)
|
|
51
|
+
|
|
52
|
+
# Also clear thread-local storage if we're in a non-main thread
|
|
53
|
+
if not is_main_thread():
|
|
54
|
+
clear_thread_session()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def bind_session(session_id: str) -> Iterator[None]:
|
|
59
|
+
"""Context manager to temporarily bind an active session id.
|
|
60
|
+
|
|
61
|
+
Handles both thread-local and context variable storage for proper isolation.
|
|
62
|
+
"""
|
|
63
|
+
from .init import set_thread_session, clear_thread_session, is_main_thread
|
|
64
|
+
|
|
65
|
+
token = current_session_id.set(session_id)
|
|
66
|
+
|
|
67
|
+
# If we're in a non-main thread, also set thread-local storage
|
|
68
|
+
thread_local_set = False
|
|
69
|
+
if not is_main_thread():
|
|
70
|
+
set_thread_session(session_id)
|
|
71
|
+
thread_local_set = True
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
if thread_local_set:
|
|
77
|
+
clear_thread_session()
|
|
78
|
+
current_session_id.reset(token)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def bind_session_async(session_id: str) -> AsyncIterator[None]:
|
|
83
|
+
"""Async context manager to temporarily bind an active session id."""
|
|
84
|
+
from .init import set_task_session, clear_task_session
|
|
85
|
+
|
|
86
|
+
token = current_session_id.set(session_id)
|
|
87
|
+
|
|
88
|
+
# Also set task-local for async isolation
|
|
89
|
+
set_task_session(session_id)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
yield
|
|
93
|
+
finally:
|
|
94
|
+
clear_task_session()
|
|
95
|
+
current_session_id.reset(token)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# NEW: Parent event context managers
|
|
99
|
+
@contextmanager
|
|
100
|
+
def event_context(event_id: str) -> Iterator[None]:
|
|
101
|
+
token = current_parent_event_id.set(event_id)
|
|
102
|
+
try:
|
|
103
|
+
yield
|
|
104
|
+
finally:
|
|
105
|
+
current_parent_event_id.reset(token)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def event_context_async(event_id: str) -> AsyncIterator[None]:
|
|
110
|
+
token = current_parent_event_id.set(event_id)
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
finally:
|
|
114
|
+
current_parent_event_id.reset(token)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def session(**init_params) -> Iterator[None]:
|
|
119
|
+
"""All-in-one context manager: init → bind → yield → clear → end.
|
|
120
|
+
|
|
121
|
+
Notes:
|
|
122
|
+
- Ignores any provided auto_end parameter and ends the session on context exit.
|
|
123
|
+
- If LUCIDIC_DEBUG is true, logs a warning about ignoring auto_end.
|
|
124
|
+
- Handles thread-local storage for proper thread isolation.
|
|
125
|
+
"""
|
|
126
|
+
# Lazy import to avoid circular imports
|
|
127
|
+
import lucidicai as lai # type: ignore
|
|
128
|
+
from .init import set_thread_session, clear_thread_session, is_main_thread
|
|
129
|
+
|
|
130
|
+
# Force auto_end to False inside a context manager to control explicit end
|
|
131
|
+
user_auto_end = init_params.get('auto_end', None)
|
|
132
|
+
init_params = dict(init_params)
|
|
133
|
+
init_params['auto_end'] = False
|
|
134
|
+
|
|
135
|
+
if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
|
|
136
|
+
logging.getLogger('Lucidic').warning('session(...) ignores auto_end and will end the session at context exit')
|
|
137
|
+
|
|
138
|
+
session_id = lai.init(**init_params)
|
|
139
|
+
token = current_session_id.set(session_id)
|
|
140
|
+
|
|
141
|
+
# If we're in a non-main thread, also set thread-local storage
|
|
142
|
+
thread_local_set = False
|
|
143
|
+
if not is_main_thread():
|
|
144
|
+
set_thread_session(session_id)
|
|
145
|
+
thread_local_set = True
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
if thread_local_set:
|
|
151
|
+
clear_thread_session()
|
|
152
|
+
current_session_id.reset(token)
|
|
153
|
+
try:
|
|
154
|
+
# Force flush OpenTelemetry spans before ending session
|
|
155
|
+
from .init import get_tracer_provider
|
|
156
|
+
from ..utils.logger import debug, info
|
|
157
|
+
import time
|
|
158
|
+
|
|
159
|
+
tracer_provider = get_tracer_provider()
|
|
160
|
+
if tracer_provider:
|
|
161
|
+
debug(f"[Session] Force flushing OpenTelemetry spans for session {session_id}")
|
|
162
|
+
try:
|
|
163
|
+
# Force flush with 5 second timeout to ensure all spans are exported
|
|
164
|
+
flush_result = tracer_provider.force_flush(timeout_millis=5000)
|
|
165
|
+
debug(f"[Session] Tracer provider force_flush returned: {flush_result}")
|
|
166
|
+
|
|
167
|
+
# Give a small additional delay to ensure the exporter processes everything
|
|
168
|
+
# This is necessary because force_flush on the provider flushes the processors,
|
|
169
|
+
# but the exporter might still be processing the spans
|
|
170
|
+
time.sleep(0.5)
|
|
171
|
+
debug(f"[Session] Successfully flushed spans for session {session_id}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
debug(f"[Session] Error flushing spans: {e}")
|
|
174
|
+
|
|
175
|
+
# Pass session_id explicitly to avoid context issues
|
|
176
|
+
lai.end_session(session_id=session_id)
|
|
177
|
+
except Exception:
|
|
178
|
+
# Avoid masking the original exception from the with-block
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@asynccontextmanager
|
|
183
|
+
async def session_async(**init_params) -> AsyncIterator[None]:
|
|
184
|
+
"""Async counterpart of session(...)."""
|
|
185
|
+
import lucidicai as lai # type: ignore
|
|
186
|
+
from .init import set_task_session, clear_task_session
|
|
187
|
+
|
|
188
|
+
user_auto_end = init_params.get('auto_end', None)
|
|
189
|
+
init_params = dict(init_params)
|
|
190
|
+
init_params['auto_end'] = False
|
|
191
|
+
|
|
192
|
+
if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
|
|
193
|
+
logging.getLogger('Lucidic').warning('session_async(...) ignores auto_end and will end the session at context exit')
|
|
194
|
+
|
|
195
|
+
session_id = lai.init(**init_params)
|
|
196
|
+
token = current_session_id.set(session_id)
|
|
197
|
+
|
|
198
|
+
# Set task-local session for true isolation in async
|
|
199
|
+
set_task_session(session_id)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
yield
|
|
203
|
+
finally:
|
|
204
|
+
# Clear task-local session first
|
|
205
|
+
clear_task_session()
|
|
206
|
+
current_session_id.reset(token)
|
|
207
|
+
try:
|
|
208
|
+
# Force flush OpenTelemetry spans before ending session
|
|
209
|
+
from .init import get_tracer_provider
|
|
210
|
+
from ..utils.logger import debug, info
|
|
211
|
+
import asyncio
|
|
212
|
+
|
|
213
|
+
tracer_provider = get_tracer_provider()
|
|
214
|
+
if tracer_provider:
|
|
215
|
+
debug(f"[Session] Force flushing OpenTelemetry spans for async session {session_id}")
|
|
216
|
+
try:
|
|
217
|
+
# Force flush with 5 second timeout to ensure all spans are exported
|
|
218
|
+
flush_result = tracer_provider.force_flush(timeout_millis=5000)
|
|
219
|
+
debug(f"[Session] Tracer provider force_flush returned: {flush_result}")
|
|
220
|
+
|
|
221
|
+
# Give a small additional delay to ensure the exporter processes everything
|
|
222
|
+
# This is necessary because force_flush on the provider flushes the processors,
|
|
223
|
+
# but the exporter might still be processing the spans
|
|
224
|
+
await asyncio.sleep(0.5)
|
|
225
|
+
debug(f"[Session] Successfully flushed spans for async session {session_id}")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
debug(f"[Session] Error flushing spans: {e}")
|
|
228
|
+
|
|
229
|
+
# Pass session_id explicitly to avoid context issues in async
|
|
230
|
+
lai.end_session(session_id=session_id)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_session(fn: Callable[..., Any], *fn_args: Any, init_params: Optional[Dict[str, Any]] = None, **fn_kwargs: Any) -> Any:
|
|
236
|
+
"""Run a callable within a full Lucidic session lifecycle context."""
|
|
237
|
+
with session(**(init_params or {})):
|
|
238
|
+
return fn(*fn_args, **fn_kwargs)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_in_session(session_id: str, fn: Callable[..., Any], *fn_args: Any, **fn_kwargs: Any) -> Any:
|
|
242
|
+
"""Run a callable with a bound session id. Does not end the session."""
|
|
243
|
+
with bind_session(session_id):
|
|
244
|
+
return fn(*fn_args, **fn_kwargs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def thread_worker_with_session(session_id: str, target: Callable[..., Any], *args, **kwargs) -> Any:
|
|
248
|
+
"""Wrapper for thread worker functions that ensures proper session isolation.
|
|
249
|
+
|
|
250
|
+
Use this as the target function for threads to ensure each thread gets
|
|
251
|
+
its own session context without bleeding from the parent thread.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
thread = Thread(
|
|
255
|
+
target=thread_worker_with_session,
|
|
256
|
+
args=(session_id, actual_worker_function, arg1, arg2),
|
|
257
|
+
kwargs={'key': 'value'}
|
|
258
|
+
)
|
|
259
|
+
"""
|
|
260
|
+
from .init import set_thread_session, clear_thread_session
|
|
261
|
+
|
|
262
|
+
# Set thread-local session immediately
|
|
263
|
+
set_thread_session(session_id)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
# Also bind to contextvar for compatibility
|
|
267
|
+
with bind_session(session_id):
|
|
268
|
+
return target(*args, **kwargs)
|
|
269
|
+
finally:
|
|
270
|
+
# Clean up thread-local storage
|
|
271
|
+
clear_thread_session()
|
|
272
|
+
|
|
273
|
+
|
|
@@ -12,23 +12,27 @@ from ..utils.logger import debug, truncate_id
|
|
|
12
12
|
def create_event(
|
|
13
13
|
type: str = "generic",
|
|
14
14
|
event_id: Optional[str] = None,
|
|
15
|
+
session_id: Optional[str] = None, # accept explicit session_id
|
|
15
16
|
**kwargs
|
|
16
17
|
) -> str:
|
|
17
18
|
"""Create a new event.
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
Args:
|
|
20
21
|
type: Event type (llm_generation, function_call, error_traceback, generic)
|
|
21
22
|
event_id: Optional client event ID (will generate if not provided)
|
|
23
|
+
session_id: Optional session ID (will use context if not provided)
|
|
22
24
|
**kwargs: Event-specific fields
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
Returns:
|
|
25
27
|
Event ID (client-generated or provided UUID)
|
|
26
28
|
"""
|
|
27
29
|
# Import here to avoid circular dependency
|
|
28
30
|
from ..sdk.init import get_session_id, get_event_queue
|
|
29
|
-
|
|
30
|
-
#
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
# Use provided session_id or fall back to context
|
|
33
|
+
if not session_id:
|
|
34
|
+
session_id = get_session_id()
|
|
35
|
+
|
|
32
36
|
if not session_id:
|
|
33
37
|
# No active session, return dummy ID
|
|
34
38
|
debug("[Event] No active session, returning dummy event ID")
|
|
@@ -4,6 +4,9 @@ This module handles SDK initialization, separating concerns from the main __init
|
|
|
4
4
|
"""
|
|
5
5
|
import uuid
|
|
6
6
|
from typing import List, Optional
|
|
7
|
+
import asyncio
|
|
8
|
+
import threading
|
|
9
|
+
from weakref import WeakKeyDictionary
|
|
7
10
|
|
|
8
11
|
from ..api.client import HttpClient
|
|
9
12
|
from ..api.resources.event import EventResource
|
|
@@ -21,14 +24,18 @@ from opentelemetry.sdk.trace import TracerProvider
|
|
|
21
24
|
|
|
22
25
|
class SDKState:
|
|
23
26
|
"""Container for SDK runtime state."""
|
|
24
|
-
|
|
27
|
+
|
|
25
28
|
def __init__(self):
|
|
26
29
|
self.http: Optional[HttpClient] = None
|
|
27
30
|
self.event_queue: Optional[EventQueue] = None
|
|
28
31
|
self.session_id: Optional[str] = None
|
|
29
32
|
self.tracer_provider: Optional[TracerProvider] = None
|
|
30
33
|
self.resources = {}
|
|
31
|
-
|
|
34
|
+
# Task-local storage for async task isolation
|
|
35
|
+
self.task_sessions: WeakKeyDictionary = WeakKeyDictionary()
|
|
36
|
+
# Thread-local storage for thread isolation
|
|
37
|
+
self.thread_local = threading.local()
|
|
38
|
+
|
|
32
39
|
def reset(self):
|
|
33
40
|
"""Reset SDK state."""
|
|
34
41
|
# Shutdown telemetry first to ensure all spans are exported
|
|
@@ -42,17 +49,21 @@ class SDKState:
|
|
|
42
49
|
debug("[SDK] TracerProvider shutdown complete")
|
|
43
50
|
except Exception as e:
|
|
44
51
|
error(f"[SDK] Error shutting down TracerProvider: {e}")
|
|
45
|
-
|
|
52
|
+
|
|
46
53
|
if self.event_queue:
|
|
47
54
|
self.event_queue.shutdown()
|
|
48
55
|
if self.http:
|
|
49
56
|
self.http.close()
|
|
50
|
-
|
|
57
|
+
|
|
51
58
|
self.http = None
|
|
52
59
|
self.event_queue = None
|
|
53
60
|
self.session_id = None
|
|
54
61
|
self.tracer_provider = None
|
|
55
62
|
self.resources = {}
|
|
63
|
+
self.task_sessions.clear()
|
|
64
|
+
# Clear thread-local storage for current thread
|
|
65
|
+
if hasattr(self.thread_local, 'session_id'):
|
|
66
|
+
delattr(self.thread_local, 'session_id')
|
|
56
67
|
|
|
57
68
|
|
|
58
69
|
# Global SDK state
|
|
@@ -243,8 +254,87 @@ def _initialize_telemetry(providers: List[str]) -> None:
|
|
|
243
254
|
info(f"[Telemetry] Initialized for providers: {providers}")
|
|
244
255
|
|
|
245
256
|
|
|
257
|
+
def set_task_session(session_id: str) -> None:
|
|
258
|
+
"""Set session ID for current async task (if in async context)."""
|
|
259
|
+
try:
|
|
260
|
+
if task := asyncio.current_task():
|
|
261
|
+
_sdk_state.task_sessions[task] = session_id
|
|
262
|
+
debug(f"[SDK] Set task-local session {truncate_id(session_id)} for task {task.get_name()}")
|
|
263
|
+
except RuntimeError:
|
|
264
|
+
# Not in async context, ignore
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def clear_task_session() -> None:
|
|
269
|
+
"""Clear session ID for current async task (if in async context)."""
|
|
270
|
+
try:
|
|
271
|
+
if task := asyncio.current_task():
|
|
272
|
+
_sdk_state.task_sessions.pop(task, None)
|
|
273
|
+
debug(f"[SDK] Cleared task-local session for task {task.get_name()}")
|
|
274
|
+
except RuntimeError:
|
|
275
|
+
# Not in async context, ignore
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def set_thread_session(session_id: str) -> None:
|
|
280
|
+
"""Set session ID for current thread.
|
|
281
|
+
|
|
282
|
+
This provides true thread-local storage that doesn't inherit from parent thread.
|
|
283
|
+
"""
|
|
284
|
+
_sdk_state.thread_local.session_id = session_id
|
|
285
|
+
current_thread = threading.current_thread()
|
|
286
|
+
debug(f"[SDK] Set thread-local session {truncate_id(session_id)} for thread {current_thread.name}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def clear_thread_session() -> None:
|
|
290
|
+
"""Clear session ID for current thread."""
|
|
291
|
+
if hasattr(_sdk_state.thread_local, 'session_id'):
|
|
292
|
+
delattr(_sdk_state.thread_local, 'session_id')
|
|
293
|
+
current_thread = threading.current_thread()
|
|
294
|
+
debug(f"[SDK] Cleared thread-local session for thread {current_thread.name}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def get_thread_session() -> Optional[str]:
|
|
298
|
+
"""Get session ID from thread-local storage."""
|
|
299
|
+
return getattr(_sdk_state.thread_local, 'session_id', None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def is_main_thread() -> bool:
|
|
303
|
+
"""Check if we're running in the main thread."""
|
|
304
|
+
return threading.current_thread() is threading.main_thread()
|
|
305
|
+
|
|
306
|
+
|
|
246
307
|
def get_session_id() -> Optional[str]:
|
|
247
|
-
"""Get the current session ID.
|
|
308
|
+
"""Get the current session ID.
|
|
309
|
+
|
|
310
|
+
Priority:
|
|
311
|
+
1. Task-local session (for async tasks)
|
|
312
|
+
2. Thread-local session (for threads) - NO FALLBACK for threads
|
|
313
|
+
3. SDK state session (for main thread)
|
|
314
|
+
4. Context variable session (fallback for main thread only)
|
|
315
|
+
"""
|
|
316
|
+
# First check task-local storage for async isolation
|
|
317
|
+
try:
|
|
318
|
+
if task := asyncio.current_task():
|
|
319
|
+
if task_session := _sdk_state.task_sessions.get(task):
|
|
320
|
+
debug(f"[SDK] Using task-local session {truncate_id(task_session)}")
|
|
321
|
+
return task_session
|
|
322
|
+
except RuntimeError:
|
|
323
|
+
# Not in async context
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
# Check if we're in a thread
|
|
327
|
+
if not is_main_thread():
|
|
328
|
+
# For threads, ONLY use thread-local storage - no fallback!
|
|
329
|
+
# This prevents inheriting the parent thread's session
|
|
330
|
+
thread_session = get_thread_session()
|
|
331
|
+
if thread_session:
|
|
332
|
+
debug(f"[SDK] Using thread-local session {truncate_id(thread_session)}")
|
|
333
|
+
else:
|
|
334
|
+
debug(f"[SDK] Thread {threading.current_thread().name} has no thread-local session")
|
|
335
|
+
return thread_session # Return None if not set - don't fall back!
|
|
336
|
+
|
|
337
|
+
# For main thread only: fall back to SDK state or context variable
|
|
248
338
|
return _sdk_state.session_id or current_session_id.get()
|
|
249
339
|
|
|
250
340
|
|
|
@@ -263,6 +353,11 @@ def get_resources() -> dict:
|
|
|
263
353
|
return _sdk_state.resources
|
|
264
354
|
|
|
265
355
|
|
|
356
|
+
def get_tracer_provider() -> Optional[TracerProvider]:
|
|
357
|
+
"""Get the tracer provider instance."""
|
|
358
|
+
return _sdk_state.tracer_provider
|
|
359
|
+
|
|
360
|
+
|
|
266
361
|
def clear_state() -> None:
|
|
267
362
|
"""Clear SDK state (for testing)."""
|
|
268
363
|
global _sdk_state
|
|
@@ -149,6 +149,7 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
149
149
|
# Create event with correct field names
|
|
150
150
|
create_event(
|
|
151
151
|
type="llm_generation",
|
|
152
|
+
session_id=session_id, # Pass session_id explicitly
|
|
152
153
|
provider=provider,
|
|
153
154
|
model=model,
|
|
154
155
|
messages=messages,
|
|
@@ -210,6 +211,7 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
210
211
|
|
|
211
212
|
create_event(
|
|
212
213
|
type="error_traceback",
|
|
214
|
+
session_id=session_id, # Pass session_id explicitly
|
|
213
215
|
error=error_msg,
|
|
214
216
|
traceback="",
|
|
215
217
|
parent_event_id=parent_id, # This will be normalized by EventBuilder
|