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,335 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import uuid
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from urllib.request import Request, urlopen
9
+
10
+ from .adapters import JsonValue, build_backend_envelope
11
+ from .contracts import EVENT_CATEGORY_OUTBOUND_REQUEST, EVENT_CATEGORY_REQUEST
12
+ from .event_size import DEFAULT_MAX_EVENT_BYTES, normalize_event_for_size
13
+
14
+ DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024
15
+ DEFAULT_FLUSH_THRESHOLD_BYTES = 220 * 1024
16
+ DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024
17
+ DEFAULT_MAX_BUFFER_EVENTS = 10_000
18
+ DEFAULT_FLUSH_INTERVAL_MS = 180_000
19
+
20
+
21
+ def _json_bytes(value: Mapping[str, JsonValue]) -> bytes:
22
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class _BatchMetadata:
27
+ batch_id: str
28
+ idempotency_key: str
29
+ sent_at: str
30
+
31
+
32
+ def _backend_ingest_url(endpoint: str) -> str:
33
+ if endpoint.endswith("/ingest/backend"):
34
+ return endpoint
35
+ if endpoint.endswith("/api/v1"):
36
+ return f"{endpoint}/ingest/backend"
37
+ return f"{endpoint}/api/v1/ingest/backend"
38
+
39
+
40
+ class CluePythonClient:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ endpoint: str,
45
+ project_key: str,
46
+ environment: str,
47
+ producer_id: str = "python-service",
48
+ api_key: str | None = None,
49
+ timeout_seconds: float = 5.0,
50
+ max_event_bytes: int | None = DEFAULT_MAX_EVENT_BYTES,
51
+ max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES,
52
+ flush_threshold_bytes: int = DEFAULT_FLUSH_THRESHOLD_BYTES,
53
+ max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES,
54
+ max_buffer_events: int = DEFAULT_MAX_BUFFER_EVENTS,
55
+ flush_interval_ms: int = DEFAULT_FLUSH_INTERVAL_MS,
56
+ auto_flush: bool = False,
57
+ opener: Callable[..., object] | None = None,
58
+ ) -> None:
59
+ self._endpoint = endpoint.rstrip("/")
60
+ self._project_key = project_key
61
+ self._environment = environment
62
+ self._producer_id = producer_id
63
+ self._api_key = api_key
64
+ self._timeout_seconds = timeout_seconds
65
+ self._max_event_bytes = max_event_bytes
66
+ self._max_payload_bytes = max(1024, max_payload_bytes)
67
+ self._flush_threshold_bytes = max(1024, min(flush_threshold_bytes, self._max_payload_bytes))
68
+ self._max_buffer_bytes = max(1024, max_buffer_bytes)
69
+ self._max_buffer_events = max(1, max_buffer_events)
70
+ self._flush_interval_ms = max(1, flush_interval_ms)
71
+ self._auto_flush = auto_flush
72
+ self._opener = opener or urlopen
73
+ self._buffer: list[Mapping[str, JsonValue]] = []
74
+ self._buffer_bytes = 0
75
+ self._failed_batches: list[tuple[_BatchMetadata, list[Mapping[str, JsonValue]]]] = []
76
+ self._idle_timer: threading.Timer | None = None
77
+ self._lock = threading.RLock()
78
+ self._dropped_oversized_count = 0
79
+ self._parameter_snapshot_oversized_dropped = 0
80
+ self._dropped_buffer_full_count = 0
81
+ self._events_summarized_count = 0
82
+ self._sampling_dropped_count = 0
83
+ self._flush_failed_count = 0
84
+ self._flush_retry_count = 0
85
+ self._last_flush_status = "skipped"
86
+
87
+ def add_event(self, event: Mapping[str, JsonValue]) -> bool:
88
+ normalized_event = normalize_event_for_size(event, self._max_event_bytes)
89
+ if normalized_event is None:
90
+ self._dropped_oversized_count += 1
91
+ if event.get("event_category") in {
92
+ EVENT_CATEGORY_REQUEST,
93
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
94
+ }:
95
+ self._parameter_snapshot_oversized_dropped += 1
96
+ return False
97
+
98
+ event_bytes = self._measure_event_bytes(normalized_event)
99
+ with self._lock:
100
+ while self._buffer and (
101
+ len(self._buffer) >= self._max_buffer_events
102
+ or self._buffer_bytes + event_bytes > self._max_buffer_bytes
103
+ ):
104
+ dropped = self._buffer.pop(0)
105
+ self._buffer_bytes = max(
106
+ 0,
107
+ self._buffer_bytes - self._measure_event_bytes(dropped),
108
+ )
109
+ self._dropped_buffer_full_count += 1
110
+
111
+ if (
112
+ len(self._buffer) >= self._max_buffer_events
113
+ or self._buffer_bytes + event_bytes > self._max_buffer_bytes
114
+ ):
115
+ self._dropped_buffer_full_count += 1
116
+ return False
117
+
118
+ self._buffer.append(normalized_event)
119
+ self._buffer_bytes += event_bytes
120
+
121
+ if self._auto_flush and self._buffer_bytes >= self._flush_threshold_bytes:
122
+ self._cancel_idle_timer()
123
+ should_flush = True
124
+ else:
125
+ self._schedule_idle_flush_locked()
126
+ should_flush = False
127
+
128
+ if should_flush:
129
+ try:
130
+ self.flush()
131
+ except Exception:
132
+ return True
133
+ return True
134
+
135
+ def record_events_summarized(self, count: int = 1) -> None:
136
+ self._events_summarized_count += max(0, int(count))
137
+
138
+ def record_sampling_dropped(self, count: int = 1) -> None:
139
+ self._sampling_dropped_count += max(0, int(count))
140
+
141
+ def build_envelope(
142
+ self,
143
+ events: Sequence[Mapping[str, JsonValue]] | None = None,
144
+ metadata: _BatchMetadata | None = None,
145
+ ) -> dict[str, JsonValue]:
146
+ resolved_metadata = metadata or self._create_batch_metadata()
147
+ return build_backend_envelope(
148
+ project_key=self._project_key,
149
+ environment=self._environment,
150
+ producer_id=self._producer_id,
151
+ api_key=self._api_key,
152
+ batch_id=resolved_metadata.batch_id,
153
+ idempotency_key=resolved_metadata.idempotency_key,
154
+ sent_at=resolved_metadata.sent_at,
155
+ events=events if events is not None else self.pending_events(),
156
+ )
157
+
158
+ def flush(self, timeout_seconds: float | None = None) -> dict[str, JsonValue]:
159
+ self._cancel_idle_timer()
160
+ result: dict[str, JsonValue] = {}
161
+ while True:
162
+ batch = self._next_batch()
163
+ if batch is None:
164
+ return result
165
+ metadata, events = batch
166
+ envelope = self.build_envelope(events=events, metadata=metadata)
167
+ result = self._send_envelope(
168
+ envelope,
169
+ timeout_seconds=timeout_seconds or self._timeout_seconds,
170
+ on_failure=lambda: self._restore_failed_batch(metadata, events),
171
+ )
172
+
173
+ def _send_envelope(
174
+ self,
175
+ envelope: Mapping[str, JsonValue],
176
+ *,
177
+ timeout_seconds: float,
178
+ on_failure: Callable[[], None],
179
+ ) -> dict[str, JsonValue]:
180
+ request = Request(
181
+ url=_backend_ingest_url(self._endpoint),
182
+ data=_json_bytes(envelope),
183
+ headers={
184
+ "content-type": "application/json",
185
+ "x-clue-project-key": self._project_key,
186
+ **({"x-clue-api-key": self._api_key} if self._api_key else {}),
187
+ },
188
+ method="POST",
189
+ )
190
+
191
+ try:
192
+ response = self._opener(request, timeout=timeout_seconds)
193
+ body = response.read()
194
+ except Exception:
195
+ self._flush_failed_count += 1
196
+ self._last_flush_status = "failed"
197
+ on_failure()
198
+ raise
199
+ self._last_flush_status = "success"
200
+ if not body:
201
+ return {}
202
+ return json.loads(body.decode("utf-8"))
203
+
204
+ def pending_events(self) -> Sequence[Mapping[str, JsonValue]]:
205
+ with self._lock:
206
+ failed_events = [
207
+ event
208
+ for _metadata, events in self._failed_batches
209
+ for event in events
210
+ ]
211
+ return tuple([*failed_events, *self._buffer])
212
+
213
+ def drain_events(self) -> Sequence[Mapping[str, JsonValue]]:
214
+ with self._lock:
215
+ events = tuple(self._buffer)
216
+ self._buffer.clear()
217
+ self._buffer_bytes = 0
218
+ self._cancel_idle_timer()
219
+ return events
220
+
221
+ def close(self) -> None:
222
+ self._cancel_idle_timer()
223
+
224
+ def internal_metrics(self) -> dict[str, JsonValue]:
225
+ queue_size = len(self.pending_events())
226
+ return {
227
+ "events_dropped_count": (
228
+ self._dropped_oversized_count + self._dropped_buffer_full_count
229
+ ),
230
+ "events_summarized_count": self._events_summarized_count,
231
+ "sampling_dropped_count": self._sampling_dropped_count,
232
+ "oversized_event_count": self._dropped_oversized_count,
233
+ "snapshot_truncated_count": self._parameter_snapshot_oversized_dropped,
234
+ "flush_failed_count": self._flush_failed_count,
235
+ "flush_retry_count": self._flush_retry_count,
236
+ "queue_size": queue_size,
237
+ "queue_bytes": self._buffer_bytes,
238
+ "max_payload_bytes": self._max_payload_bytes,
239
+ "flush_threshold_bytes": self._flush_threshold_bytes,
240
+ "last_flush_status": self._last_flush_status,
241
+ "dropped_oversized_count": self._dropped_oversized_count,
242
+ "parameter_snapshot_oversized_dropped": self._parameter_snapshot_oversized_dropped,
243
+ "dropped_buffer_full_count": self._dropped_buffer_full_count,
244
+ "flush_interval_ms": self._flush_interval_ms,
245
+ }
246
+
247
+ def _create_batch_metadata(self) -> _BatchMetadata:
248
+ batch_id = str(uuid.uuid4())
249
+ return _BatchMetadata(
250
+ batch_id=f"batch_{batch_id}",
251
+ idempotency_key=f"idem_{batch_id}",
252
+ sent_at=self._now_iso(),
253
+ )
254
+
255
+ def _next_batch(self) -> tuple[_BatchMetadata, list[Mapping[str, JsonValue]]] | None:
256
+ with self._lock:
257
+ if self._failed_batches:
258
+ return self._failed_batches.pop(0)
259
+ if not self._buffer:
260
+ return None
261
+
262
+ metadata = self._create_batch_metadata()
263
+ batch: list[Mapping[str, JsonValue]] = []
264
+ while self._buffer:
265
+ candidate = [*batch, self._buffer[0]]
266
+ if batch and self._measure_envelope_bytes(candidate, metadata) > self._max_payload_bytes:
267
+ break
268
+ event = self._buffer.pop(0)
269
+ self._buffer_bytes = max(
270
+ 0,
271
+ self._buffer_bytes - self._measure_event_bytes(event),
272
+ )
273
+ batch.append(event)
274
+
275
+ if not batch:
276
+ event = self._buffer.pop(0)
277
+ self._buffer_bytes = max(
278
+ 0,
279
+ self._buffer_bytes - self._measure_event_bytes(event),
280
+ )
281
+ self._dropped_oversized_count += 1
282
+ return None
283
+ return metadata, batch
284
+
285
+ def _restore_failed_batch(
286
+ self,
287
+ metadata: _BatchMetadata,
288
+ events: list[Mapping[str, JsonValue]],
289
+ ) -> None:
290
+ with self._lock:
291
+ self._failed_batches.insert(0, (metadata, events))
292
+ if self._auto_flush:
293
+ self._schedule_idle_flush_locked()
294
+
295
+ def _schedule_idle_flush_locked(self) -> None:
296
+ if not self._auto_flush:
297
+ return
298
+ if self._idle_timer is not None:
299
+ self._idle_timer.cancel()
300
+ self._idle_timer = threading.Timer(
301
+ self._flush_interval_ms / 1000,
302
+ self._flush_from_timer,
303
+ )
304
+ self._idle_timer.daemon = True
305
+ self._idle_timer.start()
306
+
307
+ def _cancel_idle_timer(self) -> None:
308
+ if self._idle_timer is not None:
309
+ self._idle_timer.cancel()
310
+ self._idle_timer = None
311
+
312
+ def _flush_from_timer(self) -> None:
313
+ with self._lock:
314
+ self._idle_timer = None
315
+ try:
316
+ self.flush()
317
+ except Exception:
318
+ return
319
+
320
+ def _measure_event_bytes(self, event: Mapping[str, JsonValue]) -> int:
321
+ return len(_json_bytes(event))
322
+
323
+ def _measure_envelope_bytes(
324
+ self,
325
+ events: Sequence[Mapping[str, JsonValue]],
326
+ metadata: _BatchMetadata,
327
+ ) -> int:
328
+ return len(
329
+ _json_bytes(self.build_envelope(events=events, metadata=metadata))
330
+ )
331
+
332
+ def _now_iso(self) -> str:
333
+ from datetime import datetime, timezone
334
+
335
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ BACKEND_STATUS_OBSERVED = "observed"
4
+ BACKEND_STATUS_STARTED = "started"
5
+ BACKEND_STATUS_FINISHED = "finished"
6
+ BACKEND_STATUS_FAILED = "failed"
7
+ BACKEND_STATUSES = (
8
+ BACKEND_STATUS_OBSERVED,
9
+ BACKEND_STATUS_STARTED,
10
+ BACKEND_STATUS_FINISHED,
11
+ BACKEND_STATUS_FAILED,
12
+ )
13
+
14
+ BACKEND_SDK_TYPE = "backend"
15
+ BACKEND_SDK_VERSION = "0.0.1"
16
+ BACKEND_SDK_SCHEMA_VERSION = 1
17
+ BACKEND_RUNTIME_LANGUAGE = "python"
18
+
19
+ SDK_COLLECTION_MODE_STANDARD = "standard"
20
+ SDK_COLLECTION_MODE_DIAGNOSTIC = "diagnostic"
21
+ SDK_COLLECTION_MODES = (
22
+ SDK_COLLECTION_MODE_STANDARD,
23
+ SDK_COLLECTION_MODE_DIAGNOSTIC,
24
+ )
25
+
26
+ EVENT_CATEGORY_REQUEST = "request"
27
+ EVENT_CATEGORY_OUTBOUND_REQUEST = "outbound_request"
28
+ EVENT_CATEGORY_ERROR = "error"
29
+ EVENT_CATEGORY_JOB = "job"
30
+ EVENT_CATEGORY_TOOL_CALL = "tool_call"
31
+ EVENT_CATEGORY_DOMAIN_COMMAND = "domain_command"
32
+ EVENT_CATEGORY_REPOSITORY_MUTATION = "repository_mutation"
33
+ EVENT_CATEGORY_STATE_TRANSITION = "state_transition"
34
+ EVENT_CATEGORY_CUSTOM = "custom"
35
+ BACKEND_EVENT_CATEGORIES = (
36
+ EVENT_CATEGORY_REQUEST,
37
+ EVENT_CATEGORY_ERROR,
38
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
39
+ EVENT_CATEGORY_JOB,
40
+ EVENT_CATEGORY_TOOL_CALL,
41
+ EVENT_CATEGORY_DOMAIN_COMMAND,
42
+ EVENT_CATEGORY_REPOSITORY_MUTATION,
43
+ EVENT_CATEGORY_STATE_TRANSITION,
44
+ EVENT_CATEGORY_CUSTOM,
45
+ )
46
+ PYTHON_RUNTIME_EVENT_CATEGORIES = (
47
+ EVENT_CATEGORY_REQUEST,
48
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
49
+ EVENT_CATEGORY_ERROR,
50
+ EVENT_CATEGORY_JOB,
51
+ EVENT_CATEGORY_TOOL_CALL,
52
+ EVENT_CATEGORY_DOMAIN_COMMAND,
53
+ EVENT_CATEGORY_REPOSITORY_MUTATION,
54
+ EVENT_CATEGORY_STATE_TRANSITION,
55
+ EVENT_CATEGORY_CUSTOM,
56
+ )
57
+
58
+ REQUEST_EVENT_STARTED = "request_started"
59
+ REQUEST_EVENT_FINISHED = "request_finished"
60
+ REQUEST_EVENT_FAILED = "request_failed"
61
+
62
+ OUTBOUND_REQUEST_EVENT_STARTED = "outbound_request_started"
63
+ OUTBOUND_REQUEST_EVENT_FINISHED = "outbound_request_finished"
64
+ OUTBOUND_REQUEST_EVENT_FAILED = "outbound_request_failed"
65
+
66
+ JOB_EVENT_STARTED = "job_started"
67
+ JOB_EVENT_FINISHED = "job_finished"
68
+ JOB_EVENT_FAILED = "job_failed"
69
+
70
+ TOOL_CALL_EVENT_STARTED = "tool_call_started"
71
+ TOOL_CALL_EVENT_FINISHED = "tool_call_finished"
72
+ TOOL_CALL_EVENT_FAILED = "tool_call_failed"
73
+
74
+ DOMAIN_COMMAND_EVENT_STARTED = "domain_command_started"
75
+ DOMAIN_COMMAND_EVENT_FINISHED = "domain_command_finished"
76
+ DOMAIN_COMMAND_EVENT_FAILED = "domain_command_failed"
77
+
78
+ REPOSITORY_MUTATION_EVENT_STARTED = "repository_mutation_started"
79
+ REPOSITORY_MUTATION_EVENT_FINISHED = "repository_mutation_finished"
80
+ REPOSITORY_MUTATION_EVENT_FAILED = "repository_mutation_failed"
81
+
82
+ STATE_TRANSITION_EVENT_STARTED = "state_transition_started"
83
+ STATE_TRANSITION_EVENT_FINISHED = "state_transition_finished"
84
+ STATE_TRANSITION_EVENT_FAILED = "state_transition_failed"
85
+
86
+ BACKEND_ERROR_EVENT = "backend_error"
87
+ CUSTOM_EVENT_EMITTED = "custom_emitted"
88
+ REPOSITORY_MUTATION_SUMMARY_EVENT = "repository_mutation_summary"
89
+ QUEUE_LIFECYCLE_SUMMARY_EVENT = "queue_lifecycle_summary"
90
+ DEPENDENCY_SUMMARY_EVENT = "dependency_summary"
91
+ CACHE_OPERATION_SUMMARY_EVENT = "cache_operation_summary"
92
+ APPLICATION_LOG_SUMMARY_EVENT = "application_log_summary"
93
+ SDK_DIAGNOSTIC_EVENT = "sdk_diagnostic_observed"
94
+ CUSTOM_EVENT_SDK_INITIALIZED = "sdk_initialized"
95
+ CUSTOM_EVENT_IDENTITY_IDENTIFIED = "identity_identified"
96
+ CUSTOM_EVENT_ACCOUNT_ASSOCIATED = "account_associated"
97
+ CUSTOM_EVENT_IDENTITY_LOGGED_OUT = "identity_logged_out"
98
+ BACKEND_OFFICIAL_CUSTOM_EVENT_NAMES = (
99
+ CUSTOM_EVENT_SDK_INITIALIZED,
100
+ CUSTOM_EVENT_IDENTITY_IDENTIFIED,
101
+ CUSTOM_EVENT_ACCOUNT_ASSOCIATED,
102
+ CUSTOM_EVENT_IDENTITY_LOGGED_OUT,
103
+ )
104
+
105
+ JOB_EVENT_BY_STATUS = {
106
+ BACKEND_STATUS_STARTED: JOB_EVENT_STARTED,
107
+ BACKEND_STATUS_FINISHED: JOB_EVENT_FINISHED,
108
+ BACKEND_STATUS_FAILED: JOB_EVENT_FAILED,
109
+ }
110
+
111
+ TOOL_CALL_EVENT_BY_STATUS = {
112
+ BACKEND_STATUS_STARTED: TOOL_CALL_EVENT_STARTED,
113
+ BACKEND_STATUS_FINISHED: TOOL_CALL_EVENT_FINISHED,
114
+ BACKEND_STATUS_FAILED: TOOL_CALL_EVENT_FAILED,
115
+ }
116
+
117
+ DOMAIN_COMMAND_EVENT_BY_STATUS = {
118
+ BACKEND_STATUS_STARTED: DOMAIN_COMMAND_EVENT_STARTED,
119
+ BACKEND_STATUS_FINISHED: DOMAIN_COMMAND_EVENT_FINISHED,
120
+ BACKEND_STATUS_FAILED: DOMAIN_COMMAND_EVENT_FAILED,
121
+ }
122
+
123
+ REPOSITORY_MUTATION_EVENT_BY_STATUS = {
124
+ BACKEND_STATUS_STARTED: REPOSITORY_MUTATION_EVENT_STARTED,
125
+ BACKEND_STATUS_FINISHED: REPOSITORY_MUTATION_EVENT_FINISHED,
126
+ BACKEND_STATUS_FAILED: REPOSITORY_MUTATION_EVENT_FAILED,
127
+ }
128
+
129
+ STATE_TRANSITION_EVENT_BY_STATUS = {
130
+ BACKEND_STATUS_STARTED: STATE_TRANSITION_EVENT_STARTED,
131
+ BACKEND_STATUS_FINISHED: STATE_TRANSITION_EVENT_FINISHED,
132
+ BACKEND_STATUS_FAILED: STATE_TRANSITION_EVENT_FAILED,
133
+ }
134
+
135
+ PYTHON_RUNTIME_EVENT_NAMES = (
136
+ REQUEST_EVENT_STARTED,
137
+ REQUEST_EVENT_FINISHED,
138
+ REQUEST_EVENT_FAILED,
139
+ OUTBOUND_REQUEST_EVENT_STARTED,
140
+ OUTBOUND_REQUEST_EVENT_FINISHED,
141
+ OUTBOUND_REQUEST_EVENT_FAILED,
142
+ JOB_EVENT_STARTED,
143
+ JOB_EVENT_FINISHED,
144
+ JOB_EVENT_FAILED,
145
+ TOOL_CALL_EVENT_STARTED,
146
+ TOOL_CALL_EVENT_FINISHED,
147
+ TOOL_CALL_EVENT_FAILED,
148
+ DOMAIN_COMMAND_EVENT_STARTED,
149
+ DOMAIN_COMMAND_EVENT_FINISHED,
150
+ DOMAIN_COMMAND_EVENT_FAILED,
151
+ REPOSITORY_MUTATION_EVENT_STARTED,
152
+ REPOSITORY_MUTATION_EVENT_FINISHED,
153
+ REPOSITORY_MUTATION_EVENT_FAILED,
154
+ STATE_TRANSITION_EVENT_STARTED,
155
+ STATE_TRANSITION_EVENT_FINISHED,
156
+ STATE_TRANSITION_EVENT_FAILED,
157
+ BACKEND_ERROR_EVENT,
158
+ CUSTOM_EVENT_EMITTED,
159
+ )
160
+ BACKEND_EVENT_NAMES = (
161
+ REQUEST_EVENT_STARTED,
162
+ REQUEST_EVENT_FINISHED,
163
+ REQUEST_EVENT_FAILED,
164
+ OUTBOUND_REQUEST_EVENT_STARTED,
165
+ OUTBOUND_REQUEST_EVENT_FINISHED,
166
+ OUTBOUND_REQUEST_EVENT_FAILED,
167
+ JOB_EVENT_STARTED,
168
+ JOB_EVENT_FINISHED,
169
+ JOB_EVENT_FAILED,
170
+ TOOL_CALL_EVENT_STARTED,
171
+ TOOL_CALL_EVENT_FINISHED,
172
+ TOOL_CALL_EVENT_FAILED,
173
+ DOMAIN_COMMAND_EVENT_STARTED,
174
+ DOMAIN_COMMAND_EVENT_FINISHED,
175
+ DOMAIN_COMMAND_EVENT_FAILED,
176
+ REPOSITORY_MUTATION_EVENT_STARTED,
177
+ REPOSITORY_MUTATION_EVENT_FINISHED,
178
+ REPOSITORY_MUTATION_EVENT_FAILED,
179
+ STATE_TRANSITION_EVENT_STARTED,
180
+ STATE_TRANSITION_EVENT_FINISHED,
181
+ STATE_TRANSITION_EVENT_FAILED,
182
+ BACKEND_ERROR_EVENT,
183
+ REPOSITORY_MUTATION_SUMMARY_EVENT,
184
+ QUEUE_LIFECYCLE_SUMMARY_EVENT,
185
+ DEPENDENCY_SUMMARY_EVENT,
186
+ CACHE_OPERATION_SUMMARY_EVENT,
187
+ APPLICATION_LOG_SUMMARY_EVENT,
188
+ SDK_DIAGNOSTIC_EVENT,
189
+ CUSTOM_EVENT_EMITTED,
190
+ )
191
+
192
+ SURFACE_TYPE_API = "api"
193
+ SURFACE_TYPE_SERVICE = "service"
194
+ SURFACE_TYPE_JOB = "job"
195
+ BACKEND_SURFACE_TYPES = (
196
+ SURFACE_TYPE_API,
197
+ SURFACE_TYPE_SERVICE,
198
+ SURFACE_TYPE_JOB,
199
+ )
200
+
201
+ CAPTURE_MODE_ALLOWED_PLAINTEXT = "allowed_plaintext"
202
+ CAPTURE_MODE_MASKED_FINGERPRINT = "masked_fingerprint"
203
+ CAPTURE_MODE_FORBIDDEN = "forbidden"
204
+ CAPTURE_MODE_TRUNCATED = "truncated"
205
+ BACKEND_CAPTURE_MODES = (
206
+ CAPTURE_MODE_ALLOWED_PLAINTEXT,
207
+ CAPTURE_MODE_MASKED_FINGERPRINT,
208
+ CAPTURE_MODE_FORBIDDEN,
209
+ CAPTURE_MODE_TRUNCATED,
210
+ )
211
+
212
+ BACKEND_LIFECYCLE_MARKERS = (
213
+ "start",
214
+ "finish",
215
+ "fail",
216
+ )
217
+
218
+ BACKEND_MUTATION_KINDS = (
219
+ "create",
220
+ "update",
221
+ "delete",
222
+ "upsert",
223
+ "bulk_write",
224
+ )
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+
6
+ from .contracts import (
7
+ CAPTURE_MODE_TRUNCATED,
8
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
9
+ EVENT_CATEGORY_REQUEST,
10
+ )
11
+ from .privacy import JsonValue
12
+
13
+ DEFAULT_MAX_EVENT_BYTES = 128 * 1024
14
+ TRUNCATED_PARAMETER_VALUE = "__truncated__"
15
+ PARAMETER_SNAPSHOT_FIELDS = (
16
+ "request_parameters_snapshot_json",
17
+ "response_parameters_snapshot_json",
18
+ )
19
+
20
+
21
+ def measure_event_bytes(event: Mapping[str, JsonValue]) -> int:
22
+ return len(
23
+ json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
24
+ )
25
+
26
+
27
+ def _is_parameter_leaf(value: JsonValue) -> bool:
28
+ return (
29
+ isinstance(value, dict)
30
+ and isinstance(value.get("type"), str)
31
+ and isinstance(value.get("capture_mode"), str)
32
+ and "value" in value
33
+ )
34
+
35
+
36
+ def _truncate_parameter_snapshot(value: JsonValue) -> JsonValue:
37
+ if isinstance(value, list):
38
+ return [_truncate_parameter_snapshot(entry) for entry in value]
39
+
40
+ if _is_parameter_leaf(value):
41
+ return {
42
+ "type": value.get("type"),
43
+ "value": TRUNCATED_PARAMETER_VALUE,
44
+ "capture_mode": CAPTURE_MODE_TRUNCATED,
45
+ }
46
+
47
+ if isinstance(value, dict):
48
+ return {
49
+ str(key): _truncate_parameter_snapshot(child)
50
+ for key, child in value.items()
51
+ }
52
+
53
+ return value
54
+
55
+
56
+ def _is_parameter_snapshot_event(event: Mapping[str, JsonValue]) -> bool:
57
+ return event.get("event_category") in {
58
+ EVENT_CATEGORY_REQUEST,
59
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
60
+ }
61
+
62
+
63
+ def normalize_event_for_size(
64
+ event: Mapping[str, JsonValue],
65
+ max_event_bytes: int | None,
66
+ ) -> dict[str, JsonValue] | None:
67
+ normalized = dict(event)
68
+ if max_event_bytes is None or max_event_bytes <= 0:
69
+ return normalized
70
+
71
+ if measure_event_bytes(normalized) <= max_event_bytes:
72
+ return normalized
73
+
74
+ if not _is_parameter_snapshot_event(normalized):
75
+ return None
76
+
77
+ has_snapshot = any(field in normalized for field in PARAMETER_SNAPSHOT_FIELDS)
78
+ if not has_snapshot:
79
+ return None
80
+
81
+ truncated = dict(normalized)
82
+ for field in PARAMETER_SNAPSHOT_FIELDS:
83
+ value = truncated.get(field)
84
+ if value is not None:
85
+ truncated[field] = _truncate_parameter_snapshot(value)
86
+
87
+ if isinstance(truncated.get("properties"), dict):
88
+ properties = dict(truncated["properties"])
89
+ properties["degradation_stage"] = "parameter_snapshot_truncated"
90
+ truncated["properties"] = properties
91
+ else:
92
+ truncated["properties"] = {
93
+ "degradation_stage": "parameter_snapshot_truncated",
94
+ }
95
+
96
+ return truncated if measure_event_bytes(truncated) <= max_event_bytes else None