lucidicai 2.1.3__py3-none-any.whl → 3.0.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.
Files changed (38) hide show
  1. lucidicai/__init__.py +32 -390
  2. lucidicai/api/client.py +31 -2
  3. lucidicai/api/resources/__init__.py +16 -1
  4. lucidicai/api/resources/dataset.py +422 -82
  5. lucidicai/api/resources/event.py +399 -27
  6. lucidicai/api/resources/experiment.py +108 -0
  7. lucidicai/api/resources/feature_flag.py +78 -0
  8. lucidicai/api/resources/prompt.py +84 -0
  9. lucidicai/api/resources/session.py +545 -38
  10. lucidicai/client.py +395 -480
  11. lucidicai/core/config.py +73 -48
  12. lucidicai/core/errors.py +3 -3
  13. lucidicai/sdk/bound_decorators.py +321 -0
  14. lucidicai/sdk/context.py +20 -2
  15. lucidicai/sdk/decorators.py +283 -74
  16. lucidicai/sdk/event.py +538 -36
  17. lucidicai/sdk/event_builder.py +2 -4
  18. lucidicai/sdk/features/dataset.py +391 -1
  19. lucidicai/sdk/features/feature_flag.py +344 -3
  20. lucidicai/sdk/init.py +49 -347
  21. lucidicai/sdk/session.py +502 -0
  22. lucidicai/sdk/shutdown_manager.py +103 -46
  23. lucidicai/session_obj.py +321 -0
  24. lucidicai/telemetry/context_capture_processor.py +13 -6
  25. lucidicai/telemetry/extract.py +60 -63
  26. lucidicai/telemetry/litellm_bridge.py +3 -44
  27. lucidicai/telemetry/lucidic_exporter.py +143 -131
  28. lucidicai/telemetry/openai_agents_instrumentor.py +2 -2
  29. lucidicai/telemetry/openai_patch.py +7 -6
  30. lucidicai/telemetry/telemetry_manager.py +183 -0
  31. lucidicai/telemetry/utils/model_pricing.py +21 -30
  32. lucidicai/telemetry/utils/provider.py +77 -0
  33. lucidicai/utils/images.py +27 -11
  34. lucidicai/utils/serialization.py +27 -0
  35. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
  36. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/RECORD +38 -29
  37. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
  38. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/top_level.txt +0 -0
lucidicai/client.py CHANGED
@@ -1,513 +1,428 @@
1
+ """LucidicAI Client - Instance-based SDK for AI observability.
2
+
3
+ This module provides the main LucidicAI class, which is the entry point
4
+ for all SDK operations. Each client instance maintains its own state,
5
+ HTTP connections, and telemetry context.
6
+
7
+ Example:
8
+ from lucidicai import LucidicAI
9
+
10
+ client = LucidicAI(api_key="...", agent_id="...", providers=["openai"])
11
+
12
+ with client.create_session(session_name="My Session") as session:
13
+ @client.event
14
+ def my_function():
15
+ # LLM calls are automatically tracked
16
+ pass
17
+ my_function()
18
+ """
19
+
20
+ import logging
1
21
  import os
2
- import time
3
22
  import threading
4
- from datetime import datetime, timezone
5
- from typing import Optional, Tuple, Dict, Any
23
+ import uuid
24
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
25
+
26
+ from .api.client import HttpClient
27
+ from .api.resources.session import SessionResource
28
+ from .api.resources.event import EventResource
29
+ from .api.resources.dataset import DatasetResource
30
+ from .api.resources.experiment import ExperimentResource
31
+ from .api.resources.prompt import PromptResource
32
+ from .api.resources.feature_flag import FeatureFlagResource
33
+ from .core.config import SDKConfig
34
+ from .core.errors import LucidicError
35
+ from .session_obj import Session
36
+ from .sdk.shutdown_manager import ShutdownManager, SessionState
6
37
 
7
- import requests
8
- import logging
9
- import json
10
- from requests.adapters import HTTPAdapter, Retry
11
- from urllib3.util import Retry
38
+ logger = logging.getLogger("Lucidic")
12
39
 
40
+ F = TypeVar("F", bound=Callable[..., Any])
13
41
 
14
- from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError
15
- from .session import Session
16
- from .singleton import singleton, clear_singletons
17
- from .lru import LRUCache
18
- from .event import Event
19
- from .event_queue import EventQueue
20
- import uuid
21
42
 
22
- NETWORK_RETRIES = 3
43
+ def get_shutdown_manager() -> ShutdownManager:
44
+ """Get the singleton ShutdownManager instance."""
45
+ return ShutdownManager()
23
46
 
24
- logger = logging.getLogger("Lucidic")
25
47
 
48
+ class LucidicAI:
49
+ """Instance-based Lucidic AI client for observability.
50
+
51
+ Each LucidicAI instance maintains its own:
52
+ - HTTP connections and configuration
53
+ - API resources (sessions, events, datasets)
54
+ - Active sessions
55
+ - Telemetry registration
56
+
57
+ Multiple clients can coexist with different configurations.
58
+
59
+ Args:
60
+ api_key: Lucidic API key. Falls back to LUCIDIC_API_KEY env var.
61
+ agent_id: Agent identifier. Falls back to LUCIDIC_AGENT_ID env var.
62
+ providers: List of LLM providers to instrument (e.g., ["openai", "anthropic"]).
63
+ auto_end: Whether sessions auto-end on context exit or process shutdown.
64
+ production: If True, suppress SDK errors. If None, checks LUCIDIC_PRODUCTION env var.
65
+ region: Deployment region ("us", "india"). Falls back to LUCIDIC_REGION env var.
66
+ **kwargs: Additional configuration options passed to SDKConfig.
67
+
68
+ Raises:
69
+ ValueError: If required configuration is missing, invalid, or region is unrecognized.
70
+
71
+ Example:
72
+ # Basic usage
73
+ client = LucidicAI(api_key="...", agent_id="...")
74
+
75
+ # With providers for auto-instrumentation
76
+ client = LucidicAI(
77
+ api_key="...",
78
+ agent_id="...",
79
+ providers=["openai", "anthropic"]
80
+ )
81
+
82
+ # Production mode (suppress errors)
83
+ client = LucidicAI(
84
+ api_key="...",
85
+ agent_id="...",
86
+ production=True
87
+ )
88
+
89
+ # India region
90
+ client = LucidicAI(
91
+ api_key="...",
92
+ agent_id="...",
93
+ region="india"
94
+ )
95
+ """
26
96
 
27
- @singleton
28
- class Client:
29
97
  def __init__(
30
98
  self,
31
- api_key: str,
32
- agent_id: str,
99
+ api_key: Optional[str] = None,
100
+ agent_id: Optional[str] = None,
101
+ providers: Optional[List[str]] = None,
102
+ auto_end: bool = True,
103
+ production: Optional[bool] = None,
104
+ region: Optional[str] = None,
105
+ **kwargs,
33
106
  ):
34
- self.base_url = "https://backend.lucidic.ai/api" if not (os.getenv("LUCIDIC_DEBUG", 'False') == 'True') else "http://localhost:8000/api"
35
- self.initialized = False
36
- self.session = None
37
- self.previous_sessions = LRUCache(500) # For LRU cache of previously initialized sessions
38
- self.custom_session_id_translations = LRUCache(500) # For translations of custom session IDs to real session IDs
39
- self.api_key = api_key
40
- self.agent_id = agent_id
41
- self.masking_function = None
42
- self.auto_end = False # Default to False until explicitly set during init
43
- self._shutdown = False # Flag to prevent requests after shutdown
44
- self.request_session = requests.Session()
45
- retry_cfg = Retry(
46
- total=3, # 3 attempts in total
47
- backoff_factor=0.5, # exponential back-off: 0.5s, 1s, 2s …
48
- status_forcelist=[502, 503, 504],
49
- allowed_methods=["GET", "POST", "PUT", "DELETE"],
107
+ # Generate unique client ID for telemetry routing
108
+ self._client_id = str(uuid.uuid4())
109
+
110
+ # Resolve production mode: arg > env > default (False)
111
+ if production is None:
112
+ production = os.getenv("LUCIDIC_PRODUCTION", "").lower() in ("true", "1")
113
+ self._production = production
114
+
115
+ # Build configuration
116
+ self._config = SDKConfig.from_env(
117
+ api_key=api_key,
118
+ agent_id=agent_id,
119
+ auto_end=auto_end,
120
+ region=region,
121
+ **kwargs,
50
122
  )
51
- adapter = HTTPAdapter(max_retries=retry_cfg, pool_connections=20, pool_maxsize=100)
52
- self.request_session.mount("https://", adapter)
53
- self.set_api_key(api_key)
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
68
-
69
- def set_api_key(self, api_key: str):
70
- self.api_key = api_key
71
- self.request_session.headers.update({"Authorization": f"Api-Key {self.api_key}", "User-Agent": "lucidic-sdk/1.1"})
72
- try:
73
- self.verify_api_key(self.base_url, api_key)
74
- except APIKeyVerificationError:
75
- raise APIKeyVerificationError("Invalid API Key")
76
-
77
- def clear(self):
78
- # Clean up singleton state
79
- clear_singletons()
80
- self.initialized = False
81
- self.session = None
82
- del self
83
-
84
- def verify_api_key(self, base_url: str, api_key: str) -> Tuple[str, str]:
85
- data = self.make_request('verifyapikey', 'GET', {}) # TODO: Verify against agent ID provided
86
- return data["project"], data["project_id"]
87
-
88
- def set_provider(self, provider) -> None:
89
- """Deprecated: manual provider overrides removed (no-op)."""
90
- return
91
-
92
- def init_session(
93
- self,
94
- session_name: str,
95
- task: Optional[str] = None,
96
- rubrics: Optional[list] = None,
97
- tags: Optional[list] = None,
98
- production_monitoring: Optional[bool] = False,
99
- session_id: Optional[str] = None,
100
- experiment_id: Optional[str] = None,
101
- dataset_item_id: Optional[str] = None,
102
- ) -> None:
103
- if session_id:
104
- # Check if it's a known session ID, maybe custom and maybe real
105
- if session_id in self.custom_session_id_translations:
106
- session_id = self.custom_session_id_translations[session_id]
107
- # Check if it's the same as the current session
108
- if self.session and self.session.session_id == session_id:
109
- return self.session.session_id
110
- # Check if it's a previous session that we have saved
111
- if session_id in self.previous_sessions:
112
- if self.session:
113
- self.previous_sessions[self.session.session_id] = self.session
114
- self.session = self.previous_sessions.pop(session_id) # Remove from previous sessions because it's now the current session
115
- return self.session.session_id
116
-
117
- # Either there's no session ID, or we don't know about the old session
118
- # We need to go to the backend in both cases
119
- request_data = {
120
- "agent_id": self.agent_id,
121
- "session_name": session_name,
122
- "task": task,
123
- "experiment_id": experiment_id,
124
- "rubrics": rubrics,
125
- "tags": tags,
126
- "session_id": session_id,
127
- "dataset_item_id": dataset_item_id,
128
- "production_monitoring": production_monitoring
123
+
124
+ # Validate configuration
125
+ errors = self._config.validate()
126
+ if errors:
127
+ error_msg = f"Invalid configuration: {', '.join(errors)}"
128
+ if self._production:
129
+ logger.error(f"[LucidicAI] {error_msg}")
130
+ # In production mode, allow initialization but mark as invalid
131
+ self._valid = False
132
+ else:
133
+ raise ValueError(error_msg)
134
+ else:
135
+ self._valid = True
136
+
137
+ # Initialize HTTP client
138
+ self._http = HttpClient(self._config)
139
+
140
+ # Initialize API resources
141
+ self._resources: Dict[str, Any] = {
142
+ "sessions": SessionResource(self._http, self, self._config, self._production),
143
+ "events": EventResource(self._http, self._production),
144
+ "datasets": DatasetResource(self._http, self._config.agent_id, self._production),
145
+ "experiments": ExperimentResource(self._http, self._config.agent_id, self._production),
146
+ "prompts": PromptResource(self._http, self._production),
147
+ "feature_flags": FeatureFlagResource(self._http, self._config.agent_id, self._production),
129
148
  }
130
- data = self.make_request('initsession', 'POST', request_data)
131
- real_session_id = data["session_id"]
132
- if session_id and session_id != real_session_id:
133
- self.custom_session_id_translations[session_id] = real_session_id
134
-
135
- if self.session:
136
- self.previous_sessions[self.session.session_id] = self.session
137
-
138
- self.session = Session(
139
- agent_id=self.agent_id,
140
- session_id=real_session_id,
141
- session_name=session_name,
142
- experiment_id=experiment_id,
143
- task=task,
144
- rubrics=rubrics,
145
- tags=tags,
149
+
150
+ # Active sessions for this client
151
+ self._sessions: Dict[str, Session] = {}
152
+ self._session_lock = threading.Lock()
153
+
154
+ # Store providers list
155
+ self._providers = providers or []
156
+
157
+ # Initialize telemetry if providers specified
158
+ if self._providers:
159
+ self._initialize_telemetry()
160
+
161
+ # Register with shutdown manager
162
+ shutdown_manager = get_shutdown_manager()
163
+ shutdown_manager.register_client(self)
164
+
165
+ logger.info(
166
+ f"[LucidicAI] Initialized client {self._client_id[:8]}... "
167
+ f"(production={self._production}, providers={self._providers})"
146
168
  )
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
-
154
- self.initialized = True
155
- return self.session.session_id
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
-
170
- def create_event_for_session(self, session_id: str, **kwargs) -> str:
171
- """Create an event for a specific session id (new typed model).
172
-
173
- This avoids mutating the global session and directly uses the new
174
- event API. Prefer passing typed fields and a 'type' argument.
175
- """
176
- kwargs = dict(kwargs)
177
- kwargs['session_id'] = session_id
178
- return self.create_event(**kwargs)
179
-
180
- def create_experiment(self, **kwargs) -> str:
181
- kwargs['agent_id'] = self.agent_id
182
- return self.make_request('createexperiment', 'POST', kwargs)['experiment_id']
183
-
184
- def get_prompt(self, prompt_name, cache_ttl, label) -> str:
185
- current_time = time.time()
186
- key = (prompt_name, label)
187
- if key in self.prompts:
188
- prompt, expiration_time = self.prompts[key]
189
- if expiration_time == float('inf') or current_time < expiration_time:
190
- return prompt
191
- params={
192
- "agent_id": self.agent_id,
193
- "prompt_name": prompt_name,
194
- "label": label
195
- }
196
- prompt = self.make_request('getprompt', 'GET', params)['prompt_content']
197
-
198
- if cache_ttl != 0:
199
- if cache_ttl == -1:
200
- expiration_time = float('inf')
201
- else:
202
- expiration_time = current_time + cache_ttl
203
- self.prompts[key] = (prompt, expiration_time)
204
- return prompt
205
-
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
-
214
- http_methods = {
215
- "GET": lambda data: self.request_session.get(f"{self.base_url}/{endpoint}", params=data),
216
- "POST": lambda data: self.request_session.post(f"{self.base_url}/{endpoint}", json=data),
217
- "PUT": lambda data: self.request_session.put(f"{self.base_url}/{endpoint}", json=data),
218
- "DELETE": lambda data: self.request_session.delete(f"{self.base_url}/{endpoint}", params=data),
219
- } # TODO: make into enum
220
- data['current_time'] = datetime.now().astimezone(timezone.utc).isoformat()
221
- # Debug: print final payload about to be sent
169
+
170
+ def _initialize_telemetry(self) -> None:
171
+ """Initialize telemetry and register this client."""
222
172
  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}")
227
- func = http_methods[method]
228
- response = None
229
- for _ in range(NETWORK_RETRIES):
230
- try:
231
- response = func(data)
232
- break
233
- except Exception:
173
+ from .telemetry.telemetry_manager import get_telemetry_manager
174
+
175
+ manager = get_telemetry_manager()
176
+ manager.ensure_initialized(self._providers)
177
+ manager.register_client(self)
178
+ logger.debug(f"[LucidicAI] Registered with telemetry manager")
179
+ except Exception as e:
180
+ if self._production:
181
+ logger.error(f"[LucidicAI] Failed to initialize telemetry: {e}")
182
+ else:
183
+ raise
184
+
185
+ @property
186
+ def client_id(self) -> str:
187
+ """Get the unique client identifier."""
188
+ return self._client_id
189
+
190
+ @property
191
+ def config(self) -> SDKConfig:
192
+ """Get the client configuration."""
193
+ return self._config
194
+
195
+ @property
196
+ def agent_id(self) -> Optional[str]:
197
+ """Get the agent ID."""
198
+ return self._config.agent_id
199
+
200
+ @property
201
+ def is_valid(self) -> bool:
202
+ """Check if the client is properly configured."""
203
+ return self._valid
204
+
205
+ @property
206
+ def experiments(self) -> ExperimentResource:
207
+ """Access experiments resource.
208
+
209
+ Example:
210
+ experiment_id = client.experiments.create(
211
+ experiment_name="My Experiment",
212
+ description="Testing new model"
213
+ )
214
+ """
215
+ return self._resources["experiments"]
216
+
217
+ @property
218
+ def prompts(self) -> PromptResource:
219
+ """Access prompts resource.
220
+
221
+ Example:
222
+ prompt = client.prompts.get(
223
+ prompt_name="greeting",
224
+ variables={"name": "Alice"}
225
+ )
226
+ """
227
+ return self._resources["prompts"]
228
+
229
+ @property
230
+ def feature_flags(self) -> FeatureFlagResource:
231
+ """Access feature flags resource.
232
+
233
+ Example:
234
+ flag_value = client.feature_flags.get(
235
+ flag_name="new_feature",
236
+ default=False
237
+ )
238
+ """
239
+ return self._resources["feature_flags"]
240
+
241
+ @property
242
+ def sessions(self) -> SessionResource:
243
+ """Access sessions resource.
244
+
245
+ Example:
246
+ with client.sessions.create(session_name="My Session") as session:
247
+ # Do work
234
248
  pass
235
- if response is None:
236
- raise InvalidOperationError("Cannot reach backend. Check your internet connection.")
237
- if response.status_code == 401:
238
- raise APIKeyVerificationError("Invalid API key: 401 Unauthorized")
239
- if response.status_code == 402:
240
- raise InvalidOperationError("Invalid operation: 402 Insufficient Credits")
241
- if response.status_code == 403:
242
- raise APIKeyVerificationError(f"Invalid API key: 403 Forbidden")
243
- try:
244
- response.raise_for_status()
245
- except requests.exceptions.HTTPError as e:
246
- raise InvalidOperationError(f"Request to Lucidic AI Backend failed: {e.response.text}")
247
- return response.json()
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
249
+ """
250
+ return self._resources["sessions"]
306
251
 
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
252
+ @property
253
+ def events(self) -> EventResource:
254
+ """Access events resource.
314
255
 
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
256
+ Example:
257
+ event_id = client.events.create(
258
+ type="custom_event",
259
+ data={"key": "value"}
260
+ )
261
+ """
262
+ return self._resources["events"]
263
+
264
+ @property
265
+ def datasets(self) -> DatasetResource:
266
+ """Access datasets resource.
267
+
268
+ Example:
269
+ dataset = client.datasets.get(dataset_id)
270
+ client.datasets.create(name="My Dataset")
271
+ """
272
+ return self._resources["datasets"]
273
+
274
+ # ==================== Decorators ====================
275
+
276
+ def event(
277
+ self, func: Optional[Callable] = None, **decorator_kwargs
278
+ ) -> Callable[[F], F]:
279
+ """Create an event decorator bound to this client.
280
+
281
+ The decorator tracks function calls as events when the current
282
+ context belongs to this client.
283
+
284
+ Can be used with or without parentheses:
285
+ @client.event
286
+ def my_function(): ...
287
+
288
+ @client.event()
289
+ def my_function(): ...
290
+
291
+ @client.event(tags=["important"])
292
+ def my_function(): ...
293
+
294
+ Args:
295
+ func: The function to decorate (when used without parentheses).
296
+ **decorator_kwargs: Additional event metadata.
297
+
298
+ Returns:
299
+ A decorator function or decorated function.
300
+
301
+ Example:
302
+ @client.event
303
+ def process_data(data):
304
+ # Function call is tracked as an event
305
+ return result
306
+
307
+ @client.event(tags=["important"])
308
+ async def async_process(data):
309
+ # Async functions are also supported
310
+ return result
311
+ """
312
+ from .sdk.decorators import event as event_decorator
313
+
314
+ decorator = event_decorator(client=self, **decorator_kwargs)
315
+
316
+ # If func is provided, we're being used without parentheses
317
+ if func is not None:
318
+ return decorator(func)
319
+
320
+ # Otherwise, return the decorator for later application
321
+ return decorator
321
322
 
322
- def create_event(self, type: str = "generic", **kwargs) -> str:
323
- """Create a typed event (non-blocking) and return client-side UUID.
323
+ # ==================== Lifecycle ====================
324
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
325
+ def close(self) -> None:
326
+ """Close the client and clean up resources.
327
+
328
+ This ends all active sessions and unregisters from telemetry.
329
329
  """
330
- # Resolve session_id: explicit -> context -> current session
331
- session_id = kwargs.pop('session_id', None)
332
- if not session_id:
330
+ logger.info(f"[LucidicAI] Closing client {self._client_id[:8]}...")
331
+
332
+ # Collect session IDs under lock (don't clear - let end() handle removal)
333
+ with self._session_lock:
334
+ session_ids = list(self._sessions.keys())
335
+
336
+ # End sessions WITHOUT holding lock (HTTP calls can be slow)
337
+ # end() will acquire lock and pop each session from _sessions
338
+ for session_id in session_ids:
333
339
  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:
340
+ self.sessions.end(session_id)
341
+ except Exception as e:
342
+ logger.debug(f"[LucidicAI] Error ending session on close: {e}")
343
+
344
+ # Unregister from telemetry
345
+ if self._providers:
346
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
- }
347
+ from .telemetry.telemetry_manager import get_telemetry_manager
384
348
 
385
- # Queue for background processing and return immediately
386
- self._event_queue.queue_event(event_request)
387
- return client_event_id
349
+ manager = get_telemetry_manager()
350
+ manager.unregister_client(self._client_id)
351
+ except Exception as e:
352
+ logger.debug(f"[LucidicAI] Error unregistering from telemetry: {e}")
388
353
 
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.")
354
+ # Unregister from shutdown manager
355
+ try:
356
+ shutdown_manager = get_shutdown_manager()
357
+ shutdown_manager.unregister_client(self._client_id)
358
+ except Exception as e:
359
+ logger.debug(f"[LucidicAI] Error unregistering from shutdown manager: {e}")
392
360
 
393
- def mask(self, data):
394
- if not self.masking_function:
395
- return data
396
- if not data:
397
- return data
361
+ # Close HTTP client
398
362
  try:
399
- return self.masking_function(data)
363
+ self._http.close()
400
364
  except Exception as e:
401
- logger = logging.getLogger('Lucidic')
402
- logger.error(f"Error in custom masking function: {repr(e)}")
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:
365
+ logger.debug(f"[LucidicAI] Error closing HTTP client: {e}")
366
+
367
+ logger.info(f"[LucidicAI] Client {self._client_id[:8]}... closed")
368
+
369
+ async def aclose(self) -> None:
370
+ """Close the client (async version)."""
371
+ logger.info(f"[LucidicAI] Closing async client {self._client_id[:8]}...")
372
+
373
+ # Collect session IDs under lock (don't clear - let aend() handle removal)
374
+ with self._session_lock:
375
+ session_ids = list(self._sessions.keys())
376
+
377
+ # End sessions WITHOUT holding lock (HTTP calls can be slow)
378
+ # aend() will acquire lock and pop each session from _sessions
379
+ for session_id in session_ids:
418
380
  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
-
381
+ await self.sessions.aend(session_id)
475
382
  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
- """
383
+ logger.debug(f"[LucidicAI] Error ending session on close: {e}")
384
+
385
+ if self._providers:
386
+ try:
387
+ from .telemetry.telemetry_manager import get_telemetry_manager
388
+
389
+ manager = get_telemetry_manager()
390
+ manager.unregister_client(self._client_id)
391
+ except Exception as e:
392
+ logger.debug(f"[LucidicAI] Error unregistering from telemetry: {e}")
393
+
394
+ try:
395
+ shutdown_manager = get_shutdown_manager()
396
+ shutdown_manager.unregister_client(self._client_id)
397
+ except Exception as e:
398
+ logger.debug(f"[LucidicAI] Error unregistering from shutdown manager: {e}")
399
+
495
400
  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
401
+ await self._http.aclose()
511
402
  except Exception as e:
512
- logger.error(f"[Telemetry] Failed to flush spans: {e}")
513
- return False
403
+ logger.debug(f"[LucidicAI] Error closing HTTP client: {e}")
404
+
405
+ logger.info(f"[LucidicAI] Async client {self._client_id[:8]}... closed")
406
+
407
+ def __enter__(self) -> "LucidicAI":
408
+ """Enter context manager."""
409
+ return self
410
+
411
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
412
+ """Exit context manager."""
413
+ self.close()
414
+
415
+ async def __aenter__(self) -> "LucidicAI":
416
+ """Enter async context manager."""
417
+ return self
418
+
419
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
420
+ """Exit async context manager."""
421
+ await self.aclose()
422
+
423
+ def __repr__(self) -> str:
424
+ return (
425
+ f"<LucidicAI(id={self._client_id[:8]}..., "
426
+ f"agent_id={self._config.agent_id}, "
427
+ f"sessions={len(self._sessions)})>"
428
+ )