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.
@@ -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)