clue-python-sdk-core 0.0.1__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.
- clue_python_sdk_core/__init__.py +135 -0
- clue_python_sdk_core/adapters.py +2177 -0
- clue_python_sdk_core/bootstrap.py +166 -0
- clue_python_sdk_core/celery.py +734 -0
- clue_python_sdk_core/client.py +335 -0
- clue_python_sdk_core/contracts.py +224 -0
- clue_python_sdk_core/event_size.py +96 -0
- clue_python_sdk_core/http_extraction.py +146 -0
- clue_python_sdk_core/orm.py +534 -0
- clue_python_sdk_core/otel_bridge.py +164 -0
- clue_python_sdk_core/parameter_snapshot.py +152 -0
- clue_python_sdk_core/privacy.py +124 -0
- clue_python_sdk_core/requests_instrumentation.py +341 -0
- clue_python_sdk_core/resources.py +44 -0
- clue_python_sdk_core/runtime.py +894 -0
- clue_python_sdk_core-0.0.1.dist-info/METADATA +11 -0
- clue_python_sdk_core-0.0.1.dist-info/RECORD +19 -0
- clue_python_sdk_core-0.0.1.dist-info/WHEEL +5 -0
- clue_python_sdk_core-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from contextlib import ContextDecorator
|
|
9
|
+
from contextvars import ContextVar, Token
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .adapters import (
|
|
13
|
+
JsonValue,
|
|
14
|
+
build_account_associated_event,
|
|
15
|
+
build_backend_context,
|
|
16
|
+
build_custom_event,
|
|
17
|
+
build_domain_command_event,
|
|
18
|
+
build_identity_identified_event,
|
|
19
|
+
build_identity_logged_out_event,
|
|
20
|
+
build_sdk_diagnostic_event,
|
|
21
|
+
build_state_transition_event,
|
|
22
|
+
build_subject_profile,
|
|
23
|
+
build_tool_call_event,
|
|
24
|
+
)
|
|
25
|
+
from .client import (
|
|
26
|
+
DEFAULT_FLUSH_INTERVAL_MS,
|
|
27
|
+
DEFAULT_FLUSH_THRESHOLD_BYTES,
|
|
28
|
+
DEFAULT_MAX_BUFFER_BYTES,
|
|
29
|
+
DEFAULT_MAX_BUFFER_EVENTS,
|
|
30
|
+
DEFAULT_MAX_PAYLOAD_BYTES,
|
|
31
|
+
CluePythonClient,
|
|
32
|
+
)
|
|
33
|
+
from .contracts import (
|
|
34
|
+
BACKEND_RUNTIME_LANGUAGE,
|
|
35
|
+
BACKEND_STATUS_FAILED,
|
|
36
|
+
BACKEND_STATUS_FINISHED,
|
|
37
|
+
BACKEND_STATUS_STARTED,
|
|
38
|
+
CUSTOM_EVENT_SDK_INITIALIZED,
|
|
39
|
+
SDK_COLLECTION_MODE_STANDARD,
|
|
40
|
+
)
|
|
41
|
+
from .event_size import DEFAULT_MAX_EVENT_BYTES
|
|
42
|
+
from .otel_bridge import annotate_current_otel_span, merge_current_otel_span_context, resolve_current_otel_span_context
|
|
43
|
+
from .privacy import DEFAULT_DENIED_KEYS
|
|
44
|
+
|
|
45
|
+
_current_client: ContextVar[CluePythonClient | None] = ContextVar(
|
|
46
|
+
"clue_python_current_client",
|
|
47
|
+
default=None,
|
|
48
|
+
)
|
|
49
|
+
_current_context: ContextVar[Mapping[str, JsonValue] | None] = ContextVar(
|
|
50
|
+
"clue_python_current_context",
|
|
51
|
+
default=None,
|
|
52
|
+
)
|
|
53
|
+
_configured_settings: CluePythonSettings | None = None
|
|
54
|
+
_shared_clients: dict[str, CluePythonClient] = {}
|
|
55
|
+
DEFAULT_INGEST_ENDPOINT = "https://clue.example.com/api/v1/ingest/backend"
|
|
56
|
+
LOCAL_SETUP_CHECK_ENDPOINT_ENV = "CLUE_LOCAL_SETUP_CHECK_ENDPOINT"
|
|
57
|
+
DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SECONDS = 5.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CluePythonSettings:
|
|
62
|
+
enabled: bool
|
|
63
|
+
endpoint: str | None
|
|
64
|
+
project_key: str | None
|
|
65
|
+
environment: str
|
|
66
|
+
api_key: str | None
|
|
67
|
+
service_name: str
|
|
68
|
+
allowed_value_paths: tuple[str, ...]
|
|
69
|
+
denied_keys: tuple[str, ...]
|
|
70
|
+
service_key: str = "python-service"
|
|
71
|
+
producer_id: str = "python-service"
|
|
72
|
+
sdk_collection_mode: str = SDK_COLLECTION_MODE_STANDARD
|
|
73
|
+
max_event_bytes: int | None = DEFAULT_MAX_EVENT_BYTES
|
|
74
|
+
max_buffer_events: int = DEFAULT_MAX_BUFFER_EVENTS
|
|
75
|
+
max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES
|
|
76
|
+
flush_threshold_bytes: int = DEFAULT_FLUSH_THRESHOLD_BYTES
|
|
77
|
+
max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES
|
|
78
|
+
flush_interval_ms: int = DEFAULT_FLUSH_INTERVAL_MS
|
|
79
|
+
capture_outbound_requests: bool = True
|
|
80
|
+
capture_celery: bool = True
|
|
81
|
+
capture_orm: bool = False
|
|
82
|
+
state_fields: tuple[str, ...] = ()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_trace_id() -> str:
|
|
86
|
+
return uuid.uuid4().hex
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_span_id() -> str:
|
|
90
|
+
return uuid.uuid4().hex[:16]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_request_span_id() -> str:
|
|
94
|
+
return f"rsp_{uuid.uuid4()}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_interaction_id() -> str:
|
|
98
|
+
return f"int_{uuid.uuid4()}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _require_non_empty(value: str, field_name: str) -> str:
|
|
102
|
+
normalized = value.strip()
|
|
103
|
+
if not normalized:
|
|
104
|
+
raise ValueError(f"{field_name} is required")
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _positive_int(value: object, fallback: int) -> int:
|
|
109
|
+
try:
|
|
110
|
+
parsed = int(str(value))
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
return fallback
|
|
113
|
+
return parsed if parsed > 0 else fallback
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_settings(
|
|
117
|
+
*,
|
|
118
|
+
project_key: str,
|
|
119
|
+
environment: str,
|
|
120
|
+
api_key: str | None = None,
|
|
121
|
+
endpoint: str | None = None,
|
|
122
|
+
service_name: str = "python-service",
|
|
123
|
+
service_key: str | None = None,
|
|
124
|
+
producer_id: str | None = None,
|
|
125
|
+
allowed_value_paths: Sequence[str] = (),
|
|
126
|
+
denied_keys: Sequence[str] = (),
|
|
127
|
+
max_event_bytes: int | None = DEFAULT_MAX_EVENT_BYTES,
|
|
128
|
+
max_buffer_events: int = DEFAULT_MAX_BUFFER_EVENTS,
|
|
129
|
+
max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES,
|
|
130
|
+
flush_threshold_bytes: int = DEFAULT_FLUSH_THRESHOLD_BYTES,
|
|
131
|
+
max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES,
|
|
132
|
+
flush_interval_ms: int = DEFAULT_FLUSH_INTERVAL_MS,
|
|
133
|
+
capture_outbound_requests: bool = True,
|
|
134
|
+
capture_celery: bool = True,
|
|
135
|
+
capture_orm: bool = False,
|
|
136
|
+
state_fields: Sequence[str] = (),
|
|
137
|
+
sdk_collection_mode: str = SDK_COLLECTION_MODE_STANDARD,
|
|
138
|
+
) -> CluePythonSettings:
|
|
139
|
+
resolved_endpoint = (
|
|
140
|
+
os.environ.get(LOCAL_SETUP_CHECK_ENDPOINT_ENV)
|
|
141
|
+
or endpoint
|
|
142
|
+
or os.environ.get("CLUE_INGEST_ENDPOINT")
|
|
143
|
+
or DEFAULT_INGEST_ENDPOINT
|
|
144
|
+
)
|
|
145
|
+
resolved_producer_id = (
|
|
146
|
+
producer_id
|
|
147
|
+
or os.environ.get("CLUE_PRODUCER_ID")
|
|
148
|
+
or service_name
|
|
149
|
+
)
|
|
150
|
+
resolved_service_key = (
|
|
151
|
+
service_key
|
|
152
|
+
or os.environ.get("CLUE_SERVICE_KEY")
|
|
153
|
+
or resolved_producer_id
|
|
154
|
+
or service_name
|
|
155
|
+
)
|
|
156
|
+
env_state_fields = os.environ.get("CLUE_STATE_FIELDS", "")
|
|
157
|
+
resolved_state_fields = tuple(
|
|
158
|
+
dict.fromkeys(
|
|
159
|
+
[
|
|
160
|
+
*(str(entry).strip() for entry in state_fields if str(entry).strip()),
|
|
161
|
+
*(
|
|
162
|
+
entry.strip()
|
|
163
|
+
for entry in env_state_fields.split(",")
|
|
164
|
+
if entry.strip()
|
|
165
|
+
),
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
resolved_max_buffer_events = _positive_int(
|
|
170
|
+
os.environ.get("CLUE_MAX_BUFFER_EVENTS"),
|
|
171
|
+
max_buffer_events,
|
|
172
|
+
)
|
|
173
|
+
resolved_flush_interval_ms = _positive_int(
|
|
174
|
+
os.environ.get("CLUE_FLUSH_INTERVAL_MS"),
|
|
175
|
+
flush_interval_ms,
|
|
176
|
+
)
|
|
177
|
+
return CluePythonSettings(
|
|
178
|
+
enabled=bool(project_key and environment.strip()),
|
|
179
|
+
endpoint=resolved_endpoint,
|
|
180
|
+
project_key=project_key or None,
|
|
181
|
+
environment=environment.strip(),
|
|
182
|
+
api_key=api_key,
|
|
183
|
+
service_name=service_name,
|
|
184
|
+
service_key=resolved_service_key,
|
|
185
|
+
producer_id=resolved_producer_id,
|
|
186
|
+
sdk_collection_mode=(
|
|
187
|
+
sdk_collection_mode
|
|
188
|
+
if sdk_collection_mode in {"standard", "diagnostic"}
|
|
189
|
+
else SDK_COLLECTION_MODE_STANDARD
|
|
190
|
+
),
|
|
191
|
+
allowed_value_paths=tuple(
|
|
192
|
+
str(entry) for entry in allowed_value_paths if str(entry).strip()
|
|
193
|
+
),
|
|
194
|
+
denied_keys=tuple(
|
|
195
|
+
dict.fromkeys(
|
|
196
|
+
[
|
|
197
|
+
*DEFAULT_DENIED_KEYS,
|
|
198
|
+
*(str(entry) for entry in denied_keys if str(entry).strip()),
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
),
|
|
202
|
+
max_event_bytes=max_event_bytes,
|
|
203
|
+
max_buffer_events=max(1, resolved_max_buffer_events),
|
|
204
|
+
max_payload_bytes=max(1024, max_payload_bytes),
|
|
205
|
+
flush_threshold_bytes=max(1024, min(flush_threshold_bytes, max_payload_bytes)),
|
|
206
|
+
max_buffer_bytes=max(1024, max_buffer_bytes),
|
|
207
|
+
flush_interval_ms=max(1, resolved_flush_interval_ms),
|
|
208
|
+
capture_outbound_requests=capture_outbound_requests,
|
|
209
|
+
capture_celery=capture_celery,
|
|
210
|
+
capture_orm=capture_orm,
|
|
211
|
+
state_fields=resolved_state_fields,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_settings() -> CluePythonSettings:
|
|
216
|
+
if _configured_settings is not None:
|
|
217
|
+
return _configured_settings
|
|
218
|
+
|
|
219
|
+
return build_settings(
|
|
220
|
+
project_key="",
|
|
221
|
+
environment="",
|
|
222
|
+
service_name="python-service",
|
|
223
|
+
service_key="python-service",
|
|
224
|
+
producer_id="python-service",
|
|
225
|
+
capture_outbound_requests=False,
|
|
226
|
+
capture_celery=False,
|
|
227
|
+
capture_orm=False,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def configure_settings(settings: CluePythonSettings | None) -> None:
|
|
232
|
+
global _configured_settings
|
|
233
|
+
_configured_settings = settings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def clear_configured_settings() -> None:
|
|
237
|
+
configure_settings(None)
|
|
238
|
+
for client in tuple(_shared_clients.values()):
|
|
239
|
+
close = getattr(client, "close", None)
|
|
240
|
+
if callable(close):
|
|
241
|
+
close()
|
|
242
|
+
_shared_clients.clear()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def create_client(settings: CluePythonSettings) -> CluePythonClient | None:
|
|
246
|
+
if not settings.enabled or not settings.endpoint or not settings.project_key:
|
|
247
|
+
return None
|
|
248
|
+
return CluePythonClient(
|
|
249
|
+
endpoint=settings.endpoint,
|
|
250
|
+
project_key=settings.project_key,
|
|
251
|
+
environment=settings.environment,
|
|
252
|
+
producer_id=settings.producer_id,
|
|
253
|
+
api_key=settings.api_key,
|
|
254
|
+
max_event_bytes=settings.max_event_bytes,
|
|
255
|
+
max_buffer_events=settings.max_buffer_events,
|
|
256
|
+
max_payload_bytes=settings.max_payload_bytes,
|
|
257
|
+
flush_threshold_bytes=settings.flush_threshold_bytes,
|
|
258
|
+
max_buffer_bytes=settings.max_buffer_bytes,
|
|
259
|
+
flush_interval_ms=settings.flush_interval_ms,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _shared_client_key(settings: CluePythonSettings) -> str:
|
|
264
|
+
return "|".join(
|
|
265
|
+
[
|
|
266
|
+
settings.endpoint or "",
|
|
267
|
+
settings.project_key or "",
|
|
268
|
+
settings.environment,
|
|
269
|
+
settings.producer_id,
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_shared_client(settings: CluePythonSettings) -> CluePythonClient | None:
|
|
275
|
+
if not settings.enabled or not settings.endpoint or not settings.project_key:
|
|
276
|
+
return None
|
|
277
|
+
key = _shared_client_key(settings)
|
|
278
|
+
client = _shared_clients.get(key)
|
|
279
|
+
if client is not None:
|
|
280
|
+
return client
|
|
281
|
+
client = CluePythonClient(
|
|
282
|
+
endpoint=settings.endpoint,
|
|
283
|
+
project_key=settings.project_key,
|
|
284
|
+
environment=settings.environment,
|
|
285
|
+
producer_id=settings.producer_id,
|
|
286
|
+
api_key=settings.api_key,
|
|
287
|
+
max_event_bytes=settings.max_event_bytes,
|
|
288
|
+
max_buffer_events=settings.max_buffer_events,
|
|
289
|
+
max_payload_bytes=settings.max_payload_bytes,
|
|
290
|
+
flush_threshold_bytes=settings.flush_threshold_bytes,
|
|
291
|
+
max_buffer_bytes=settings.max_buffer_bytes,
|
|
292
|
+
flush_interval_ms=settings.flush_interval_ms,
|
|
293
|
+
auto_flush=True,
|
|
294
|
+
)
|
|
295
|
+
_shared_clients[key] = client
|
|
296
|
+
return client
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def enqueue_event_to_shared(
|
|
300
|
+
settings: CluePythonSettings,
|
|
301
|
+
event: Mapping[str, JsonValue],
|
|
302
|
+
) -> bool:
|
|
303
|
+
client = get_shared_client(settings)
|
|
304
|
+
if client is None:
|
|
305
|
+
return False
|
|
306
|
+
return client.add_event(event)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def enqueue_client_events(
|
|
310
|
+
settings: CluePythonSettings,
|
|
311
|
+
client: CluePythonClient,
|
|
312
|
+
) -> int:
|
|
313
|
+
shared_client = get_shared_client(settings)
|
|
314
|
+
if shared_client is None:
|
|
315
|
+
return 0
|
|
316
|
+
count = 0
|
|
317
|
+
for event in client.drain_events():
|
|
318
|
+
if shared_client.add_event(event):
|
|
319
|
+
count += 1
|
|
320
|
+
return count
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def flush_shared_clients(
|
|
324
|
+
timeout_seconds: float = DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SECONDS,
|
|
325
|
+
) -> None:
|
|
326
|
+
for client in tuple(_shared_clients.values()):
|
|
327
|
+
try:
|
|
328
|
+
client.flush(timeout_seconds=timeout_seconds)
|
|
329
|
+
except Exception:
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def emit_sdk_initialized(settings: CluePythonSettings) -> bool:
|
|
334
|
+
context = build_backend_context(
|
|
335
|
+
service_name=settings.service_name,
|
|
336
|
+
service_key=settings.service_key,
|
|
337
|
+
producer_id=settings.producer_id,
|
|
338
|
+
environment=settings.environment,
|
|
339
|
+
sdk_collection_mode=settings.sdk_collection_mode,
|
|
340
|
+
)
|
|
341
|
+
return enqueue_event_to_shared(
|
|
342
|
+
settings,
|
|
343
|
+
build_custom_event(
|
|
344
|
+
context=context,
|
|
345
|
+
event_name=CUSTOM_EVENT_SDK_INITIALIZED,
|
|
346
|
+
properties={
|
|
347
|
+
"backend_scope": "sdk",
|
|
348
|
+
"sdk_action": "initialize",
|
|
349
|
+
},
|
|
350
|
+
metrics={"count": 1},
|
|
351
|
+
denied_keys=settings.denied_keys,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def create_request_context(
|
|
357
|
+
*,
|
|
358
|
+
settings: CluePythonSettings,
|
|
359
|
+
request: object | None = None,
|
|
360
|
+
service_name: str | None = None,
|
|
361
|
+
request_id: str | None = None,
|
|
362
|
+
request_span_id: str | None = None,
|
|
363
|
+
interaction_id: str | None = None,
|
|
364
|
+
trace_id: str | None = None,
|
|
365
|
+
session_id: str | None = None,
|
|
366
|
+
) -> dict[str, JsonValue]:
|
|
367
|
+
anonymous_id: str | None = None
|
|
368
|
+
user_id: str | None = None
|
|
369
|
+
account_id: str | None = None
|
|
370
|
+
tab_id: str | None = None
|
|
371
|
+
parent_span_id: str | None = None
|
|
372
|
+
request_span_id_is_explicit = request_span_id is not None
|
|
373
|
+
if request is not None:
|
|
374
|
+
cookies = getattr(request, "COOKIES", None)
|
|
375
|
+
if isinstance(cookies, Mapping) and session_id is None:
|
|
376
|
+
raw_session_id = cookies.get("sessionid")
|
|
377
|
+
if raw_session_id is not None:
|
|
378
|
+
session_id = str(raw_session_id)
|
|
379
|
+
|
|
380
|
+
headers = getattr(request, "headers", None)
|
|
381
|
+
if hasattr(headers, "get"):
|
|
382
|
+
raw_anonymous_id = headers.get("x-clue-anonymous-id")
|
|
383
|
+
if raw_anonymous_id:
|
|
384
|
+
anonymous_id = str(raw_anonymous_id)
|
|
385
|
+
raw_user_id_header = headers.get("x-clue-user-id")
|
|
386
|
+
if raw_user_id_header:
|
|
387
|
+
user_id = str(raw_user_id_header)
|
|
388
|
+
raw_account_id = headers.get("x-clue-account-id")
|
|
389
|
+
if raw_account_id:
|
|
390
|
+
account_id = str(raw_account_id)
|
|
391
|
+
raw_session_id_header = headers.get("x-clue-session-id")
|
|
392
|
+
if raw_session_id_header:
|
|
393
|
+
session_id = str(raw_session_id_header)
|
|
394
|
+
raw_tab_id = headers.get("x-clue-tab-id")
|
|
395
|
+
if raw_tab_id:
|
|
396
|
+
tab_id = str(raw_tab_id)
|
|
397
|
+
if request_id is None:
|
|
398
|
+
raw_request_id = headers.get("x-request-id") or headers.get("x-clue-request-id")
|
|
399
|
+
if raw_request_id:
|
|
400
|
+
request_id = str(raw_request_id)
|
|
401
|
+
if request_span_id is None:
|
|
402
|
+
raw_request_span_id = headers.get("x-clue-request-span-id")
|
|
403
|
+
if raw_request_span_id:
|
|
404
|
+
request_span_id = str(raw_request_span_id)
|
|
405
|
+
request_span_id_is_explicit = True
|
|
406
|
+
if interaction_id is None:
|
|
407
|
+
raw_interaction_id = headers.get("x-clue-interaction-id")
|
|
408
|
+
if raw_interaction_id:
|
|
409
|
+
interaction_id = str(raw_interaction_id)
|
|
410
|
+
if trace_id is None:
|
|
411
|
+
raw_trace_id = headers.get("x-clue-trace-id")
|
|
412
|
+
if raw_trace_id:
|
|
413
|
+
trace_id = str(raw_trace_id)
|
|
414
|
+
if trace_id is None:
|
|
415
|
+
traceparent = headers.get("traceparent")
|
|
416
|
+
if isinstance(traceparent, str) and traceparent.count("-") >= 3:
|
|
417
|
+
parts = traceparent.split("-")
|
|
418
|
+
trace_id = parts[1]
|
|
419
|
+
if len(parts) >= 3 and parts[2]:
|
|
420
|
+
parent_span_id = parts[2]
|
|
421
|
+
else:
|
|
422
|
+
parent_span_id = None
|
|
423
|
+
else:
|
|
424
|
+
parent_span_id = None
|
|
425
|
+
else:
|
|
426
|
+
parent_span_id = None
|
|
427
|
+
else:
|
|
428
|
+
parent_span_id = None
|
|
429
|
+
else:
|
|
430
|
+
parent_span_id = None
|
|
431
|
+
|
|
432
|
+
if user_id is None:
|
|
433
|
+
user = getattr(request, "user", None)
|
|
434
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
435
|
+
raw_user_id = getattr(user, "id", None)
|
|
436
|
+
if raw_user_id is not None:
|
|
437
|
+
user_id = str(raw_user_id)
|
|
438
|
+
|
|
439
|
+
if trace_id is None:
|
|
440
|
+
trace_id = create_trace_id()
|
|
441
|
+
|
|
442
|
+
context = {
|
|
443
|
+
"service_name": service_name or settings.service_name,
|
|
444
|
+
"service_key": settings.service_key,
|
|
445
|
+
"producer_id": settings.producer_id,
|
|
446
|
+
"runtime_language": BACKEND_RUNTIME_LANGUAGE,
|
|
447
|
+
"environment": settings.environment,
|
|
448
|
+
"sdk_collection_mode": settings.sdk_collection_mode,
|
|
449
|
+
"anonymous_id": anonymous_id,
|
|
450
|
+
"user_id": user_id,
|
|
451
|
+
"account_id": account_id,
|
|
452
|
+
"session_id": session_id,
|
|
453
|
+
"tab_id": tab_id,
|
|
454
|
+
"request_id": request_id or str(uuid.uuid4()),
|
|
455
|
+
"request_span_id": request_span_id if request_span_id_is_explicit else None,
|
|
456
|
+
"interaction_id": interaction_id,
|
|
457
|
+
"trace_id": trace_id,
|
|
458
|
+
"span_id": create_span_id(),
|
|
459
|
+
"parent_span_id": parent_span_id,
|
|
460
|
+
}
|
|
461
|
+
if request is not None:
|
|
462
|
+
context = merge_current_otel_span_context(
|
|
463
|
+
context,
|
|
464
|
+
expected_kind="SERVER",
|
|
465
|
+
use_otel_request_span_id=not request_span_id_is_explicit,
|
|
466
|
+
)
|
|
467
|
+
context["request_span_id"] = (
|
|
468
|
+
request_span_id
|
|
469
|
+
if request_span_id_is_explicit
|
|
470
|
+
else (
|
|
471
|
+
context.get("request_span_id")
|
|
472
|
+
if isinstance(context.get("request_span_id"), str)
|
|
473
|
+
and context.get("request_span_id")
|
|
474
|
+
else create_request_span_id()
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
return context
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def set_current_state(
|
|
481
|
+
*,
|
|
482
|
+
client: CluePythonClient,
|
|
483
|
+
context: Mapping[str, JsonValue],
|
|
484
|
+
) -> tuple[Token[CluePythonClient | None], Token[Mapping[str, JsonValue] | None]]:
|
|
485
|
+
return _current_client.set(client), _current_context.set(context)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def reset_current_state(
|
|
489
|
+
client_token: Token[CluePythonClient | None],
|
|
490
|
+
context_token: Token[Mapping[str, JsonValue] | None],
|
|
491
|
+
) -> None:
|
|
492
|
+
_current_client.reset(client_token)
|
|
493
|
+
_current_context.reset(context_token)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def get_current_client() -> CluePythonClient | None:
|
|
497
|
+
return _current_client.get()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def get_current_context() -> Mapping[str, JsonValue] | None:
|
|
501
|
+
return _current_context.get()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _update_current_context(
|
|
505
|
+
fields: Mapping[str, JsonValue],
|
|
506
|
+
) -> Mapping[str, JsonValue] | None:
|
|
507
|
+
context = get_current_context()
|
|
508
|
+
if context is None:
|
|
509
|
+
return None
|
|
510
|
+
updated = dict(context)
|
|
511
|
+
updated.update(fields)
|
|
512
|
+
_current_context.set(updated)
|
|
513
|
+
return updated
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def add_event(event: Mapping[str, JsonValue]) -> bool:
|
|
517
|
+
client = get_current_client()
|
|
518
|
+
if client is None:
|
|
519
|
+
return False
|
|
520
|
+
result = client.add_event(event)
|
|
521
|
+
return result is not False
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def ClueIdentify(user_id: str, traits: Mapping[str, object] | None = None) -> bool:
|
|
525
|
+
normalized_user_id = user_id.strip()
|
|
526
|
+
if not normalized_user_id:
|
|
527
|
+
raise ValueError("user_id is required")
|
|
528
|
+
settings = load_settings()
|
|
529
|
+
context = _update_current_context(
|
|
530
|
+
{
|
|
531
|
+
"user_id": normalized_user_id,
|
|
532
|
+
"user_profile": build_subject_profile(traits),
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
if context is None:
|
|
536
|
+
return False
|
|
537
|
+
return add_event(
|
|
538
|
+
build_identity_identified_event(
|
|
539
|
+
context=context,
|
|
540
|
+
user_id=normalized_user_id,
|
|
541
|
+
traits=traits,
|
|
542
|
+
denied_keys=settings.denied_keys,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def ClueSetAccount(
|
|
548
|
+
account_id: str,
|
|
549
|
+
traits: Mapping[str, object] | None = None,
|
|
550
|
+
) -> bool:
|
|
551
|
+
normalized_account_id = account_id.strip()
|
|
552
|
+
if not normalized_account_id:
|
|
553
|
+
raise ValueError("account_id is required")
|
|
554
|
+
workspace_id = None
|
|
555
|
+
if traits is not None:
|
|
556
|
+
raw_workspace_id = traits.get("workspace_id") or traits.get("workspaceId")
|
|
557
|
+
if isinstance(raw_workspace_id, str) and raw_workspace_id.strip():
|
|
558
|
+
workspace_id = raw_workspace_id.strip()
|
|
559
|
+
settings = load_settings()
|
|
560
|
+
context = _update_current_context(
|
|
561
|
+
{
|
|
562
|
+
"account_id": normalized_account_id,
|
|
563
|
+
"account_profile": build_subject_profile(traits),
|
|
564
|
+
"workspace_id": workspace_id,
|
|
565
|
+
}
|
|
566
|
+
)
|
|
567
|
+
if context is None:
|
|
568
|
+
return False
|
|
569
|
+
return add_event(
|
|
570
|
+
build_account_associated_event(
|
|
571
|
+
context=context,
|
|
572
|
+
account_id=normalized_account_id,
|
|
573
|
+
traits=traits,
|
|
574
|
+
denied_keys=settings.denied_keys,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def ClueLogout(reason: str | None = None) -> bool:
|
|
580
|
+
settings = load_settings()
|
|
581
|
+
context = get_current_context()
|
|
582
|
+
if context is None:
|
|
583
|
+
return False
|
|
584
|
+
emitted = add_event(
|
|
585
|
+
build_identity_logged_out_event(
|
|
586
|
+
context=context,
|
|
587
|
+
reason=reason,
|
|
588
|
+
denied_keys=settings.denied_keys,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
_update_current_context(
|
|
592
|
+
{
|
|
593
|
+
"user_id": None,
|
|
594
|
+
"account_id": None,
|
|
595
|
+
"user_profile": None,
|
|
596
|
+
"account_profile": None,
|
|
597
|
+
"workspace_id": None,
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
return emitted
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def ClueTrack(
|
|
604
|
+
event_name: str,
|
|
605
|
+
properties: Mapping[str, object] | None = None,
|
|
606
|
+
metrics: Mapping[str, object] | None = None,
|
|
607
|
+
) -> bool:
|
|
608
|
+
settings = load_settings()
|
|
609
|
+
context = get_current_context()
|
|
610
|
+
if context is None:
|
|
611
|
+
return False
|
|
612
|
+
return add_event(
|
|
613
|
+
build_custom_event(
|
|
614
|
+
context=context,
|
|
615
|
+
event_name=event_name,
|
|
616
|
+
properties=properties,
|
|
617
|
+
metrics=metrics,
|
|
618
|
+
denied_keys=settings.denied_keys,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class ClueCommand(ContextDecorator):
|
|
624
|
+
def __init__(self, command_key: str, command_label: str | None = None) -> None:
|
|
625
|
+
self.command_key = _require_non_empty(command_key, "command_key")
|
|
626
|
+
self.command_label = (
|
|
627
|
+
command_label.strip() if command_label and command_label.strip() else None
|
|
628
|
+
)
|
|
629
|
+
self._started_at: float | None = None
|
|
630
|
+
self._span_id: str | None = None
|
|
631
|
+
self._parent_span_id: str | None = None
|
|
632
|
+
|
|
633
|
+
def __enter__(self):
|
|
634
|
+
context = get_current_context()
|
|
635
|
+
if context is None:
|
|
636
|
+
return self
|
|
637
|
+
self._started_at = time.perf_counter()
|
|
638
|
+
otel_span = resolve_current_otel_span_context("INTERNAL")
|
|
639
|
+
self._span_id = (
|
|
640
|
+
otel_span["span_id"]
|
|
641
|
+
if otel_span is not None and otel_span.get("span_id")
|
|
642
|
+
else create_span_id()
|
|
643
|
+
)
|
|
644
|
+
self._parent_span_id = (
|
|
645
|
+
otel_span.get("parent_span_id") or None if otel_span is not None else None
|
|
646
|
+
)
|
|
647
|
+
annotate_current_otel_span(
|
|
648
|
+
{
|
|
649
|
+
"clue.flow.kind": "domain_command",
|
|
650
|
+
"clue.command.key": self.command_key,
|
|
651
|
+
"clue.command.label": self.command_label,
|
|
652
|
+
},
|
|
653
|
+
expected_kind="INTERNAL",
|
|
654
|
+
)
|
|
655
|
+
add_event(
|
|
656
|
+
build_domain_command_event(
|
|
657
|
+
context=context,
|
|
658
|
+
status=BACKEND_STATUS_STARTED,
|
|
659
|
+
command_key=self.command_key,
|
|
660
|
+
command_label=self.command_label,
|
|
661
|
+
span_id=self._span_id,
|
|
662
|
+
parent_span_id=self._parent_span_id,
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
return self
|
|
666
|
+
|
|
667
|
+
def __exit__(self, exc_type, _exc_value, _traceback) -> bool:
|
|
668
|
+
context = get_current_context()
|
|
669
|
+
if context is None:
|
|
670
|
+
return False
|
|
671
|
+
started_at = (
|
|
672
|
+
self._started_at if self._started_at is not None else time.perf_counter()
|
|
673
|
+
)
|
|
674
|
+
duration_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
|
675
|
+
add_event(
|
|
676
|
+
build_domain_command_event(
|
|
677
|
+
context=context,
|
|
678
|
+
status=BACKEND_STATUS_FAILED if exc_type is not None else BACKEND_STATUS_FINISHED,
|
|
679
|
+
command_key=self.command_key,
|
|
680
|
+
command_label=self.command_label,
|
|
681
|
+
duration_ms=duration_ms,
|
|
682
|
+
span_id=self._span_id,
|
|
683
|
+
parent_span_id=self._parent_span_id,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class ClueToolCall(ContextDecorator):
|
|
690
|
+
def __init__(
|
|
691
|
+
self,
|
|
692
|
+
tool_provider: str,
|
|
693
|
+
tool_name: str,
|
|
694
|
+
*,
|
|
695
|
+
tool_call_id: str | None = None,
|
|
696
|
+
tool_schema_hash: str | None = None,
|
|
697
|
+
validation_field_paths: Sequence[str] = (),
|
|
698
|
+
) -> None:
|
|
699
|
+
self.tool_provider = _require_non_empty(tool_provider, "tool_provider")
|
|
700
|
+
self.tool_name = _require_non_empty(tool_name, "tool_name")
|
|
701
|
+
self.tool_call_id = (
|
|
702
|
+
tool_call_id.strip() if tool_call_id and tool_call_id.strip() else None
|
|
703
|
+
)
|
|
704
|
+
self.tool_schema_hash = (
|
|
705
|
+
tool_schema_hash.strip()
|
|
706
|
+
if tool_schema_hash and tool_schema_hash.strip()
|
|
707
|
+
else None
|
|
708
|
+
)
|
|
709
|
+
self.validation_field_paths = tuple(
|
|
710
|
+
str(path).strip() for path in validation_field_paths if str(path).strip()
|
|
711
|
+
)
|
|
712
|
+
self._started_at: float | None = None
|
|
713
|
+
self._span_id: str | None = None
|
|
714
|
+
self._parent_span_id: str | None = None
|
|
715
|
+
|
|
716
|
+
def __enter__(self):
|
|
717
|
+
context = get_current_context()
|
|
718
|
+
if context is None:
|
|
719
|
+
return self
|
|
720
|
+
self._started_at = time.perf_counter()
|
|
721
|
+
otel_span = resolve_current_otel_span_context("INTERNAL")
|
|
722
|
+
self._span_id = (
|
|
723
|
+
otel_span["span_id"]
|
|
724
|
+
if otel_span is not None and otel_span.get("span_id")
|
|
725
|
+
else create_span_id()
|
|
726
|
+
)
|
|
727
|
+
self._parent_span_id = (
|
|
728
|
+
otel_span.get("parent_span_id") or None if otel_span is not None else None
|
|
729
|
+
)
|
|
730
|
+
annotate_current_otel_span(
|
|
731
|
+
{
|
|
732
|
+
"clue.flow.kind": "tool_call",
|
|
733
|
+
"clue.tool.provider": self.tool_provider,
|
|
734
|
+
"clue.tool.name": self.tool_name,
|
|
735
|
+
"clue.tool.schema_hash": self.tool_schema_hash,
|
|
736
|
+
},
|
|
737
|
+
expected_kind="INTERNAL",
|
|
738
|
+
)
|
|
739
|
+
add_event(
|
|
740
|
+
build_tool_call_event(
|
|
741
|
+
context=context,
|
|
742
|
+
status=BACKEND_STATUS_STARTED,
|
|
743
|
+
tool_provider=self.tool_provider,
|
|
744
|
+
tool_name=self.tool_name,
|
|
745
|
+
tool_call_id=self.tool_call_id,
|
|
746
|
+
tool_schema_hash=self.tool_schema_hash,
|
|
747
|
+
validation_field_paths=self.validation_field_paths,
|
|
748
|
+
span_id=self._span_id,
|
|
749
|
+
parent_span_id=self._parent_span_id,
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
return self
|
|
753
|
+
|
|
754
|
+
def __exit__(self, exc_type, _exc_value, _traceback) -> bool:
|
|
755
|
+
context = get_current_context()
|
|
756
|
+
if context is None:
|
|
757
|
+
return False
|
|
758
|
+
started_at = (
|
|
759
|
+
self._started_at if self._started_at is not None else time.perf_counter()
|
|
760
|
+
)
|
|
761
|
+
duration_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
|
762
|
+
add_event(
|
|
763
|
+
build_tool_call_event(
|
|
764
|
+
context=context,
|
|
765
|
+
status=BACKEND_STATUS_FAILED if exc_type is not None else BACKEND_STATUS_FINISHED,
|
|
766
|
+
tool_provider=self.tool_provider,
|
|
767
|
+
tool_name=self.tool_name,
|
|
768
|
+
tool_call_id=self.tool_call_id,
|
|
769
|
+
tool_schema_hash=self.tool_schema_hash,
|
|
770
|
+
validation_field_paths=self.validation_field_paths,
|
|
771
|
+
duration_ms=duration_ms,
|
|
772
|
+
failure_type="exception" if exc_type is not None else None,
|
|
773
|
+
span_id=self._span_id,
|
|
774
|
+
parent_span_id=self._parent_span_id,
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
return False
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class ClueStateTransition(ContextDecorator):
|
|
781
|
+
def __init__(
|
|
782
|
+
self,
|
|
783
|
+
transition_key: str,
|
|
784
|
+
*,
|
|
785
|
+
from_state: str,
|
|
786
|
+
to_state: str,
|
|
787
|
+
transition_axis: str | None = None,
|
|
788
|
+
entity_id: object | None = None,
|
|
789
|
+
) -> None:
|
|
790
|
+
self.transition_key = _require_non_empty(transition_key, "transition_key")
|
|
791
|
+
self.from_state = _require_non_empty(from_state, "from_state")
|
|
792
|
+
self.to_state = _require_non_empty(to_state, "to_state")
|
|
793
|
+
self.transition_axis = (
|
|
794
|
+
transition_axis.strip()
|
|
795
|
+
if transition_axis and transition_axis.strip()
|
|
796
|
+
else None
|
|
797
|
+
)
|
|
798
|
+
self.entity_id = entity_id
|
|
799
|
+
self._started_at: float | None = None
|
|
800
|
+
self._span_id: str | None = None
|
|
801
|
+
self._parent_span_id: str | None = None
|
|
802
|
+
|
|
803
|
+
def __enter__(self):
|
|
804
|
+
context = get_current_context()
|
|
805
|
+
if context is None:
|
|
806
|
+
return self
|
|
807
|
+
self._started_at = time.perf_counter()
|
|
808
|
+
otel_span = resolve_current_otel_span_context("INTERNAL")
|
|
809
|
+
self._span_id = (
|
|
810
|
+
otel_span["span_id"]
|
|
811
|
+
if otel_span is not None and otel_span.get("span_id")
|
|
812
|
+
else create_span_id()
|
|
813
|
+
)
|
|
814
|
+
self._parent_span_id = (
|
|
815
|
+
otel_span.get("parent_span_id") or None if otel_span is not None else None
|
|
816
|
+
)
|
|
817
|
+
annotate_current_otel_span(
|
|
818
|
+
{
|
|
819
|
+
"clue.flow.kind": "state_transition",
|
|
820
|
+
"clue.transition.key": self.transition_key,
|
|
821
|
+
"clue.transition.from_state": self.from_state,
|
|
822
|
+
"clue.transition.to_state": self.to_state,
|
|
823
|
+
"clue.transition.axis": self.transition_axis,
|
|
824
|
+
},
|
|
825
|
+
expected_kind="INTERNAL",
|
|
826
|
+
)
|
|
827
|
+
add_event(
|
|
828
|
+
build_state_transition_event(
|
|
829
|
+
context=context,
|
|
830
|
+
status=BACKEND_STATUS_STARTED,
|
|
831
|
+
transition_key=self.transition_key,
|
|
832
|
+
from_state=self.from_state,
|
|
833
|
+
to_state=self.to_state,
|
|
834
|
+
transition_axis=self.transition_axis,
|
|
835
|
+
entity_id=self.entity_id,
|
|
836
|
+
span_id=self._span_id,
|
|
837
|
+
parent_span_id=self._parent_span_id,
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
return self
|
|
841
|
+
|
|
842
|
+
def __exit__(self, exc_type, _exc_value, _traceback) -> bool:
|
|
843
|
+
context = get_current_context()
|
|
844
|
+
if context is None:
|
|
845
|
+
return False
|
|
846
|
+
started_at = (
|
|
847
|
+
self._started_at if self._started_at is not None else time.perf_counter()
|
|
848
|
+
)
|
|
849
|
+
duration_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
|
850
|
+
add_event(
|
|
851
|
+
build_state_transition_event(
|
|
852
|
+
context=context,
|
|
853
|
+
status=BACKEND_STATUS_FAILED if exc_type is not None else BACKEND_STATUS_FINISHED,
|
|
854
|
+
transition_key=self.transition_key,
|
|
855
|
+
from_state=self.from_state,
|
|
856
|
+
to_state=self.to_state,
|
|
857
|
+
transition_axis=self.transition_axis,
|
|
858
|
+
entity_id=self.entity_id,
|
|
859
|
+
duration_ms=duration_ms,
|
|
860
|
+
span_id=self._span_id,
|
|
861
|
+
parent_span_id=self._parent_span_id,
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
return False
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def flush_client(client: CluePythonClient) -> dict[str, JsonValue]:
|
|
868
|
+
try:
|
|
869
|
+
return client.flush()
|
|
870
|
+
except Exception:
|
|
871
|
+
return {}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def build_internal_diagnostic_event(
|
|
875
|
+
settings: CluePythonSettings,
|
|
876
|
+
) -> dict[str, JsonValue] | None:
|
|
877
|
+
client = get_current_client()
|
|
878
|
+
context = get_current_context()
|
|
879
|
+
if client is None or context is None:
|
|
880
|
+
return None
|
|
881
|
+
return build_sdk_diagnostic_event(
|
|
882
|
+
context=context,
|
|
883
|
+
metrics=client.internal_metrics(),
|
|
884
|
+
instrumentation_active={
|
|
885
|
+
"requests": settings.capture_outbound_requests,
|
|
886
|
+
"celery": settings.capture_celery,
|
|
887
|
+
"orm": settings.capture_orm,
|
|
888
|
+
},
|
|
889
|
+
last_flush_status="pending",
|
|
890
|
+
denied_keys=settings.denied_keys,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
atexit.register(flush_shared_clients)
|