lucidicai 1.3.5__py3-none-any.whl → 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lucidicai/__init__.py +475 -398
- lucidicai/client.py +328 -50
- lucidicai/constants.py +7 -37
- lucidicai/context.py +25 -0
- lucidicai/dataset.py +114 -0
- lucidicai/decorators.py +96 -325
- lucidicai/errors.py +39 -0
- lucidicai/event.py +50 -59
- lucidicai/event_queue.py +466 -0
- lucidicai/feature_flag.py +344 -0
- lucidicai/session.py +9 -71
- lucidicai/singleton.py +20 -17
- lucidicai/streaming.py +15 -50
- lucidicai/telemetry/context_capture_processor.py +65 -0
- lucidicai/telemetry/extract.py +192 -0
- lucidicai/telemetry/litellm_bridge.py +80 -45
- lucidicai/telemetry/lucidic_exporter.py +125 -142
- lucidicai/telemetry/telemetry_init.py +189 -0
- {lucidicai-1.3.5.dist-info → lucidicai-2.0.2.dist-info}/METADATA +1 -1
- {lucidicai-1.3.5.dist-info → lucidicai-2.0.2.dist-info}/RECORD +22 -16
- {lucidicai-1.3.5.dist-info → lucidicai-2.0.2.dist-info}/WHEEL +0 -0
- {lucidicai-1.3.5.dist-info → lucidicai-2.0.2.dist-info}/top_level.txt +0 -0
lucidicai/client.py
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import time
|
|
3
|
+
import threading
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
|
-
from typing import Optional, Tuple
|
|
5
|
+
from typing import Optional, Tuple, Dict, Any
|
|
5
6
|
|
|
6
7
|
import requests
|
|
7
8
|
import logging
|
|
9
|
+
import json
|
|
8
10
|
from requests.adapters import HTTPAdapter, Retry
|
|
9
11
|
from urllib3.util import Retry
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError
|
|
13
|
-
from .telemetry.base_provider import BaseProvider
|
|
14
15
|
from .session import Session
|
|
15
16
|
from .singleton import singleton, clear_singletons
|
|
16
17
|
from .lru import LRUCache
|
|
18
|
+
from .event import Event
|
|
19
|
+
from .event_queue import EventQueue
|
|
20
|
+
import uuid
|
|
17
21
|
|
|
18
22
|
NETWORK_RETRIES = 3
|
|
19
23
|
|
|
24
|
+
logger = logging.getLogger("Lucidic")
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
@singleton
|
|
22
28
|
class Client:
|
|
@@ -25,16 +31,16 @@ class Client:
|
|
|
25
31
|
api_key: str,
|
|
26
32
|
agent_id: str,
|
|
27
33
|
):
|
|
28
|
-
self.base_url = "https://
|
|
34
|
+
self.base_url = "https://backend.lucidic.ai/api" if not (os.getenv("LUCIDIC_DEBUG", 'False') == 'True') else "http://localhost:8000/api"
|
|
29
35
|
self.initialized = False
|
|
30
36
|
self.session = None
|
|
31
37
|
self.previous_sessions = LRUCache(500) # For LRU cache of previously initialized sessions
|
|
32
38
|
self.custom_session_id_translations = LRUCache(500) # For translations of custom session IDs to real session IDs
|
|
33
|
-
self.providers = []
|
|
34
39
|
self.api_key = api_key
|
|
35
40
|
self.agent_id = agent_id
|
|
36
41
|
self.masking_function = None
|
|
37
42
|
self.auto_end = False # Default to False until explicitly set during init
|
|
43
|
+
self._shutdown = False # Flag to prevent requests after shutdown
|
|
38
44
|
self.request_session = requests.Session()
|
|
39
45
|
retry_cfg = Retry(
|
|
40
46
|
total=3, # 3 attempts in total
|
|
@@ -46,6 +52,19 @@ class Client:
|
|
|
46
52
|
self.request_session.mount("https://", adapter)
|
|
47
53
|
self.set_api_key(api_key)
|
|
48
54
|
self.prompts = dict()
|
|
55
|
+
# Initialize event queue (non-blocking event delivery)
|
|
56
|
+
self._event_queue = EventQueue(self)
|
|
57
|
+
|
|
58
|
+
# Track telemetry state to prevent re-initialization
|
|
59
|
+
# These are process-wide singletons for telemetry
|
|
60
|
+
self._telemetry_lock = threading.Lock() # Prevent race conditions
|
|
61
|
+
self._tracer_provider = None
|
|
62
|
+
self._instrumentors = {} # Dict to track which providers are instrumented
|
|
63
|
+
self._telemetry_initialized = False
|
|
64
|
+
|
|
65
|
+
# Track active sessions to prevent premature EventQueue shutdown
|
|
66
|
+
self._active_sessions_lock = threading.Lock()
|
|
67
|
+
self._active_sessions = set() # Set of active session IDs
|
|
49
68
|
|
|
50
69
|
def set_api_key(self, api_key: str):
|
|
51
70
|
self.api_key = api_key
|
|
@@ -56,40 +75,30 @@ class Client:
|
|
|
56
75
|
raise APIKeyVerificationError("Invalid API Key")
|
|
57
76
|
|
|
58
77
|
def clear(self):
|
|
59
|
-
|
|
78
|
+
# Clean up singleton state
|
|
60
79
|
clear_singletons()
|
|
61
80
|
self.initialized = False
|
|
62
81
|
self.session = None
|
|
63
|
-
self.providers = []
|
|
64
82
|
del self
|
|
65
83
|
|
|
66
84
|
def verify_api_key(self, base_url: str, api_key: str) -> Tuple[str, str]:
|
|
67
85
|
data = self.make_request('verifyapikey', 'GET', {}) # TODO: Verify against agent ID provided
|
|
68
86
|
return data["project"], data["project_id"]
|
|
69
87
|
|
|
70
|
-
def set_provider(self, provider
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
for existing in self.providers:
|
|
74
|
-
if type(existing) is type(provider):
|
|
75
|
-
return
|
|
76
|
-
self.providers.append(provider)
|
|
77
|
-
provider.override()
|
|
78
|
-
|
|
79
|
-
def undo_overrides(self):
|
|
80
|
-
for provider in self.providers:
|
|
81
|
-
provider.undo_override()
|
|
88
|
+
def set_provider(self, provider) -> None:
|
|
89
|
+
"""Deprecated: manual provider overrides removed (no-op)."""
|
|
90
|
+
return
|
|
82
91
|
|
|
83
92
|
def init_session(
|
|
84
93
|
self,
|
|
85
94
|
session_name: str,
|
|
86
|
-
mass_sim_id: Optional[str] = None,
|
|
87
95
|
task: Optional[str] = None,
|
|
88
96
|
rubrics: Optional[list] = None,
|
|
89
97
|
tags: Optional[list] = None,
|
|
90
98
|
production_monitoring: Optional[bool] = False,
|
|
91
99
|
session_id: Optional[str] = None,
|
|
92
100
|
experiment_id: Optional[str] = None,
|
|
101
|
+
dataset_item_id: Optional[str] = None,
|
|
93
102
|
) -> None:
|
|
94
103
|
if session_id:
|
|
95
104
|
# Check if it's a known session ID, maybe custom and maybe real
|
|
@@ -111,11 +120,12 @@ class Client:
|
|
|
111
120
|
"agent_id": self.agent_id,
|
|
112
121
|
"session_name": session_name,
|
|
113
122
|
"task": task,
|
|
114
|
-
"mass_sim_id": mass_sim_id,
|
|
115
123
|
"experiment_id": experiment_id,
|
|
116
124
|
"rubrics": rubrics,
|
|
117
125
|
"tags": tags,
|
|
118
|
-
"session_id": session_id
|
|
126
|
+
"session_id": session_id,
|
|
127
|
+
"dataset_item_id": dataset_item_id,
|
|
128
|
+
"production_monitoring": production_monitoring
|
|
119
129
|
}
|
|
120
130
|
data = self.make_request('initsession', 'POST', request_data)
|
|
121
131
|
real_session_id = data["session_id"]
|
|
@@ -129,47 +139,47 @@ class Client:
|
|
|
129
139
|
agent_id=self.agent_id,
|
|
130
140
|
session_id=real_session_id,
|
|
131
141
|
session_name=session_name,
|
|
132
|
-
mass_sim_id=mass_sim_id,
|
|
133
142
|
experiment_id=experiment_id,
|
|
134
143
|
task=task,
|
|
135
144
|
rubrics=rubrics,
|
|
136
145
|
tags=tags,
|
|
137
146
|
)
|
|
147
|
+
|
|
148
|
+
# Track this as an active session
|
|
149
|
+
with self._active_sessions_lock:
|
|
150
|
+
self._active_sessions.add(real_session_id)
|
|
151
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
152
|
+
logger.debug(f"[Client] Added active session {real_session_id[:8]}..., total: {len(self._active_sessions)}")
|
|
153
|
+
|
|
138
154
|
self.initialized = True
|
|
139
155
|
return self.session.session_id
|
|
140
156
|
|
|
157
|
+
def mark_session_inactive(self, session_id: str) -> None:
|
|
158
|
+
"""Mark a session as inactive. Used when ending a session."""
|
|
159
|
+
with self._active_sessions_lock:
|
|
160
|
+
if session_id in self._active_sessions:
|
|
161
|
+
self._active_sessions.discard(session_id)
|
|
162
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
163
|
+
logger.debug(f"[Client] Removed active session {session_id[:8]}..., remaining: {len(self._active_sessions)}")
|
|
164
|
+
|
|
165
|
+
def has_active_sessions(self) -> bool:
|
|
166
|
+
"""Check if there are any active sessions."""
|
|
167
|
+
with self._active_sessions_lock:
|
|
168
|
+
return len(self._active_sessions) > 0
|
|
169
|
+
|
|
141
170
|
def create_event_for_session(self, session_id: str, **kwargs) -> str:
|
|
142
|
-
"""Create an event for a specific session id
|
|
171
|
+
"""Create an event for a specific session id (new typed model).
|
|
143
172
|
|
|
144
|
-
This avoids
|
|
145
|
-
|
|
146
|
-
requests under the provided session id.
|
|
173
|
+
This avoids mutating the global session and directly uses the new
|
|
174
|
+
event API. Prefer passing typed fields and a 'type' argument.
|
|
147
175
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def continue_session(self, session_id: str):
|
|
152
|
-
if session_id in self.custom_session_id_translations:
|
|
153
|
-
session_id = self.custom_session_id_translations[session_id]
|
|
154
|
-
if self.session and self.session.session_id == session_id:
|
|
155
|
-
return self.session.session_id
|
|
156
|
-
if self.session:
|
|
157
|
-
self.previous_sessions[self.session.session_id] = self.session
|
|
158
|
-
data = self.make_request('continuesession', 'POST', {"session_id": session_id})
|
|
159
|
-
real_session_id = data["session_id"]
|
|
160
|
-
if session_id != real_session_id:
|
|
161
|
-
self.custom_session_id_translations[session_id] = real_session_id
|
|
162
|
-
self.session = Session(
|
|
163
|
-
agent_id=self.agent_id,
|
|
164
|
-
session_id=real_session_id
|
|
165
|
-
)
|
|
166
|
-
import logging as _logging
|
|
167
|
-
_logging.getLogger('Lucidic').info(f"Session {data.get('session_name', '')} continuing...")
|
|
168
|
-
return self.session.session_id
|
|
176
|
+
kwargs = dict(kwargs)
|
|
177
|
+
kwargs['session_id'] = session_id
|
|
178
|
+
return self.create_event(**kwargs)
|
|
169
179
|
|
|
170
|
-
def
|
|
180
|
+
def create_experiment(self, **kwargs) -> str:
|
|
171
181
|
kwargs['agent_id'] = self.agent_id
|
|
172
|
-
return self.make_request('
|
|
182
|
+
return self.make_request('createexperiment', 'POST', kwargs)['experiment_id']
|
|
173
183
|
|
|
174
184
|
def get_prompt(self, prompt_name, cache_ttl, label) -> str:
|
|
175
185
|
current_time = time.time()
|
|
@@ -194,6 +204,13 @@ class Client:
|
|
|
194
204
|
return prompt
|
|
195
205
|
|
|
196
206
|
def make_request(self, endpoint, method, data):
|
|
207
|
+
# Check if client is shutting down
|
|
208
|
+
if self._shutdown:
|
|
209
|
+
logger.warning(f"[HTTP] Attempted request after shutdown: {endpoint}")
|
|
210
|
+
return {}
|
|
211
|
+
|
|
212
|
+
data = {k: v for k, v in data.items() if v is not None}
|
|
213
|
+
|
|
197
214
|
http_methods = {
|
|
198
215
|
"GET": lambda data: self.request_session.get(f"{self.base_url}/{endpoint}", params=data),
|
|
199
216
|
"POST": lambda data: self.request_session.post(f"{self.base_url}/{endpoint}", json=data),
|
|
@@ -201,7 +218,14 @@ class Client:
|
|
|
201
218
|
"DELETE": lambda data: self.request_session.delete(f"{self.base_url}/{endpoint}", params=data),
|
|
202
219
|
} # TODO: make into enum
|
|
203
220
|
data['current_time'] = datetime.now().astimezone(timezone.utc).isoformat()
|
|
221
|
+
# Debug: print final payload about to be sent
|
|
222
|
+
try:
|
|
223
|
+
dbg = json.dumps({"endpoint": endpoint, "method": method, "body": data}, ensure_ascii=False)
|
|
224
|
+
logger.debug(f"[HTTP] Sending request: {dbg}")
|
|
225
|
+
except Exception:
|
|
226
|
+
logger.debug(f"[HTTP] Sending request to {endpoint} {method}")
|
|
204
227
|
func = http_methods[method]
|
|
228
|
+
response = None
|
|
205
229
|
for _ in range(NETWORK_RETRIES):
|
|
206
230
|
try:
|
|
207
231
|
response = func(data)
|
|
@@ -222,6 +246,150 @@ class Client:
|
|
|
222
246
|
raise InvalidOperationError(f"Request to Lucidic AI Backend failed: {e.response.text}")
|
|
223
247
|
return response.json()
|
|
224
248
|
|
|
249
|
+
# ==== New Typed Event Model Helpers ====
|
|
250
|
+
def _build_payload(self, type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
251
|
+
"""Build type-specific payload and place unrecognized keys in misc."""
|
|
252
|
+
# Remove non-payload top-level fields from kwargs copy
|
|
253
|
+
non_payload_fields = [
|
|
254
|
+
'parent_event_id', 'tags', 'metadata', 'occurred_at', 'duration', 'session_id',
|
|
255
|
+
'event_id'
|
|
256
|
+
]
|
|
257
|
+
for field in non_payload_fields:
|
|
258
|
+
if field in kwargs:
|
|
259
|
+
kwargs.pop(field, None)
|
|
260
|
+
|
|
261
|
+
if type == "llm_generation":
|
|
262
|
+
return self._build_llm_payload(kwargs)
|
|
263
|
+
elif type == "function_call":
|
|
264
|
+
return self._build_function_payload(kwargs)
|
|
265
|
+
elif type == "error_traceback":
|
|
266
|
+
return self._build_error_payload(kwargs)
|
|
267
|
+
else:
|
|
268
|
+
return self._build_generic_payload(kwargs)
|
|
269
|
+
|
|
270
|
+
def _build_llm_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
271
|
+
payload: Dict[str, Any] = {
|
|
272
|
+
"request": {},
|
|
273
|
+
"response": {},
|
|
274
|
+
"usage": {},
|
|
275
|
+
"status": "ok",
|
|
276
|
+
"misc": {}
|
|
277
|
+
}
|
|
278
|
+
# Request fields
|
|
279
|
+
for field in ["provider", "model", "messages", "params"]:
|
|
280
|
+
if field in kwargs:
|
|
281
|
+
payload["request"][field] = kwargs.pop(field)
|
|
282
|
+
# Response fields
|
|
283
|
+
for field in ["output", "messages", "tool_calls", "thinking", "raw"]:
|
|
284
|
+
if field in kwargs:
|
|
285
|
+
payload["response"][field] = kwargs.pop(field)
|
|
286
|
+
# Usage fields
|
|
287
|
+
for field in ["input_tokens", "output_tokens", "cache", "cost"]:
|
|
288
|
+
if field in kwargs:
|
|
289
|
+
payload["usage"][field] = kwargs.pop(field)
|
|
290
|
+
# Status / error
|
|
291
|
+
if 'status' in kwargs:
|
|
292
|
+
payload['status'] = kwargs.pop('status')
|
|
293
|
+
if 'error' in kwargs:
|
|
294
|
+
payload['error'] = kwargs.pop('error')
|
|
295
|
+
payload["misc"] = kwargs
|
|
296
|
+
return payload
|
|
297
|
+
|
|
298
|
+
def _build_function_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
299
|
+
payload: Dict[str, Any] = {
|
|
300
|
+
"function_name": kwargs.pop("function_name", "unknown"),
|
|
301
|
+
"arguments": kwargs.pop("arguments", {}),
|
|
302
|
+
"return_value": kwargs.pop("return_value", None),
|
|
303
|
+
"misc": kwargs
|
|
304
|
+
}
|
|
305
|
+
return payload
|
|
306
|
+
|
|
307
|
+
def _build_error_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
308
|
+
payload: Dict[str, Any] = {
|
|
309
|
+
"error": kwargs.pop("error", ""),
|
|
310
|
+
"traceback": kwargs.pop("traceback", ""),
|
|
311
|
+
"misc": kwargs
|
|
312
|
+
}
|
|
313
|
+
return payload
|
|
314
|
+
|
|
315
|
+
def _build_generic_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
316
|
+
payload: Dict[str, Any] = {
|
|
317
|
+
"details": kwargs.pop("details", kwargs.pop("description", "")),
|
|
318
|
+
"misc": kwargs
|
|
319
|
+
}
|
|
320
|
+
return payload
|
|
321
|
+
|
|
322
|
+
def create_event(self, type: str = "generic", **kwargs) -> str:
|
|
323
|
+
"""Create a typed event (non-blocking) and return client-side UUID.
|
|
324
|
+
|
|
325
|
+
- Generates and returns client_event_id immediately
|
|
326
|
+
- Enqueues the full event for background processing via EventQueue
|
|
327
|
+
- Supports parent nesting via client-side parent_event_id
|
|
328
|
+
- Handles client-side blob thresholding in the queue
|
|
329
|
+
"""
|
|
330
|
+
# Resolve session_id: explicit -> context -> current session
|
|
331
|
+
session_id = kwargs.pop('session_id', None)
|
|
332
|
+
if not session_id:
|
|
333
|
+
try:
|
|
334
|
+
from .context import current_session_id
|
|
335
|
+
session_id = current_session_id.get(None)
|
|
336
|
+
except Exception:
|
|
337
|
+
session_id = None
|
|
338
|
+
if not session_id and self.session:
|
|
339
|
+
session_id = self.session.session_id
|
|
340
|
+
if not session_id:
|
|
341
|
+
raise InvalidOperationError("No active session for event creation")
|
|
342
|
+
|
|
343
|
+
# Parent event id from kwargs or parent context (client-side)
|
|
344
|
+
parent_event_id = kwargs.get('parent_event_id')
|
|
345
|
+
if not parent_event_id:
|
|
346
|
+
try:
|
|
347
|
+
from .context import current_parent_event_id
|
|
348
|
+
parent_event_id = current_parent_event_id.get(None)
|
|
349
|
+
except Exception:
|
|
350
|
+
parent_event_id = None
|
|
351
|
+
|
|
352
|
+
# Build payload (typed)
|
|
353
|
+
payload = self._build_payload(type, dict(kwargs))
|
|
354
|
+
|
|
355
|
+
# Occurred-at
|
|
356
|
+
from datetime import datetime as _dt
|
|
357
|
+
_occ = kwargs.get("occurred_at")
|
|
358
|
+
if isinstance(_occ, str):
|
|
359
|
+
occurred_at_str = _occ
|
|
360
|
+
elif isinstance(_occ, _dt):
|
|
361
|
+
if _occ.tzinfo is None:
|
|
362
|
+
local_tz = _dt.now().astimezone().tzinfo
|
|
363
|
+
occurred_at_str = _occ.replace(tzinfo=local_tz).isoformat()
|
|
364
|
+
else:
|
|
365
|
+
occurred_at_str = _occ.isoformat()
|
|
366
|
+
else:
|
|
367
|
+
occurred_at_str = _dt.now().astimezone().isoformat()
|
|
368
|
+
|
|
369
|
+
# Client-side UUIDs
|
|
370
|
+
client_event_id = kwargs.get('event_id') or str(uuid.uuid4())
|
|
371
|
+
|
|
372
|
+
# Build request body with client ids
|
|
373
|
+
event_request: Dict[str, Any] = {
|
|
374
|
+
"session_id": session_id,
|
|
375
|
+
"client_event_id": client_event_id,
|
|
376
|
+
"client_parent_event_id": parent_event_id,
|
|
377
|
+
"type": type,
|
|
378
|
+
"occurred_at": occurred_at_str,
|
|
379
|
+
"duration": kwargs.get("duration"),
|
|
380
|
+
"tags": kwargs.get("tags", []),
|
|
381
|
+
"metadata": kwargs.get("metadata", {}),
|
|
382
|
+
"payload": payload,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# Queue for background processing and return immediately
|
|
386
|
+
self._event_queue.queue_event(event_request)
|
|
387
|
+
return client_event_id
|
|
388
|
+
|
|
389
|
+
def update_event(self, event_id: str, type: Optional[str] = None, **kwargs) -> str:
|
|
390
|
+
"""Deprecated: events are immutable in the new model."""
|
|
391
|
+
raise InvalidOperationError("update_event is no longer supported. Events are immutable.")
|
|
392
|
+
|
|
225
393
|
def mask(self, data):
|
|
226
394
|
if not self.masking_function:
|
|
227
395
|
return data
|
|
@@ -232,4 +400,114 @@ class Client:
|
|
|
232
400
|
except Exception as e:
|
|
233
401
|
logger = logging.getLogger('Lucidic')
|
|
234
402
|
logger.error(f"Error in custom masking function: {repr(e)}")
|
|
235
|
-
return "<Error in custom masking function, this is a fully-masked placeholder>"
|
|
403
|
+
return "<Error in custom masking function, this is a fully-masked placeholder>"
|
|
404
|
+
|
|
405
|
+
def initialize_telemetry(self, providers: list) -> bool:
|
|
406
|
+
"""
|
|
407
|
+
Initialize telemetry with the given providers.
|
|
408
|
+
This is a true singleton - only the first call creates the TracerProvider.
|
|
409
|
+
Subsequent calls only add new instrumentors if needed.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
providers: List of provider names to instrument
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
True if telemetry was successfully initialized or already initialized
|
|
416
|
+
"""
|
|
417
|
+
with self._telemetry_lock:
|
|
418
|
+
try:
|
|
419
|
+
# Create TracerProvider only once per process
|
|
420
|
+
if self._tracer_provider is None:
|
|
421
|
+
logger.debug("[Telemetry] Creating TracerProvider (first initialization)")
|
|
422
|
+
|
|
423
|
+
from opentelemetry import trace
|
|
424
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
425
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
426
|
+
from opentelemetry.sdk.resources import Resource
|
|
427
|
+
|
|
428
|
+
resource = Resource.create({
|
|
429
|
+
"service.name": "lucidic-ai",
|
|
430
|
+
"service.version": "1.0.0",
|
|
431
|
+
"lucidic.agent_id": self.agent_id,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
# Create provider with shutdown_on_exit=False for our control
|
|
435
|
+
self._tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
|
|
436
|
+
|
|
437
|
+
# Add context capture processor FIRST
|
|
438
|
+
from .telemetry.context_capture_processor import ContextCaptureProcessor
|
|
439
|
+
context_processor = ContextCaptureProcessor()
|
|
440
|
+
self._tracer_provider.add_span_processor(context_processor)
|
|
441
|
+
|
|
442
|
+
# Add exporter processor for sending spans to Lucidic
|
|
443
|
+
from .telemetry.lucidic_exporter import LucidicSpanExporter
|
|
444
|
+
exporter = LucidicSpanExporter()
|
|
445
|
+
# Configure for faster export: 100ms interval instead of default 5000ms
|
|
446
|
+
# This matches the TypeScript SDK's flush interval pattern
|
|
447
|
+
export_processor = BatchSpanProcessor(
|
|
448
|
+
exporter,
|
|
449
|
+
schedule_delay_millis=100, # Export every 100ms
|
|
450
|
+
max_export_batch_size=512, # Reasonable batch size
|
|
451
|
+
max_queue_size=2048 # Larger queue for burst handling
|
|
452
|
+
)
|
|
453
|
+
self._tracer_provider.add_span_processor(export_processor)
|
|
454
|
+
|
|
455
|
+
# Set as global provider (only happens once)
|
|
456
|
+
try:
|
|
457
|
+
trace.set_tracer_provider(self._tracer_provider)
|
|
458
|
+
logger.debug("[Telemetry] Set global TracerProvider")
|
|
459
|
+
except Exception as e:
|
|
460
|
+
# This is OK - might already be set
|
|
461
|
+
logger.debug(f"[Telemetry] Global provider already set: {e}")
|
|
462
|
+
|
|
463
|
+
self._telemetry_initialized = True
|
|
464
|
+
|
|
465
|
+
# Now instrument the requested providers (can happen multiple times)
|
|
466
|
+
if providers:
|
|
467
|
+
from .telemetry.telemetry_init import instrument_providers
|
|
468
|
+
new_instrumentors = instrument_providers(providers, self._tracer_provider, self._instrumentors)
|
|
469
|
+
# Update our tracking dict
|
|
470
|
+
self._instrumentors.update(new_instrumentors)
|
|
471
|
+
logger.debug(f"[Telemetry] Instrumented providers: {list(new_instrumentors.keys())}")
|
|
472
|
+
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.error(f"[Telemetry] Failed to initialize: {e}")
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
def flush_telemetry(self, timeout_seconds: float = 2.0) -> bool:
|
|
480
|
+
"""
|
|
481
|
+
Flush all OpenTelemetry spans to ensure they're exported.
|
|
482
|
+
|
|
483
|
+
This method blocks until all buffered spans in the TracerProvider
|
|
484
|
+
are exported or the timeout is reached. Critical for ensuring
|
|
485
|
+
LLM generation events are not lost during shutdown.
|
|
486
|
+
|
|
487
|
+
Handles both active and shutdown TracerProviders gracefully.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
timeout_seconds: Maximum time to wait for flush completion
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
True if flush succeeded, False if timeout occurred
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
if self._tracer_provider:
|
|
497
|
+
# Check if provider is already shutdown
|
|
498
|
+
if hasattr(self._tracer_provider, '_shutdown') and self._tracer_provider._shutdown:
|
|
499
|
+
logger.debug("[Telemetry] TracerProvider already shutdown, skipping flush")
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
# Convert seconds to milliseconds for OpenTelemetry
|
|
503
|
+
timeout_millis = int(timeout_seconds * 1000)
|
|
504
|
+
success = self._tracer_provider.force_flush(timeout_millis)
|
|
505
|
+
if success:
|
|
506
|
+
logger.debug(f"[Telemetry] Successfully flushed spans (timeout={timeout_seconds}s)")
|
|
507
|
+
else:
|
|
508
|
+
logger.warning(f"[Telemetry] Flush timed out after {timeout_seconds}s")
|
|
509
|
+
return success
|
|
510
|
+
return True # No provider = nothing to flush = success
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.error(f"[Telemetry] Failed to flush spans: {e}")
|
|
513
|
+
return False
|
lucidicai/constants.py
CHANGED
|
@@ -1,33 +1,6 @@
|
|
|
1
|
-
"""Constants used throughout the Lucidic SDK"""
|
|
1
|
+
"""Constants used throughout the Lucidic SDK (steps removed)."""
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
class StepState:
|
|
5
|
-
"""Constants for step states"""
|
|
6
|
-
RUNNING = "Running: {agent_name}"
|
|
7
|
-
FINISHED = "Finished: {agent_name}"
|
|
8
|
-
HANDOFF = "Handoff: {agent_name}"
|
|
9
|
-
TRANSFERRED = "Transferred to {agent_name}"
|
|
10
|
-
ERROR = "Error in {agent_name}"
|
|
11
|
-
|
|
12
|
-
# Step actions
|
|
13
|
-
class StepAction:
|
|
14
|
-
"""Constants for step actions"""
|
|
15
|
-
EXECUTE = "Execute {agent_name}"
|
|
16
|
-
TRANSFER = "Transfer from {from_agent}"
|
|
17
|
-
HANDOFF = "Handoff from {from_agent}"
|
|
18
|
-
DELIVERED = "{agent_name} finished processing"
|
|
19
|
-
FAILED = "Agent execution failed"
|
|
20
|
-
|
|
21
|
-
# Step goals
|
|
22
|
-
class StepGoal:
|
|
23
|
-
"""Constants for step goals"""
|
|
24
|
-
PROCESS_REQUEST = "Process request"
|
|
25
|
-
CONTINUE_PROCESSING = "Continue processing"
|
|
26
|
-
CONTINUE_WITH = "Continue with {agent_name}"
|
|
27
|
-
PROCESSING_FINISHED = "Processing finished"
|
|
28
|
-
ERROR = "Error: {error}"
|
|
29
|
-
|
|
30
|
-
# Event descriptions
|
|
3
|
+
# Event descriptions (generic)
|
|
31
4
|
class EventDescription:
|
|
32
5
|
"""Constants for event descriptions"""
|
|
33
6
|
TOOL_CALL = "Tool call: {tool_name}"
|
|
@@ -48,12 +21,9 @@ class LogMessage:
|
|
|
48
21
|
"""Constants for log messages"""
|
|
49
22
|
SESSION_INIT = "Session initialized successfully"
|
|
50
23
|
SESSION_CONTINUE = "Session {session_id} continuing..."
|
|
51
|
-
INSTRUMENTATION_ENABLED = "
|
|
52
|
-
INSTRUMENTATION_DISABLED = "
|
|
53
|
-
NO_ACTIVE_SESSION = "No active session for
|
|
24
|
+
INSTRUMENTATION_ENABLED = "Instrumentation enabled"
|
|
25
|
+
INSTRUMENTATION_DISABLED = "Instrumentation disabled"
|
|
26
|
+
NO_ACTIVE_SESSION = "No active session for tracking"
|
|
54
27
|
HANDLER_INTERCEPTED = "Intercepted {method} call"
|
|
55
|
-
AGENT_RUNNING = "Running agent '{agent_name}'
|
|
56
|
-
AGENT_COMPLETED = "Agent completed successfully"
|
|
57
|
-
STEP_CREATED = "Created step: {step_id}"
|
|
58
|
-
STEP_ENDED = "Step ended: {step_id}"
|
|
59
|
-
HANDOFF_DETECTED = "Handoff chain detected: {chain}"
|
|
28
|
+
AGENT_RUNNING = "Running agent '{agent_name}'"
|
|
29
|
+
AGENT_COMPLETED = "Agent completed successfully"
|
lucidicai/context.py
CHANGED
|
@@ -19,6 +19,12 @@ current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextV
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
# NEW: Context variable for parent event nesting
|
|
23
|
+
current_parent_event_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
24
|
+
"lucidic.parent_event_id", default=None
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
22
28
|
def set_active_session(session_id: Optional[str]) -> None:
|
|
23
29
|
"""Bind the given session id to the current execution context."""
|
|
24
30
|
current_session_id.set(session_id)
|
|
@@ -49,6 +55,25 @@ async def bind_session_async(session_id: str) -> AsyncIterator[None]:
|
|
|
49
55
|
current_session_id.reset(token)
|
|
50
56
|
|
|
51
57
|
|
|
58
|
+
# NEW: Parent event context managers
|
|
59
|
+
@contextmanager
|
|
60
|
+
def event_context(event_id: str) -> Iterator[None]:
|
|
61
|
+
token = current_parent_event_id.set(event_id)
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
current_parent_event_id.reset(token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@asynccontextmanager
|
|
69
|
+
async def event_context_async(event_id: str) -> AsyncIterator[None]:
|
|
70
|
+
token = current_parent_event_id.set(event_id)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
current_parent_event_id.reset(token)
|
|
75
|
+
|
|
76
|
+
|
|
52
77
|
@contextmanager
|
|
53
78
|
def session(**init_params) -> Iterator[None]:
|
|
54
79
|
"""All-in-one context manager: init → bind → yield → clear → end.
|