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,2177 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import uuid
7
+ from collections.abc import Mapping, Sequence
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from traceback import extract_tb
11
+
12
+ from .contracts import (
13
+ APPLICATION_LOG_SUMMARY_EVENT,
14
+ BACKEND_ERROR_EVENT,
15
+ BACKEND_MUTATION_KINDS,
16
+ BACKEND_RUNTIME_LANGUAGE,
17
+ BACKEND_SDK_SCHEMA_VERSION,
18
+ BACKEND_SDK_TYPE,
19
+ BACKEND_SDK_VERSION,
20
+ BACKEND_STATUS_FAILED,
21
+ BACKEND_STATUS_FINISHED,
22
+ BACKEND_STATUS_OBSERVED,
23
+ BACKEND_STATUS_STARTED,
24
+ CACHE_OPERATION_SUMMARY_EVENT,
25
+ CUSTOM_EVENT_ACCOUNT_ASSOCIATED,
26
+ CUSTOM_EVENT_EMITTED,
27
+ CUSTOM_EVENT_IDENTITY_IDENTIFIED,
28
+ CUSTOM_EVENT_IDENTITY_LOGGED_OUT,
29
+ DEPENDENCY_SUMMARY_EVENT,
30
+ DOMAIN_COMMAND_EVENT_BY_STATUS,
31
+ EVENT_CATEGORY_CUSTOM,
32
+ EVENT_CATEGORY_DOMAIN_COMMAND,
33
+ EVENT_CATEGORY_ERROR,
34
+ EVENT_CATEGORY_JOB,
35
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
36
+ EVENT_CATEGORY_REQUEST,
37
+ EVENT_CATEGORY_REPOSITORY_MUTATION,
38
+ EVENT_CATEGORY_STATE_TRANSITION,
39
+ EVENT_CATEGORY_TOOL_CALL,
40
+ JOB_EVENT_BY_STATUS,
41
+ OUTBOUND_REQUEST_EVENT_FAILED,
42
+ OUTBOUND_REQUEST_EVENT_FINISHED,
43
+ OUTBOUND_REQUEST_EVENT_STARTED,
44
+ QUEUE_LIFECYCLE_SUMMARY_EVENT,
45
+ REPOSITORY_MUTATION_EVENT_BY_STATUS,
46
+ REPOSITORY_MUTATION_SUMMARY_EVENT,
47
+ REQUEST_EVENT_FAILED,
48
+ REQUEST_EVENT_FINISHED,
49
+ REQUEST_EVENT_STARTED,
50
+ SDK_COLLECTION_MODE_STANDARD,
51
+ SDK_DIAGNOSTIC_EVENT,
52
+ STATE_TRANSITION_EVENT_BY_STATUS,
53
+ SURFACE_TYPE_API,
54
+ SURFACE_TYPE_JOB,
55
+ SURFACE_TYPE_SERVICE,
56
+ TOOL_CALL_EVENT_BY_STATUS,
57
+ )
58
+ from .http_extraction import (
59
+ _extract_query,
60
+ _extract_request_headers,
61
+ _extract_response_headers,
62
+ _normalize_headers,
63
+ _request_body,
64
+ _response_body,
65
+ _safe_urlsplit,
66
+ _to_json_value,
67
+ to_path_template,
68
+ )
69
+ from .otel_bridge import merge_current_otel_span_context
70
+ from .parameter_snapshot import (
71
+ _build_request_parameter_snapshot,
72
+ _build_response_parameter_snapshot,
73
+ )
74
+ from .privacy import JsonValue, build_mask_token, is_denied_key, safe_sanitize_object
75
+
76
+
77
+ def _string_or_none(value: object) -> str | None:
78
+ return value.strip() if isinstance(value, str) and value.strip() else None
79
+
80
+
81
+ MAX_SCHEMA_FIELD_PATHS = 100
82
+
83
+
84
+ def _normalize_schema_token(value: object) -> str | None:
85
+ if not isinstance(value, str):
86
+ return None
87
+ token = value.strip().lower()
88
+ if not token:
89
+ return None
90
+ normalized = "".join(
91
+ char if char.isalnum() or char in {"_", "-", ".", ":"} else "_"
92
+ for char in token
93
+ )
94
+ normalized = "_".join(part for part in normalized.split("_") if part)
95
+ if not normalized or len(normalized) > 96:
96
+ return None
97
+ return normalized
98
+
99
+
100
+ def _shape_type(value: object) -> str:
101
+ if value is None:
102
+ return "null"
103
+ if isinstance(value, bool):
104
+ return "boolean"
105
+ if isinstance(value, (int, float)):
106
+ return "number"
107
+ if isinstance(value, str):
108
+ return "string"
109
+ if isinstance(value, Mapping):
110
+ return "object"
111
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
112
+ return "array"
113
+ return type(value).__name__
114
+
115
+
116
+ def _schema_shape(
117
+ value: object,
118
+ *,
119
+ denied_keys: Sequence[str],
120
+ path: str,
121
+ field_paths: list[str],
122
+ depth: int = 0,
123
+ ) -> JsonValue:
124
+ if depth > 8:
125
+ return {"type": "max_depth"}
126
+
127
+ if isinstance(value, Mapping):
128
+ shape: dict[str, JsonValue] = {}
129
+ forbidden_count = 0
130
+ for key, child in sorted(value.items(), key=lambda entry: str(entry[0])):
131
+ key_text = str(key)
132
+ if is_denied_key(key_text, denied_keys):
133
+ forbidden_count += 1
134
+ continue
135
+ child_path = f"{path}.{key_text}" if path else key_text
136
+ if len(field_paths) < MAX_SCHEMA_FIELD_PATHS:
137
+ field_paths.append(child_path)
138
+ shape[key_text] = _schema_shape(
139
+ child,
140
+ denied_keys=denied_keys,
141
+ path=child_path,
142
+ field_paths=field_paths,
143
+ depth=depth + 1,
144
+ )
145
+ if forbidden_count > 0:
146
+ shape["__forbidden__"] = {
147
+ "type": "forbidden",
148
+ "count": forbidden_count,
149
+ }
150
+ return {"type": "object", "fields": shape}
151
+
152
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
153
+ items = list(value)
154
+ if not items:
155
+ return {"type": "array", "items": "empty"}
156
+ if len(field_paths) < MAX_SCHEMA_FIELD_PATHS and path:
157
+ field_paths.append(f"{path}[]")
158
+ return {
159
+ "type": "array",
160
+ "items": _schema_shape(
161
+ items[0],
162
+ denied_keys=denied_keys,
163
+ path=f"{path}[]",
164
+ field_paths=field_paths,
165
+ depth=depth + 1,
166
+ ),
167
+ }
168
+
169
+ return {"type": _shape_type(value)}
170
+
171
+
172
+ def _build_schema_evidence(
173
+ value: object | None,
174
+ *,
175
+ denied_keys: Sequence[str],
176
+ ) -> dict[str, JsonValue]:
177
+ if value is None:
178
+ return {
179
+ "schema_hash": None,
180
+ "field_paths": [],
181
+ "shape_size": 0,
182
+ }
183
+
184
+ field_paths: list[str] = []
185
+ shape = _schema_shape(
186
+ value,
187
+ denied_keys=denied_keys,
188
+ path="",
189
+ field_paths=field_paths,
190
+ )
191
+ shape_json = json.dumps(shape, sort_keys=True, separators=(",", ":"))
192
+ schema_hash = f"sha256:{hashlib.sha256(shape_json.encode('utf-8')).hexdigest()}"
193
+ return {
194
+ "schema_hash": schema_hash,
195
+ "field_paths": sorted(dict.fromkeys(field_paths)),
196
+ "shape_size": len(set(field_paths)),
197
+ }
198
+
199
+
200
+ def _count_capture_modes(value: JsonValue) -> dict[str, int]:
201
+ counts = {
202
+ "masked_value_count": 0,
203
+ "forbidden_value_count": 0,
204
+ "truncated_value_count": 0,
205
+ "allowed_value_count": 0,
206
+ }
207
+
208
+ def walk(current: JsonValue) -> None:
209
+ if isinstance(current, Mapping):
210
+ capture_mode = current.get("capture_mode")
211
+ if capture_mode == "masked_fingerprint":
212
+ counts["masked_value_count"] += 1
213
+ return
214
+ if capture_mode == "forbidden":
215
+ counts["forbidden_value_count"] += 1
216
+ return
217
+ if capture_mode == "truncated":
218
+ counts["truncated_value_count"] += 1
219
+ return
220
+ if capture_mode == "allowed_plaintext":
221
+ counts["allowed_value_count"] += 1
222
+ return
223
+ for child in current.values():
224
+ walk(child)
225
+ return
226
+ if isinstance(current, list):
227
+ for child in current:
228
+ walk(child)
229
+
230
+ walk(value)
231
+ return counts
232
+
233
+
234
+ def _bucket_remaining(value: object) -> str | None:
235
+ try:
236
+ remaining = int(str(value))
237
+ except (TypeError, ValueError):
238
+ return None
239
+ if remaining <= 0:
240
+ return "0"
241
+ if remaining <= 10:
242
+ return "1_10"
243
+ if remaining <= 100:
244
+ return "11_100"
245
+ if remaining <= 1000:
246
+ return "101_1000"
247
+ return "1000_plus"
248
+
249
+
250
+ def _bucket_reset_after_ms(headers: Mapping[str, JsonValue]) -> str | None:
251
+ retry_after = headers.get("retry-after") or headers.get("Retry-After")
252
+ if retry_after is None:
253
+ return None
254
+ try:
255
+ reset_after_ms = int(float(str(retry_after)) * 1000)
256
+ except (TypeError, ValueError):
257
+ return None
258
+ if reset_after_ms <= 1000:
259
+ return "lte_1s"
260
+ if reset_after_ms <= 10000:
261
+ return "lte_10s"
262
+ if reset_after_ms <= 60000:
263
+ return "lte_60s"
264
+ if reset_after_ms <= 300000:
265
+ return "lte_5m"
266
+ return "gt_5m"
267
+
268
+
269
+ def _extract_error_code_candidate(value: object | None) -> str | None:
270
+ if isinstance(value, Mapping):
271
+ for key in ("code", "error_code", "type", "error"):
272
+ candidate = _normalize_schema_token(value.get(key))
273
+ if candidate:
274
+ return candidate
275
+ errors = value.get("errors") or value.get("detail") or value.get("extensions")
276
+ if isinstance(errors, Sequence) and not isinstance(errors, (str, bytes, bytearray)):
277
+ for entry in errors:
278
+ candidate = _extract_error_code_candidate(entry)
279
+ if candidate:
280
+ return candidate
281
+ if isinstance(errors, Mapping):
282
+ return _extract_error_code_candidate(errors)
283
+ return _normalize_schema_token(value)
284
+
285
+
286
+ def _extract_validation_evidence(value: object | None) -> dict[str, JsonValue]:
287
+ field_paths: list[str] = []
288
+ reason_codes: list[str] = []
289
+ error_count = 0
290
+
291
+ def add_field_path(parts: object) -> None:
292
+ if isinstance(parts, Sequence) and not isinstance(parts, (str, bytes, bytearray)):
293
+ path = ".".join(str(part) for part in parts if str(part).strip())
294
+ else:
295
+ path = str(parts).strip()
296
+ if path and len(field_paths) < MAX_SCHEMA_FIELD_PATHS:
297
+ field_paths.append(path)
298
+
299
+ def walk(current: object) -> None:
300
+ nonlocal error_count
301
+ if isinstance(current, Mapping):
302
+ loc = current.get("loc") or current.get("path") or current.get("field")
303
+ if loc is not None:
304
+ add_field_path(loc)
305
+ reason = (
306
+ _normalize_schema_token(current.get("type"))
307
+ or _normalize_schema_token(current.get("code"))
308
+ or _normalize_schema_token(current.get("reason"))
309
+ )
310
+ if reason:
311
+ reason_codes.append(reason)
312
+ if loc is not None or reason is not None:
313
+ error_count += 1
314
+ for key in ("errors", "detail"):
315
+ child = current.get(key)
316
+ if child is not None and child is not current:
317
+ walk(child)
318
+ return
319
+ if isinstance(current, Sequence) and not isinstance(current, (str, bytes, bytearray)):
320
+ for entry in current:
321
+ walk(entry)
322
+
323
+ walk(value)
324
+ return {
325
+ "field_paths": sorted(dict.fromkeys(field_paths)),
326
+ "validation_reason_codes": sorted(dict.fromkeys(reason_codes)),
327
+ "error_count": error_count,
328
+ }
329
+
330
+
331
+ def _derive_outcome_evidence(
332
+ *,
333
+ status_code: int,
334
+ response_body: object | None,
335
+ headers: Mapping[str, JsonValue],
336
+ ) -> dict[str, JsonValue]:
337
+ error_code_candidate = _extract_error_code_candidate(response_body)
338
+ validation = _extract_validation_evidence(response_body)
339
+ outcome_class = "success"
340
+ decision_type: str | None = None
341
+ decision_result: str | None = None
342
+ reason_code = error_code_candidate
343
+
344
+ if status_code == 401:
345
+ outcome_class = "auth_denied"
346
+ decision_type = "auth"
347
+ decision_result = "denied"
348
+ reason_code = reason_code or "unauthorized"
349
+ elif status_code == 403:
350
+ outcome_class = "permission_denied"
351
+ decision_type = "permission"
352
+ decision_result = "denied"
353
+ reason_code = reason_code or "forbidden"
354
+ elif status_code == 409:
355
+ outcome_class = "conflict"
356
+ decision_type = "validation"
357
+ decision_result = "blocked"
358
+ reason_code = reason_code or "conflict"
359
+ elif status_code == 422:
360
+ outcome_class = "validation_error"
361
+ decision_type = "validation"
362
+ decision_result = "blocked"
363
+ reason_code = (
364
+ str(validation["validation_reason_codes"][0])
365
+ if isinstance(validation["validation_reason_codes"], list)
366
+ and validation["validation_reason_codes"]
367
+ else reason_code or "validation_error"
368
+ )
369
+ elif status_code == 429:
370
+ outcome_class = "rate_limited"
371
+ decision_type = "rate_limit"
372
+ decision_result = "blocked"
373
+ reason_code = reason_code or "rate_limited"
374
+ elif status_code == 404:
375
+ outcome_class = "not_found"
376
+ reason_code = reason_code or "not_found"
377
+ elif status_code >= 500:
378
+ outcome_class = "server_error"
379
+ decision_result = "failed"
380
+ reason_code = reason_code or "server_error"
381
+ elif status_code >= 400:
382
+ outcome_class = "client_error"
383
+ decision_result = "failed"
384
+ reason_code = reason_code or "client_error"
385
+
386
+ if reason_code and "quota" in reason_code:
387
+ outcome_class = "quota_exceeded"
388
+ decision_type = "quota"
389
+ decision_result = "blocked"
390
+
391
+ return {
392
+ "outcome_class": outcome_class,
393
+ "decision_type": decision_type,
394
+ "decision_result": decision_result,
395
+ "reason_code": reason_code,
396
+ "error_code_candidate": error_code_candidate,
397
+ "field_paths": validation["field_paths"],
398
+ "validation_reason_codes": validation["validation_reason_codes"],
399
+ "error_count": validation["error_count"],
400
+ "limit_name": _normalize_schema_token(
401
+ headers.get("x-ratelimit-limit")
402
+ or headers.get("x-rate-limit")
403
+ or headers.get("x-quota-limit")
404
+ ),
405
+ "remaining_bucket": _bucket_remaining(
406
+ headers.get("x-ratelimit-remaining")
407
+ or headers.get("x-rate-limit-remaining")
408
+ or headers.get("x-quota-remaining")
409
+ ),
410
+ "reset_after_ms_bucket": _bucket_reset_after_ms(headers),
411
+ }
412
+
413
+
414
+ def build_subject_profile(
415
+ traits: Mapping[str, object] | None,
416
+ ) -> dict[str, JsonValue] | None:
417
+ if traits is None:
418
+ return None
419
+
420
+ display_name = _string_or_none(
421
+ traits.get("display_name") or traits.get("displayName")
422
+ )
423
+ avatar_url = _string_or_none(traits.get("avatar_url") or traits.get("avatarUrl"))
424
+ email = _string_or_none(
425
+ traits.get("email") or traits.get("email_address") or traits.get("emailAddress")
426
+ )
427
+ role = _string_or_none(traits.get("role"))
428
+ plan = _string_or_none(traits.get("plan"))
429
+
430
+ if all(value is None for value in (display_name, avatar_url, email, role, plan)):
431
+ return None
432
+
433
+ return {
434
+ "display_name": display_name,
435
+ "avatar_url": avatar_url,
436
+ "email": email,
437
+ "role": role,
438
+ "plan": plan,
439
+ }
440
+
441
+
442
+ def _workspace_id_from_traits(traits: Mapping[str, object] | None) -> str | None:
443
+ if traits is None:
444
+ return None
445
+ return _string_or_none(traits.get("workspace_id") or traits.get("workspaceId"))
446
+
447
+
448
+ def _visible_trait_keys(
449
+ traits: Mapping[str, object] | None,
450
+ denied_keys: Sequence[str],
451
+ ) -> list[str]:
452
+ return sorted(
453
+ str(key)
454
+ for key in (traits or {}).keys()
455
+ if not is_denied_key(str(key), denied_keys)
456
+ )
457
+
458
+
459
+ def _duration_root_field(status: str, duration_ms: int | None) -> dict[str, int]:
460
+ if status == BACKEND_STATUS_STARTED:
461
+ return {}
462
+ if duration_ms is None:
463
+ raise ValueError("duration_ms is required for finished and failed events")
464
+ return {"duration_ms": duration_ms}
465
+
466
+
467
+ def _masked_identifier(value: object | None) -> dict[str, JsonValue] | None:
468
+ if value is None:
469
+ return None
470
+ return build_mask_token("masked", value)
471
+
472
+
473
+ def _internal_flow_context(
474
+ *,
475
+ context: Mapping[str, JsonValue],
476
+ span_id: str | None,
477
+ parent_span_id: str | None,
478
+ expected_otel_span_kind: str | None = None,
479
+ ) -> dict[str, JsonValue]:
480
+ event_context = merge_current_otel_span_context(
481
+ context,
482
+ expected_kind=expected_otel_span_kind,
483
+ )
484
+ current_span_id = _context_string(event_context, "span_id")
485
+ current_parent_span_id = _context_string(event_context, "parent_span_id")
486
+ resolved_parent_span_id = (
487
+ parent_span_id
488
+ or current_span_id
489
+ or current_parent_span_id
490
+ )
491
+ if resolved_parent_span_id is None:
492
+ raise ValueError("parent_span_id is required for internal flow events")
493
+ event_context["parent_span_id"] = resolved_parent_span_id
494
+ event_context["span_id"] = span_id or uuid.uuid4().hex[:16]
495
+ if _context_string(event_context, "trace_id") is None:
496
+ event_context["trace_id"] = uuid.uuid4().hex
497
+ return event_context
498
+
499
+
500
+ def _now_iso() -> str:
501
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
502
+
503
+
504
+ def build_backend_context(
505
+ *,
506
+ service_name: str,
507
+ environment: str,
508
+ service_key: str | None = None,
509
+ producer_id: str | None = None,
510
+ tenant_id: str | None = None,
511
+ anonymous_id: str | None = None,
512
+ user_id: str | None = None,
513
+ account_id: str | None = None,
514
+ user_profile: Mapping[str, object] | None = None,
515
+ account_profile: Mapping[str, object] | None = None,
516
+ workspace_id: str | None = None,
517
+ session_id: str | None = None,
518
+ tab_id: str | None = None,
519
+ trace_id: str | None = None,
520
+ span_id: str | None = None,
521
+ parent_span_id: str | None = None,
522
+ request_id: str | None = None,
523
+ request_span_id: str | None = None,
524
+ interaction_id: str | None = None,
525
+ backend_release: str | None = None,
526
+ feature_flags: Sequence[str] = (),
527
+ experiment_variant: str | None = None,
528
+ runtime_language: str = BACKEND_RUNTIME_LANGUAGE,
529
+ sdk_collection_mode: str = SDK_COLLECTION_MODE_STANDARD,
530
+ ) -> dict[str, JsonValue]:
531
+ return {
532
+ "tenant_id": tenant_id,
533
+ "anonymous_id": anonymous_id,
534
+ "user_id": user_id,
535
+ "account_id": account_id,
536
+ "user_profile": build_subject_profile(user_profile),
537
+ "account_profile": build_subject_profile(account_profile),
538
+ "workspace_id": workspace_id,
539
+ "session_id": session_id,
540
+ "tab_id": tab_id,
541
+ "trace_id": trace_id,
542
+ "span_id": span_id,
543
+ "parent_span_id": parent_span_id,
544
+ "request_id": request_id,
545
+ "request_span_id": request_span_id,
546
+ "interaction_id": interaction_id,
547
+ "environment": environment,
548
+ "backend_release": backend_release,
549
+ "feature_flags": list(feature_flags),
550
+ "experiment_variant": experiment_variant,
551
+ "service_name": service_name,
552
+ "service_key": service_key or service_name,
553
+ "producer_id": producer_id or service_name,
554
+ "runtime_language": runtime_language,
555
+ "sdk_collection_mode": (
556
+ sdk_collection_mode
557
+ if sdk_collection_mode in {"standard", "diagnostic"}
558
+ else SDK_COLLECTION_MODE_STANDARD
559
+ ),
560
+ }
561
+
562
+
563
+ def _context_string(
564
+ context: Mapping[str, JsonValue],
565
+ key: str,
566
+ ) -> str | None:
567
+ value = context.get(key)
568
+ return value if isinstance(value, str) and value else None
569
+
570
+
571
+ def _ensure_event_context(
572
+ context: Mapping[str, JsonValue],
573
+ ) -> dict[str, JsonValue]:
574
+ event_context = dict(context)
575
+ if _context_string(event_context, "trace_id") is None:
576
+ event_context["trace_id"] = uuid.uuid4().hex
577
+ if _context_string(event_context, "span_id") is None:
578
+ event_context["span_id"] = uuid.uuid4().hex[:16]
579
+ return event_context
580
+
581
+
582
+ def _shared_event_identity(
583
+ *,
584
+ event_context: Mapping[str, JsonValue],
585
+ event_category: str,
586
+ event_name: str,
587
+ ) -> str | None:
588
+ request_span_id = _context_string(event_context, "request_span_id")
589
+ if event_category == EVENT_CATEGORY_REQUEST and request_span_id is not None:
590
+ return f"request:{request_span_id}:{event_name}"
591
+
592
+ trace_id = _context_string(event_context, "trace_id")
593
+ span_id = _context_string(event_context, "span_id")
594
+ if event_category in {
595
+ EVENT_CATEGORY_OUTBOUND_REQUEST,
596
+ EVENT_CATEGORY_JOB,
597
+ EVENT_CATEGORY_TOOL_CALL,
598
+ EVENT_CATEGORY_DOMAIN_COMMAND,
599
+ EVENT_CATEGORY_REPOSITORY_MUTATION,
600
+ EVENT_CATEGORY_STATE_TRANSITION,
601
+ } and trace_id is not None and span_id is not None:
602
+ return f"span:{event_category}:{trace_id}:{span_id}:{event_name}"
603
+
604
+ return None
605
+
606
+
607
+ def _request_boundary_context(
608
+ *,
609
+ context: Mapping[str, JsonValue],
610
+ span_id: str | None = None,
611
+ parent_span_id: str | None = None,
612
+ request_span_id: str | None = None,
613
+ expected_otel_span_kind: str | None = None,
614
+ ) -> dict[str, JsonValue]:
615
+ event_context = _ensure_event_context(
616
+ merge_current_otel_span_context(
617
+ context,
618
+ expected_kind=expected_otel_span_kind,
619
+ use_otel_request_span_id=True,
620
+ )
621
+ )
622
+ if span_id is not None:
623
+ event_context["span_id"] = span_id
624
+ if parent_span_id is not None:
625
+ event_context["parent_span_id"] = parent_span_id
626
+ resolved_request_span_id = (
627
+ request_span_id or _context_string(event_context, "request_span_id")
628
+ )
629
+ event_context["request_span_id"] = (
630
+ resolved_request_span_id or f"rsp_{uuid.uuid4()}"
631
+ )
632
+ return event_context
633
+
634
+
635
+ def _base_event(
636
+ *,
637
+ context: Mapping[str, JsonValue],
638
+ event_name: str,
639
+ event_category: str,
640
+ status: str,
641
+ surface_type: str,
642
+ privacy_level: str,
643
+ masking_state: str,
644
+ allowed_value_keys: Sequence[str],
645
+ custom_event_name: str | None = None,
646
+ root_fields: Mapping[str, JsonValue] | None = None,
647
+ properties: Mapping[str, JsonValue] | None = None,
648
+ metrics: Mapping[str, int | float] | None = None,
649
+ ) -> dict[str, JsonValue]:
650
+ occurred_at = _now_iso()
651
+ event_context = _ensure_event_context(context)
652
+ shared_event_identity = _shared_event_identity(
653
+ event_context=event_context,
654
+ event_category=event_category,
655
+ event_name=event_name,
656
+ )
657
+ event = {
658
+ "event_id": str(uuid.uuid4()),
659
+ "shared_event_identity": shared_event_identity,
660
+ "producer_id": event_context.get("producer_id"),
661
+ "event_name": event_name,
662
+ "custom_event_name": custom_event_name,
663
+ "event_category": event_category,
664
+ "event_source": "backend_sdk",
665
+ "event_version": 1,
666
+ "occurred_at": occurred_at,
667
+ "ingested_at": occurred_at,
668
+ "tenant_id": event_context.get("tenant_id"),
669
+ "anonymous_id": event_context.get("anonymous_id"),
670
+ "user_id": event_context.get("user_id"),
671
+ "account_id": event_context.get("account_id"),
672
+ "workspace_id": event_context.get("workspace_id"),
673
+ "session_id": event_context.get("session_id"),
674
+ "tab_id": event_context.get("tab_id"),
675
+ "trace_id": event_context.get("trace_id"),
676
+ "span_id": event_context.get("span_id"),
677
+ "parent_span_id": event_context.get("parent_span_id"),
678
+ "request_id": event_context.get("request_id"),
679
+ "request_span_id": event_context.get("request_span_id"),
680
+ "interaction_id": event_context.get("interaction_id"),
681
+ "service_name": event_context.get("service_name"),
682
+ "service_key": event_context.get("service_key"),
683
+ "runtime_language": event_context.get(
684
+ "runtime_language",
685
+ BACKEND_RUNTIME_LANGUAGE,
686
+ ),
687
+ "status": status,
688
+ "sdk_collection_mode": event_context.get(
689
+ "sdk_collection_mode",
690
+ SDK_COLLECTION_MODE_STANDARD,
691
+ ),
692
+ "surface_type": surface_type,
693
+ "environment": event_context.get("environment"),
694
+ "backend_release": event_context.get("backend_release"),
695
+ "feature_flags": list(event_context.get("feature_flags", [])),
696
+ "experiment_variant": event_context.get("experiment_variant"),
697
+ "privacy_level": privacy_level,
698
+ "masking_state": masking_state,
699
+ "allowed_value_keys": list(allowed_value_keys),
700
+ "properties": dict(properties or {}),
701
+ "metrics": dict(metrics or {}),
702
+ }
703
+ if event_context.get("user_profile") is not None:
704
+ event["user_profile"] = event_context.get("user_profile")
705
+ if event_context.get("account_profile") is not None:
706
+ event["account_profile"] = event_context.get("account_profile")
707
+ if root_fields:
708
+ event.update(dict(root_fields))
709
+ return event
710
+
711
+
712
+ def _build_request_metadata(
713
+ *,
714
+ context: Mapping[str, JsonValue],
715
+ method: str,
716
+ host: str,
717
+ path: str,
718
+ path_template: str,
719
+ protocol: str | None,
720
+ query: Mapping[str, JsonValue],
721
+ headers: Mapping[str, JsonValue],
722
+ timeout_ms: int | None = None,
723
+ retry_count: int | None = None,
724
+ ) -> dict[str, JsonValue]:
725
+ return {
726
+ "service_name": _context_string(context, "service_name"),
727
+ "span_id": _context_string(context, "span_id"),
728
+ "parent_span_id": _context_string(context, "parent_span_id"),
729
+ "request_span_id": _context_string(context, "request_span_id"),
730
+ "interaction_id": _context_string(context, "interaction_id"),
731
+ "method": method,
732
+ "host": host,
733
+ "path": path,
734
+ "path_template": path_template,
735
+ "protocol": protocol,
736
+ "query_masked": bool(query),
737
+ "timeout_ms": timeout_ms,
738
+ "retry_count": retry_count,
739
+ "header_names": sorted(headers.keys()),
740
+ }
741
+
742
+
743
+ def _build_response_metadata(
744
+ *,
745
+ context: Mapping[str, JsonValue],
746
+ method: str,
747
+ host: str,
748
+ path: str,
749
+ path_template: str,
750
+ protocol: str | None,
751
+ status_code: int,
752
+ duration_ms: int | None,
753
+ headers: Mapping[str, JsonValue],
754
+ ) -> dict[str, JsonValue]:
755
+ return {
756
+ "service_name": _context_string(context, "service_name"),
757
+ "span_id": _context_string(context, "span_id"),
758
+ "parent_span_id": _context_string(context, "parent_span_id"),
759
+ "request_span_id": _context_string(context, "request_span_id"),
760
+ "interaction_id": _context_string(context, "interaction_id"),
761
+ "method": method,
762
+ "host": host,
763
+ "path": path,
764
+ "path_template": path_template,
765
+ "protocol": protocol,
766
+ "status_code": status_code,
767
+ "duration_ms": duration_ms,
768
+ "header_names": sorted(headers.keys()),
769
+ }
770
+
771
+
772
+ def _route_operation_source_key(method: str, path_template: str) -> str:
773
+ return f"route.{method.upper()}.{path_template}"
774
+
775
+
776
+ def build_request_started_event(
777
+ request: object,
778
+ *,
779
+ context: Mapping[str, JsonValue],
780
+ handler_name: str | None = None,
781
+ allowed_value_paths: Sequence[str] = (),
782
+ denied_keys: Sequence[str] = (),
783
+ ) -> dict[str, JsonValue]:
784
+ event_context = _request_boundary_context(
785
+ context=context,
786
+ expected_otel_span_kind="SERVER",
787
+ )
788
+ headers = _extract_request_headers(request)
789
+ raw_path = "/"
790
+ if hasattr(request, "get_full_path") and callable(getattr(request, "get_full_path")):
791
+ raw_path = str(request.get_full_path())
792
+ elif hasattr(request, "path"):
793
+ raw_path = str(getattr(request, "path"))
794
+ scheme, host, path, parsed_query = _safe_urlsplit(raw_path)
795
+ query = _extract_query(getattr(request, "GET", None)) or parsed_query
796
+ body = _request_body(request)
797
+ method = str(getattr(request, "method", "GET")).upper() if getattr(request, "method", None) else "GET"
798
+ resolver_match = getattr(request, "resolver_match", None)
799
+ path_template = getattr(resolver_match, "route", None) or to_path_template(path)
800
+ operation_source_key = _route_operation_source_key(method, path_template)
801
+ request_parameters_snapshot, allowed_value_keys, privacy_level, masking_state = (
802
+ _build_request_parameter_snapshot(
803
+ query=query,
804
+ body=body,
805
+ allowed_value_paths=allowed_value_paths,
806
+ denied_keys=denied_keys,
807
+ )
808
+ )
809
+ request_schema_source: dict[str, JsonValue] = {}
810
+ if query:
811
+ request_schema_source["query"] = dict(query)
812
+ if body is not None:
813
+ request_schema_source["body"] = body
814
+ request_schema_evidence = _build_schema_evidence(
815
+ request_schema_source if request_schema_source else None,
816
+ denied_keys=denied_keys,
817
+ )
818
+ request_capture_counts = _count_capture_modes(request_parameters_snapshot)
819
+ event = _base_event(
820
+ context=event_context,
821
+ event_name=REQUEST_EVENT_STARTED,
822
+ event_category=EVENT_CATEGORY_REQUEST,
823
+ status=BACKEND_STATUS_STARTED,
824
+ surface_type=SURFACE_TYPE_API,
825
+ privacy_level=privacy_level,
826
+ masking_state=masking_state,
827
+ allowed_value_keys=allowed_value_keys,
828
+ root_fields={
829
+ "handler_name": handler_name,
830
+ "method": method,
831
+ "path_template": path_template,
832
+ "operation_source_type": "route",
833
+ "operation_source_key": operation_source_key,
834
+ "attempt_status": BACKEND_STATUS_STARTED,
835
+ "request_schema_hash": request_schema_evidence["schema_hash"],
836
+ "request_shape_size": request_schema_evidence["shape_size"],
837
+ "masked_value_count": request_capture_counts["masked_value_count"],
838
+ "forbidden_value_count": request_capture_counts["forbidden_value_count"],
839
+ "truncated_value_count": request_capture_counts["truncated_value_count"],
840
+ },
841
+ properties={
842
+ "service_name": event_context.get("service_name"),
843
+ "service_key": event_context.get("service_key"),
844
+ "handler_name": handler_name,
845
+ "method": method,
846
+ "path_template": path_template,
847
+ "operation_source_type": "route",
848
+ "operation_source_key": operation_source_key,
849
+ "attempt_status": BACKEND_STATUS_STARTED,
850
+ "request_schema_hash": request_schema_evidence["schema_hash"],
851
+ "request_field_paths": request_schema_evidence["field_paths"],
852
+ "request_shape_size": request_schema_evidence["shape_size"],
853
+ "masked_value_count": request_capture_counts["masked_value_count"],
854
+ "forbidden_value_count": request_capture_counts["forbidden_value_count"],
855
+ "truncated_value_count": request_capture_counts["truncated_value_count"],
856
+ },
857
+ )
858
+ event["request_metadata"] = _build_request_metadata(
859
+ context=event_context,
860
+ method=method,
861
+ host=host or str(headers.get("host", "") or ""),
862
+ path=path,
863
+ path_template=path_template,
864
+ protocol=scheme or getattr(request, "scheme", None),
865
+ query=query,
866
+ headers=headers,
867
+ )
868
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
869
+ return event
870
+
871
+
872
+ def build_request_finished_event(
873
+ request: object,
874
+ response: object,
875
+ *,
876
+ context: Mapping[str, JsonValue],
877
+ handler_name: str | None = None,
878
+ duration_ms: int,
879
+ allowed_value_paths: Sequence[str] = (),
880
+ denied_keys: Sequence[str] = (),
881
+ ) -> dict[str, JsonValue]:
882
+ event_context = _request_boundary_context(
883
+ context=context,
884
+ expected_otel_span_kind="SERVER",
885
+ )
886
+ headers = _extract_response_headers(response)
887
+ raw_path = "/"
888
+ if hasattr(request, "get_full_path") and callable(getattr(request, "get_full_path")):
889
+ raw_path = str(request.get_full_path())
890
+ elif hasattr(request, "path"):
891
+ raw_path = str(getattr(request, "path"))
892
+ _, _, path, _ = _safe_urlsplit(raw_path)
893
+ method = str(getattr(request, "method", "GET")).upper() if getattr(request, "method", None) else "GET"
894
+ resolver_match = getattr(request, "resolver_match", None)
895
+ path_template = getattr(resolver_match, "route", None) or to_path_template(path)
896
+ operation_source_key = _route_operation_source_key(method, path_template)
897
+ status_code = int(getattr(response, "status_code", 200))
898
+ request_headers = _extract_request_headers(request)
899
+ request_query = _extract_query(getattr(request, "GET", None))
900
+ request_body = _request_body(request)
901
+ request_parameters_snapshot, request_allowed_value_keys, request_privacy_level, request_masking_state = (
902
+ _build_request_parameter_snapshot(
903
+ query=request_query,
904
+ body=request_body,
905
+ allowed_value_paths=allowed_value_paths,
906
+ denied_keys=denied_keys,
907
+ )
908
+ )
909
+ response_body = _response_body(response)
910
+ response_parameters_snapshot, response_allowed_value_keys, response_privacy_level, response_masking_state = (
911
+ _build_response_parameter_snapshot(
912
+ body=response_body,
913
+ allowed_value_paths=allowed_value_paths,
914
+ denied_keys=denied_keys,
915
+ )
916
+ )
917
+ request_schema_source: dict[str, JsonValue] = {}
918
+ if request_query:
919
+ request_schema_source["query"] = dict(request_query)
920
+ if request_body is not None:
921
+ request_schema_source["body"] = request_body
922
+ request_schema_evidence = _build_schema_evidence(
923
+ request_schema_source if request_schema_source else None,
924
+ denied_keys=denied_keys,
925
+ )
926
+ response_schema_evidence = _build_schema_evidence(
927
+ response_body,
928
+ denied_keys=denied_keys,
929
+ )
930
+ request_capture_counts = _count_capture_modes(request_parameters_snapshot)
931
+ response_capture_counts = _count_capture_modes(response_parameters_snapshot)
932
+ capture_counts = {
933
+ key: request_capture_counts[key] + response_capture_counts[key]
934
+ for key in request_capture_counts
935
+ }
936
+ outcome_evidence = _derive_outcome_evidence(
937
+ status_code=status_code,
938
+ response_body=response_body,
939
+ headers=headers,
940
+ )
941
+ allowed_value_keys = sorted(
942
+ dict.fromkeys([*request_allowed_value_keys, *response_allowed_value_keys])
943
+ )
944
+ duration_root_field = _duration_root_field(BACKEND_STATUS_FINISHED, duration_ms)
945
+ event = _base_event(
946
+ context=event_context,
947
+ event_name=REQUEST_EVENT_FINISHED,
948
+ event_category=EVENT_CATEGORY_REQUEST,
949
+ status=BACKEND_STATUS_FINISHED,
950
+ surface_type=SURFACE_TYPE_API,
951
+ privacy_level=
952
+ "allowlisted"
953
+ if request_privacy_level == "allowlisted"
954
+ or response_privacy_level == "allowlisted"
955
+ else "default_masked",
956
+ masking_state=
957
+ "partially_unmasked"
958
+ if request_masking_state == "partially_unmasked"
959
+ or response_masking_state == "partially_unmasked"
960
+ else "masked",
961
+ allowed_value_keys=allowed_value_keys,
962
+ root_fields={
963
+ "handler_name": handler_name,
964
+ "method": method,
965
+ "path_template": path_template,
966
+ "operation_source_type": "route",
967
+ "operation_source_key": operation_source_key,
968
+ "attempt_status": BACKEND_STATUS_FINISHED,
969
+ "validation_field_paths": outcome_evidence["field_paths"],
970
+ "status_code": status_code,
971
+ **duration_root_field,
972
+ "outcome_class": outcome_evidence["outcome_class"],
973
+ "decision_type": outcome_evidence["decision_type"],
974
+ "decision_result": outcome_evidence["decision_result"],
975
+ "reason_code": outcome_evidence["reason_code"],
976
+ "request_schema_hash": request_schema_evidence["schema_hash"],
977
+ "response_schema_hash": response_schema_evidence["schema_hash"],
978
+ "request_shape_size": request_schema_evidence["shape_size"],
979
+ "response_shape_size": response_schema_evidence["shape_size"],
980
+ "masked_value_count": capture_counts["masked_value_count"],
981
+ "forbidden_value_count": capture_counts["forbidden_value_count"],
982
+ "truncated_value_count": capture_counts["truncated_value_count"],
983
+ },
984
+ properties={
985
+ "service_name": event_context.get("service_name"),
986
+ "service_key": event_context.get("service_key"),
987
+ "handler_name": handler_name,
988
+ "method": method,
989
+ "path_template": path_template,
990
+ "operation_source_type": "route",
991
+ "operation_source_key": operation_source_key,
992
+ "attempt_status": BACKEND_STATUS_FINISHED,
993
+ "outcome_class": outcome_evidence["outcome_class"],
994
+ "decision_type": outcome_evidence["decision_type"],
995
+ "decision_result": outcome_evidence["decision_result"],
996
+ "reason_code": outcome_evidence["reason_code"],
997
+ "error_code_candidate": outcome_evidence["error_code_candidate"],
998
+ "field_paths": outcome_evidence["field_paths"],
999
+ "validation_reason_codes": outcome_evidence["validation_reason_codes"],
1000
+ "error_count": outcome_evidence["error_count"],
1001
+ "limit_name": outcome_evidence["limit_name"],
1002
+ "remaining_bucket": outcome_evidence["remaining_bucket"],
1003
+ "reset_after_ms_bucket": outcome_evidence["reset_after_ms_bucket"],
1004
+ "request_schema_hash": request_schema_evidence["schema_hash"],
1005
+ "response_schema_hash": response_schema_evidence["schema_hash"],
1006
+ "request_field_paths": request_schema_evidence["field_paths"],
1007
+ "response_field_paths": response_schema_evidence["field_paths"],
1008
+ "request_shape_size": request_schema_evidence["shape_size"],
1009
+ "response_shape_size": response_schema_evidence["shape_size"],
1010
+ "masked_value_count": capture_counts["masked_value_count"],
1011
+ "forbidden_value_count": capture_counts["forbidden_value_count"],
1012
+ "truncated_value_count": capture_counts["truncated_value_count"],
1013
+ },
1014
+ metrics={
1015
+ "status_code": status_code,
1016
+ **duration_root_field,
1017
+ "error_count": int(outcome_evidence["error_count"]),
1018
+ "request_shape_size": int(request_schema_evidence["shape_size"]),
1019
+ "response_shape_size": int(response_schema_evidence["shape_size"]),
1020
+ },
1021
+ )
1022
+ event["request_metadata"] = _build_request_metadata(
1023
+ context=event_context,
1024
+ method=method,
1025
+ host=str(request_headers.get("host", "") or ""),
1026
+ path=path,
1027
+ path_template=path_template,
1028
+ protocol=getattr(request, "scheme", None),
1029
+ query=request_query,
1030
+ headers=request_headers,
1031
+ )
1032
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
1033
+ event["response_metadata"] = _build_response_metadata(
1034
+ context=event_context,
1035
+ method=method,
1036
+ host=str(headers.get("host", "") or ""),
1037
+ path=path,
1038
+ path_template=path_template,
1039
+ protocol=getattr(request, "scheme", None),
1040
+ status_code=status_code,
1041
+ duration_ms=duration_root_field["duration_ms"],
1042
+ headers=headers,
1043
+ )
1044
+ event["response_parameters_snapshot_json"] = response_parameters_snapshot
1045
+ return event
1046
+
1047
+
1048
+ def build_request_failed_event(
1049
+ request: object,
1050
+ *,
1051
+ context: Mapping[str, JsonValue],
1052
+ handler_name: str | None = None,
1053
+ status_code: int = 500,
1054
+ duration_ms: int,
1055
+ response: object | None = None,
1056
+ allowed_value_paths: Sequence[str] = (),
1057
+ denied_keys: Sequence[str] = (),
1058
+ ) -> dict[str, JsonValue]:
1059
+ event_context = _request_boundary_context(
1060
+ context=context,
1061
+ expected_otel_span_kind="SERVER",
1062
+ )
1063
+ headers = _extract_response_headers(response) if response is not None else {}
1064
+ raw_path = "/"
1065
+ if hasattr(request, "get_full_path") and callable(getattr(request, "get_full_path")):
1066
+ raw_path = str(request.get_full_path())
1067
+ elif hasattr(request, "path"):
1068
+ raw_path = str(getattr(request, "path"))
1069
+ _, _, path, _ = _safe_urlsplit(raw_path)
1070
+ method = str(getattr(request, "method", "GET")).upper() if getattr(request, "method", None) else "GET"
1071
+ resolver_match = getattr(request, "resolver_match", None)
1072
+ path_template = getattr(resolver_match, "route", None) or to_path_template(path)
1073
+ operation_source_key = _route_operation_source_key(method, path_template)
1074
+ request_headers = _extract_request_headers(request)
1075
+ request_query = _extract_query(getattr(request, "GET", None))
1076
+ request_body = _request_body(request)
1077
+ request_parameters_snapshot, request_allowed_value_keys, request_privacy_level, request_masking_state = (
1078
+ _build_request_parameter_snapshot(
1079
+ query=request_query,
1080
+ body=request_body,
1081
+ allowed_value_paths=allowed_value_paths,
1082
+ denied_keys=denied_keys,
1083
+ )
1084
+ )
1085
+ response_body = _response_body(response)
1086
+ response_parameters_snapshot, response_allowed_value_keys, response_privacy_level, response_masking_state = (
1087
+ _build_response_parameter_snapshot(
1088
+ body=response_body,
1089
+ allowed_value_paths=allowed_value_paths,
1090
+ denied_keys=denied_keys,
1091
+ )
1092
+ )
1093
+ request_schema_source: dict[str, JsonValue] = {}
1094
+ if request_query:
1095
+ request_schema_source["query"] = dict(request_query)
1096
+ if request_body is not None:
1097
+ request_schema_source["body"] = request_body
1098
+ request_schema_evidence = _build_schema_evidence(
1099
+ request_schema_source if request_schema_source else None,
1100
+ denied_keys=denied_keys,
1101
+ )
1102
+ response_schema_evidence = _build_schema_evidence(
1103
+ response_body,
1104
+ denied_keys=denied_keys,
1105
+ )
1106
+ request_capture_counts = _count_capture_modes(request_parameters_snapshot)
1107
+ response_capture_counts = _count_capture_modes(response_parameters_snapshot)
1108
+ capture_counts = {
1109
+ key: request_capture_counts[key] + response_capture_counts[key]
1110
+ for key in request_capture_counts
1111
+ }
1112
+ outcome_evidence = _derive_outcome_evidence(
1113
+ status_code=status_code,
1114
+ response_body=response_body,
1115
+ headers=headers,
1116
+ )
1117
+ allowed_value_keys = sorted(
1118
+ dict.fromkeys([*request_allowed_value_keys, *response_allowed_value_keys])
1119
+ )
1120
+ duration_root_field = _duration_root_field(BACKEND_STATUS_FAILED, duration_ms)
1121
+ event = _base_event(
1122
+ context=event_context,
1123
+ event_name=REQUEST_EVENT_FAILED,
1124
+ event_category=EVENT_CATEGORY_REQUEST,
1125
+ status=BACKEND_STATUS_FAILED,
1126
+ surface_type=SURFACE_TYPE_API,
1127
+ privacy_level=
1128
+ "allowlisted"
1129
+ if request_privacy_level == "allowlisted"
1130
+ or response_privacy_level == "allowlisted"
1131
+ else "default_masked",
1132
+ masking_state=
1133
+ "partially_unmasked"
1134
+ if request_masking_state == "partially_unmasked"
1135
+ or response_masking_state == "partially_unmasked"
1136
+ else "masked",
1137
+ allowed_value_keys=allowed_value_keys,
1138
+ root_fields={
1139
+ "handler_name": handler_name,
1140
+ "method": method,
1141
+ "path_template": path_template,
1142
+ "operation_source_type": "route",
1143
+ "operation_source_key": operation_source_key,
1144
+ "attempt_status": BACKEND_STATUS_FAILED,
1145
+ "validation_field_paths": outcome_evidence["field_paths"],
1146
+ "status_code": status_code,
1147
+ **duration_root_field,
1148
+ "outcome_class": outcome_evidence["outcome_class"],
1149
+ "decision_type": outcome_evidence["decision_type"],
1150
+ "decision_result": outcome_evidence["decision_result"],
1151
+ "reason_code": outcome_evidence["reason_code"],
1152
+ "request_schema_hash": request_schema_evidence["schema_hash"],
1153
+ "response_schema_hash": response_schema_evidence["schema_hash"],
1154
+ "request_shape_size": request_schema_evidence["shape_size"],
1155
+ "response_shape_size": response_schema_evidence["shape_size"],
1156
+ "masked_value_count": capture_counts["masked_value_count"],
1157
+ "forbidden_value_count": capture_counts["forbidden_value_count"],
1158
+ "truncated_value_count": capture_counts["truncated_value_count"],
1159
+ },
1160
+ properties={
1161
+ "service_name": event_context.get("service_name"),
1162
+ "service_key": event_context.get("service_key"),
1163
+ "handler_name": handler_name,
1164
+ "method": method,
1165
+ "path_template": path_template,
1166
+ "operation_source_type": "route",
1167
+ "operation_source_key": operation_source_key,
1168
+ "attempt_status": BACKEND_STATUS_FAILED,
1169
+ "outcome_class": outcome_evidence["outcome_class"],
1170
+ "decision_type": outcome_evidence["decision_type"],
1171
+ "decision_result": outcome_evidence["decision_result"],
1172
+ "reason_code": outcome_evidence["reason_code"],
1173
+ "error_code_candidate": outcome_evidence["error_code_candidate"],
1174
+ "field_paths": outcome_evidence["field_paths"],
1175
+ "validation_reason_codes": outcome_evidence["validation_reason_codes"],
1176
+ "error_count": outcome_evidence["error_count"],
1177
+ "limit_name": outcome_evidence["limit_name"],
1178
+ "remaining_bucket": outcome_evidence["remaining_bucket"],
1179
+ "reset_after_ms_bucket": outcome_evidence["reset_after_ms_bucket"],
1180
+ "request_schema_hash": request_schema_evidence["schema_hash"],
1181
+ "response_schema_hash": response_schema_evidence["schema_hash"],
1182
+ "request_field_paths": request_schema_evidence["field_paths"],
1183
+ "response_field_paths": response_schema_evidence["field_paths"],
1184
+ "request_shape_size": request_schema_evidence["shape_size"],
1185
+ "response_shape_size": response_schema_evidence["shape_size"],
1186
+ "masked_value_count": capture_counts["masked_value_count"],
1187
+ "forbidden_value_count": capture_counts["forbidden_value_count"],
1188
+ "truncated_value_count": capture_counts["truncated_value_count"],
1189
+ },
1190
+ metrics={
1191
+ "status_code": status_code,
1192
+ **duration_root_field,
1193
+ "error_count": int(outcome_evidence["error_count"]),
1194
+ "request_shape_size": int(request_schema_evidence["shape_size"]),
1195
+ "response_shape_size": int(response_schema_evidence["shape_size"]),
1196
+ },
1197
+ )
1198
+ event["request_metadata"] = _build_request_metadata(
1199
+ context=event_context,
1200
+ method=method,
1201
+ host=str(request_headers.get("host", "") or ""),
1202
+ path=path,
1203
+ path_template=path_template,
1204
+ protocol=getattr(request, "scheme", None),
1205
+ query=request_query,
1206
+ headers=request_headers,
1207
+ )
1208
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
1209
+ event["response_metadata"] = _build_response_metadata(
1210
+ context=event_context,
1211
+ method=method,
1212
+ host=str(headers.get("host", "") or ""),
1213
+ path=path,
1214
+ path_template=path_template,
1215
+ protocol=getattr(request, "scheme", None),
1216
+ status_code=status_code,
1217
+ duration_ms=duration_root_field["duration_ms"],
1218
+ headers=headers,
1219
+ )
1220
+ event["response_parameters_snapshot_json"] = response_parameters_snapshot
1221
+ return event
1222
+
1223
+
1224
+ def build_outbound_request_started_event(
1225
+ *,
1226
+ context: Mapping[str, JsonValue],
1227
+ method: str,
1228
+ url: str,
1229
+ request_body: object = None,
1230
+ request_headers: Mapping[str, object] | None = None,
1231
+ timeout_ms: int | None = None,
1232
+ retry_count: int | None = None,
1233
+ allowed_value_paths: Sequence[str] = (),
1234
+ denied_keys: Sequence[str] = (),
1235
+ span_id: str | None = None,
1236
+ parent_span_id: str | None = None,
1237
+ request_span_id: str | None = None,
1238
+ ) -> dict[str, JsonValue]:
1239
+ scheme, host, path, query = _safe_urlsplit(url)
1240
+ path_template = to_path_template(path)
1241
+ headers = _normalize_headers(request_headers or {})
1242
+ request_parameters_snapshot, allowed_value_keys, privacy_level, masking_state = (
1243
+ _build_request_parameter_snapshot(
1244
+ query=query,
1245
+ body=_to_json_value(request_body),
1246
+ allowed_value_paths=allowed_value_paths,
1247
+ denied_keys=denied_keys,
1248
+ )
1249
+ )
1250
+ event_context = _request_boundary_context(
1251
+ context=context,
1252
+ span_id=span_id,
1253
+ parent_span_id=parent_span_id,
1254
+ request_span_id=request_span_id,
1255
+ expected_otel_span_kind="CLIENT",
1256
+ )
1257
+
1258
+ event = _base_event(
1259
+ context=event_context,
1260
+ event_name=OUTBOUND_REQUEST_EVENT_STARTED,
1261
+ event_category=EVENT_CATEGORY_OUTBOUND_REQUEST,
1262
+ status=BACKEND_STATUS_STARTED,
1263
+ surface_type=SURFACE_TYPE_SERVICE,
1264
+ privacy_level=privacy_level,
1265
+ masking_state=masking_state,
1266
+ allowed_value_keys=allowed_value_keys,
1267
+ root_fields={
1268
+ "method": method.upper(),
1269
+ "path_template": path_template,
1270
+ "target_service": host or None,
1271
+ },
1272
+ properties={
1273
+ "service_name": event_context.get("service_name"),
1274
+ "target_service": host or None,
1275
+ "method": method.upper(),
1276
+ "path_template": path_template,
1277
+ "failure_type": None,
1278
+ },
1279
+ )
1280
+ event["request_metadata"] = _build_request_metadata(
1281
+ context=event_context,
1282
+ method=method.upper(),
1283
+ host=host,
1284
+ path=path,
1285
+ path_template=path_template,
1286
+ protocol=scheme or None,
1287
+ query=query,
1288
+ headers=headers,
1289
+ timeout_ms=timeout_ms,
1290
+ retry_count=retry_count,
1291
+ )
1292
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
1293
+ return event
1294
+
1295
+
1296
+ def build_outbound_request_finished_event(
1297
+ *,
1298
+ context: Mapping[str, JsonValue],
1299
+ method: str,
1300
+ url: str,
1301
+ status_code: int,
1302
+ duration_ms: int,
1303
+ request_body: object = None,
1304
+ request_headers: Mapping[str, object] | None = None,
1305
+ response_body: object = None,
1306
+ response_headers: Mapping[str, object] | None = None,
1307
+ allowed_value_paths: Sequence[str] = (),
1308
+ denied_keys: Sequence[str] = (),
1309
+ timeout_ms: int | None = None,
1310
+ retry_count: int | None = None,
1311
+ span_id: str | None = None,
1312
+ parent_span_id: str | None = None,
1313
+ request_span_id: str | None = None,
1314
+ ) -> dict[str, JsonValue]:
1315
+ scheme, host, path, query = _safe_urlsplit(url)
1316
+ path_template = to_path_template(path)
1317
+ request_header_record = _normalize_headers(request_headers or {})
1318
+ headers = _normalize_headers(response_headers or {})
1319
+ request_parameters_snapshot, request_allowed_value_keys, request_privacy_level, request_masking_state = (
1320
+ _build_request_parameter_snapshot(
1321
+ query=query,
1322
+ body=_to_json_value(request_body),
1323
+ allowed_value_paths=allowed_value_paths,
1324
+ denied_keys=denied_keys,
1325
+ )
1326
+ )
1327
+ response_parameters_snapshot, response_allowed_value_keys, response_privacy_level, response_masking_state = (
1328
+ _build_response_parameter_snapshot(
1329
+ body=_to_json_value(response_body),
1330
+ allowed_value_paths=allowed_value_paths,
1331
+ denied_keys=denied_keys,
1332
+ )
1333
+ )
1334
+ allowed_value_keys = sorted(
1335
+ dict.fromkeys([*request_allowed_value_keys, *response_allowed_value_keys])
1336
+ )
1337
+ event_context = _request_boundary_context(
1338
+ context=context,
1339
+ span_id=span_id,
1340
+ parent_span_id=parent_span_id,
1341
+ request_span_id=request_span_id,
1342
+ expected_otel_span_kind="CLIENT",
1343
+ )
1344
+ duration_root_field = _duration_root_field(BACKEND_STATUS_FINISHED, duration_ms)
1345
+
1346
+ event = _base_event(
1347
+ context=event_context,
1348
+ event_name=OUTBOUND_REQUEST_EVENT_FINISHED,
1349
+ event_category=EVENT_CATEGORY_OUTBOUND_REQUEST,
1350
+ status=BACKEND_STATUS_FINISHED,
1351
+ surface_type=SURFACE_TYPE_SERVICE,
1352
+ privacy_level=
1353
+ "allowlisted"
1354
+ if request_privacy_level == "allowlisted"
1355
+ or response_privacy_level == "allowlisted"
1356
+ else "default_masked",
1357
+ masking_state=
1358
+ "partially_unmasked"
1359
+ if request_masking_state == "partially_unmasked"
1360
+ or response_masking_state == "partially_unmasked"
1361
+ else "masked",
1362
+ allowed_value_keys=allowed_value_keys,
1363
+ root_fields={
1364
+ "method": method.upper(),
1365
+ "path_template": path_template,
1366
+ "target_service": host or None,
1367
+ "status_code": status_code,
1368
+ **duration_root_field,
1369
+ },
1370
+ properties={
1371
+ "service_name": event_context.get("service_name"),
1372
+ "target_service": host or None,
1373
+ "method": method.upper(),
1374
+ "path_template": path_template,
1375
+ "failure_type": None,
1376
+ },
1377
+ metrics={
1378
+ "status_code": status_code,
1379
+ **duration_root_field,
1380
+ },
1381
+ )
1382
+ event["request_metadata"] = _build_request_metadata(
1383
+ context=event_context,
1384
+ method=method.upper(),
1385
+ host=host,
1386
+ path=path,
1387
+ path_template=path_template,
1388
+ protocol=scheme or None,
1389
+ query=query,
1390
+ headers=request_header_record,
1391
+ timeout_ms=timeout_ms,
1392
+ retry_count=retry_count,
1393
+ )
1394
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
1395
+ event["response_metadata"] = _build_response_metadata(
1396
+ context=event_context,
1397
+ method=method.upper(),
1398
+ host=host,
1399
+ path=path,
1400
+ path_template=path_template,
1401
+ protocol=scheme or None,
1402
+ status_code=status_code,
1403
+ duration_ms=duration_root_field["duration_ms"],
1404
+ headers=headers,
1405
+ )
1406
+ event["response_parameters_snapshot_json"] = response_parameters_snapshot
1407
+ return event
1408
+
1409
+
1410
+ def build_outbound_request_failed_event(
1411
+ *,
1412
+ context: Mapping[str, JsonValue],
1413
+ method: str,
1414
+ url: str,
1415
+ failure_type: str,
1416
+ duration_ms: int,
1417
+ status_code: int,
1418
+ request_body: object = None,
1419
+ request_headers: Mapping[str, object] | None = None,
1420
+ response_body: object = None,
1421
+ response_headers: Mapping[str, object] | None = None,
1422
+ allowed_value_paths: Sequence[str] = (),
1423
+ denied_keys: Sequence[str] = (),
1424
+ timeout_ms: int | None = None,
1425
+ retry_count: int | None = None,
1426
+ span_id: str | None = None,
1427
+ parent_span_id: str | None = None,
1428
+ request_span_id: str | None = None,
1429
+ ) -> dict[str, JsonValue]:
1430
+ scheme, host, path, query = _safe_urlsplit(url)
1431
+ path_template = to_path_template(path)
1432
+ request_header_record = _normalize_headers(request_headers or {})
1433
+ response_header_record = _normalize_headers(response_headers or {})
1434
+ request_parameters_snapshot, request_allowed_value_keys, request_privacy_level, request_masking_state = (
1435
+ _build_request_parameter_snapshot(
1436
+ query=query,
1437
+ body=_to_json_value(request_body),
1438
+ allowed_value_paths=allowed_value_paths,
1439
+ denied_keys=denied_keys,
1440
+ )
1441
+ )
1442
+ response_parameters_snapshot, response_allowed_value_keys, response_privacy_level, response_masking_state = (
1443
+ _build_response_parameter_snapshot(
1444
+ body=_to_json_value(response_body),
1445
+ allowed_value_paths=allowed_value_paths,
1446
+ denied_keys=denied_keys,
1447
+ )
1448
+ )
1449
+ allowed_value_keys = sorted(
1450
+ dict.fromkeys([*request_allowed_value_keys, *response_allowed_value_keys])
1451
+ )
1452
+ event_context = _request_boundary_context(
1453
+ context=context,
1454
+ span_id=span_id,
1455
+ parent_span_id=parent_span_id,
1456
+ request_span_id=request_span_id,
1457
+ expected_otel_span_kind="CLIENT",
1458
+ )
1459
+ duration_root_field = _duration_root_field(BACKEND_STATUS_FAILED, duration_ms)
1460
+ event = _base_event(
1461
+ context=event_context,
1462
+ event_name=OUTBOUND_REQUEST_EVENT_FAILED,
1463
+ event_category=EVENT_CATEGORY_OUTBOUND_REQUEST,
1464
+ status=BACKEND_STATUS_FAILED,
1465
+ surface_type=SURFACE_TYPE_SERVICE,
1466
+ privacy_level=
1467
+ "allowlisted"
1468
+ if request_privacy_level == "allowlisted"
1469
+ or response_privacy_level == "allowlisted"
1470
+ else "default_masked",
1471
+ masking_state=
1472
+ "partially_unmasked"
1473
+ if request_masking_state == "partially_unmasked"
1474
+ or response_masking_state == "partially_unmasked"
1475
+ else "masked",
1476
+ allowed_value_keys=allowed_value_keys,
1477
+ root_fields={
1478
+ "method": method.upper(),
1479
+ "path_template": path_template,
1480
+ "target_service": host or None,
1481
+ "failure_type": failure_type,
1482
+ "status_code": status_code,
1483
+ **duration_root_field,
1484
+ },
1485
+ properties={
1486
+ "service_name": event_context.get("service_name"),
1487
+ "target_service": host or None,
1488
+ "method": method.upper(),
1489
+ "path_template": path_template,
1490
+ "failure_type": failure_type,
1491
+ },
1492
+ metrics={
1493
+ "status_code": status_code,
1494
+ **duration_root_field,
1495
+ },
1496
+ )
1497
+ event["request_metadata"] = _build_request_metadata(
1498
+ context=event_context,
1499
+ method=method.upper(),
1500
+ host=host,
1501
+ path=path,
1502
+ path_template=path_template,
1503
+ protocol=scheme or None,
1504
+ query=query,
1505
+ headers=request_header_record,
1506
+ timeout_ms=timeout_ms,
1507
+ retry_count=retry_count,
1508
+ )
1509
+ event["request_parameters_snapshot_json"] = request_parameters_snapshot
1510
+ event["response_metadata"] = _build_response_metadata(
1511
+ context=event_context,
1512
+ method=method.upper(),
1513
+ host=host,
1514
+ path=path,
1515
+ path_template=path_template,
1516
+ protocol=scheme or None,
1517
+ status_code=status_code,
1518
+ duration_ms=duration_root_field["duration_ms"],
1519
+ headers=response_header_record,
1520
+ )
1521
+ event["response_parameters_snapshot_json"] = response_parameters_snapshot
1522
+ return event
1523
+
1524
+
1525
+ def build_backend_error_event(
1526
+ *,
1527
+ context: Mapping[str, JsonValue],
1528
+ handler_name: str | None,
1529
+ failure_type: str,
1530
+ error_class: str,
1531
+ message: str | None = None,
1532
+ exception: BaseException | None = None,
1533
+ ) -> dict[str, JsonValue]:
1534
+ message_for_fingerprint = message or error_class
1535
+ error_fingerprint = "err_" + hashlib.sha256(
1536
+ f"{error_class}:{message_for_fingerprint}".encode("utf-8"),
1537
+ ).hexdigest()[:16]
1538
+ stack_summary = extract_tb(exception.__traceback__) if exception is not None else []
1539
+ stack_signature = "|".join(
1540
+ f"{Path(frame.filename).name}:{frame.name}" for frame in stack_summary[-8:]
1541
+ )
1542
+ stack_fingerprint = (
1543
+ "stk_" + hashlib.sha256(stack_signature.encode("utf-8")).hexdigest()[:16]
1544
+ if stack_signature
1545
+ else None
1546
+ )
1547
+ top_frame = stack_summary[-1] if stack_summary else None
1548
+ top_frame_module = (
1549
+ f"{Path(top_frame.filename).stem}:{top_frame.name}"
1550
+ if top_frame is not None
1551
+ else None
1552
+ )
1553
+
1554
+ return _base_event(
1555
+ context=context,
1556
+ event_name=BACKEND_ERROR_EVENT,
1557
+ event_category=EVENT_CATEGORY_ERROR,
1558
+ status=BACKEND_STATUS_FAILED,
1559
+ surface_type=SURFACE_TYPE_API,
1560
+ privacy_level="default_masked",
1561
+ masking_state="masked",
1562
+ allowed_value_keys=[],
1563
+ root_fields={
1564
+ "handler_name": handler_name,
1565
+ "failure_type": failure_type,
1566
+ "error_class": error_class,
1567
+ "message_masked": build_mask_token("masked", message or error_class),
1568
+ "error_fingerprint": error_fingerprint,
1569
+ "stack_fingerprint": stack_fingerprint,
1570
+ "top_frame_module": top_frame_module,
1571
+ "stack_frame_count": len(stack_summary),
1572
+ },
1573
+ properties={
1574
+ "service_name": context.get("service_name"),
1575
+ "handler_name": handler_name,
1576
+ "failure_type": failure_type,
1577
+ "error_class": error_class,
1578
+ "message_masked": build_mask_token("masked", message or error_class),
1579
+ "error_fingerprint": error_fingerprint,
1580
+ "stack_fingerprint": stack_fingerprint,
1581
+ "top_frame_module": top_frame_module,
1582
+ "stack_frame_count": len(stack_summary),
1583
+ },
1584
+ )
1585
+
1586
+
1587
+ def build_celery_job_event(
1588
+ *,
1589
+ context: Mapping[str, JsonValue],
1590
+ status: str,
1591
+ task_name: str,
1592
+ task_id: str | None = None,
1593
+ queue_name: str | None = None,
1594
+ duration_ms: int | None = None,
1595
+ failure_reason: str | None = None,
1596
+ correlation_id: str | None = None,
1597
+ message_id_masked: str | None = None,
1598
+ parent_request_span_id: str | None = None,
1599
+ delay_ms: int | None = None,
1600
+ attempt_count: int | None = None,
1601
+ max_attempts: int | None = None,
1602
+ failure_type: str | None = None,
1603
+ dlq_reason: str | None = None,
1604
+ detail_reason: str | None = None,
1605
+ ) -> dict[str, JsonValue]:
1606
+ event_context = merge_current_otel_span_context(
1607
+ context,
1608
+ expected_kind="CONSUMER",
1609
+ )
1610
+ event_name = JOB_EVENT_BY_STATUS[status]
1611
+ failure_reason_masked = (
1612
+ build_mask_token("masked", failure_reason)
1613
+ if status == BACKEND_STATUS_FAILED and failure_reason is not None
1614
+ else None
1615
+ )
1616
+ properties: dict[str, JsonValue] = {
1617
+ "service_name": context.get("service_name"),
1618
+ "job_id": task_id,
1619
+ "job_type": task_name,
1620
+ "queue_name": queue_name,
1621
+ "message_operation": "consume",
1622
+ "messaging_system": "celery",
1623
+ "message_type": task_name,
1624
+ "destination_name": queue_name,
1625
+ "message_id_masked": message_id_masked,
1626
+ "correlation_id": correlation_id,
1627
+ "parent_request_span_id": parent_request_span_id,
1628
+ "delay_ms": delay_ms,
1629
+ "attempt_count": attempt_count,
1630
+ "max_attempts": max_attempts,
1631
+ "failure_type": failure_type,
1632
+ "dlq_reason": dlq_reason,
1633
+ "detail_reason": detail_reason,
1634
+ }
1635
+ metrics = {"duration_ms": duration_ms} if duration_ms is not None else {}
1636
+ if delay_ms is not None:
1637
+ metrics["delay_ms"] = delay_ms
1638
+ if attempt_count is not None:
1639
+ metrics["attempt_count"] = attempt_count
1640
+ root_fields: dict[str, JsonValue] = {
1641
+ "job_id": task_id,
1642
+ "job_type": task_name,
1643
+ "queue_name": queue_name,
1644
+ "message_operation": "consume",
1645
+ "messaging_system": "celery",
1646
+ "message_type": task_name,
1647
+ "destination_name": queue_name,
1648
+ "message_id_masked": message_id_masked,
1649
+ "correlation_id": correlation_id,
1650
+ "parent_request_span_id": parent_request_span_id,
1651
+ "delay_ms": delay_ms,
1652
+ "attempt_count": attempt_count,
1653
+ "max_attempts": max_attempts,
1654
+ "failure_type": failure_type,
1655
+ "dlq_reason": dlq_reason,
1656
+ "detail_reason": detail_reason,
1657
+ }
1658
+ if status in {BACKEND_STATUS_FINISHED, BACKEND_STATUS_FAILED}:
1659
+ duration_root_fields = _duration_root_field(status, duration_ms)
1660
+ root_fields.update(duration_root_fields)
1661
+ metrics.update(duration_root_fields)
1662
+ if status == BACKEND_STATUS_FAILED:
1663
+ root_fields["failure_reason_masked"] = failure_reason_masked
1664
+ properties["failure_reason_masked"] = failure_reason_masked
1665
+ return _base_event(
1666
+ context=event_context,
1667
+ event_name=event_name,
1668
+ event_category=EVENT_CATEGORY_JOB,
1669
+ status=status,
1670
+ surface_type=SURFACE_TYPE_JOB,
1671
+ privacy_level="default_masked",
1672
+ masking_state="masked",
1673
+ allowed_value_keys=[],
1674
+ root_fields=root_fields,
1675
+ properties=properties,
1676
+ metrics=metrics,
1677
+ )
1678
+
1679
+
1680
+ def build_tool_call_event(
1681
+ *,
1682
+ context: Mapping[str, JsonValue],
1683
+ status: str,
1684
+ tool_provider: str,
1685
+ tool_name: str,
1686
+ tool_call_id: str | None = None,
1687
+ tool_schema_hash: str | None = None,
1688
+ validation_field_paths: Sequence[str] = (),
1689
+ duration_ms: int | None = None,
1690
+ failure_type: str | None = None,
1691
+ span_id: str | None = None,
1692
+ parent_span_id: str | None = None,
1693
+ ) -> dict[str, JsonValue]:
1694
+ event_context = _internal_flow_context(
1695
+ context=context,
1696
+ span_id=span_id,
1697
+ parent_span_id=parent_span_id,
1698
+ expected_otel_span_kind="INTERNAL",
1699
+ )
1700
+ normalized_provider = _string_or_none(tool_provider)
1701
+ normalized_name = _string_or_none(tool_name)
1702
+ if normalized_provider is None:
1703
+ raise ValueError("tool_provider is required")
1704
+ if normalized_name is None:
1705
+ raise ValueError("tool_name is required")
1706
+ operation_source_key = f"tool.{normalized_provider}.{normalized_name}"
1707
+ outcome_class, reason_code = _tool_call_outcome(
1708
+ status=status,
1709
+ failure_type=failure_type,
1710
+ )
1711
+ root_fields: dict[str, JsonValue] = {
1712
+ "tool_provider": normalized_provider,
1713
+ "tool_name": normalized_name,
1714
+ "tool_call_id": _string_or_none(tool_call_id),
1715
+ "tool_schema_hash": _string_or_none(tool_schema_hash),
1716
+ "operation_source_type": "tool_call",
1717
+ "operation_source_key": operation_source_key,
1718
+ "attempt_status": status,
1719
+ "validation_field_paths": list(validation_field_paths),
1720
+ }
1721
+ if outcome_class is not None:
1722
+ root_fields["outcome_class"] = outcome_class
1723
+ if reason_code is not None:
1724
+ root_fields["reason_code"] = reason_code
1725
+ if _string_or_none(failure_type) is not None:
1726
+ root_fields["failure_type"] = _string_or_none(failure_type)
1727
+ root_fields.update(_duration_root_field(status, duration_ms))
1728
+ properties = {
1729
+ "service_name": event_context.get("service_name"),
1730
+ "service_key": event_context.get("service_key"),
1731
+ "tool_provider": normalized_provider,
1732
+ "tool_name": normalized_name,
1733
+ "tool_schema_hash": _string_or_none(tool_schema_hash),
1734
+ "operation_source_type": "tool_call",
1735
+ "operation_source_key": operation_source_key,
1736
+ "attempt_status": status,
1737
+ "validation_field_paths": list(validation_field_paths),
1738
+ }
1739
+ if outcome_class is not None:
1740
+ properties["outcome_class"] = outcome_class
1741
+ if reason_code is not None:
1742
+ properties["reason_code"] = reason_code
1743
+ if _string_or_none(failure_type) is not None:
1744
+ properties["failure_type"] = _string_or_none(failure_type)
1745
+ return _base_event(
1746
+ context=event_context,
1747
+ event_name=TOOL_CALL_EVENT_BY_STATUS[status],
1748
+ event_category=EVENT_CATEGORY_TOOL_CALL,
1749
+ status=status,
1750
+ surface_type=SURFACE_TYPE_SERVICE,
1751
+ privacy_level="default_masked",
1752
+ masking_state="masked",
1753
+ allowed_value_keys=[],
1754
+ root_fields=root_fields,
1755
+ properties=properties,
1756
+ metrics=_duration_root_field(status, duration_ms),
1757
+ )
1758
+
1759
+
1760
+ def _tool_call_outcome(
1761
+ *,
1762
+ status: str,
1763
+ failure_type: str | None,
1764
+ ) -> tuple[str | None, str | None]:
1765
+ if status == BACKEND_STATUS_FINISHED:
1766
+ return "success", None
1767
+
1768
+ if status != BACKEND_STATUS_FAILED:
1769
+ return None, None
1770
+
1771
+ normalized_failure_type = _string_or_none(failure_type)
1772
+ if normalized_failure_type in {"validation", "validation_error", "schema_validation"}:
1773
+ return "validation_error", normalized_failure_type
1774
+ if normalized_failure_type in {
1775
+ "permission_denied",
1776
+ "auth_denied",
1777
+ "not_found",
1778
+ "conflict",
1779
+ "rate_limited",
1780
+ }:
1781
+ return normalized_failure_type, normalized_failure_type
1782
+ return "server_error", normalized_failure_type
1783
+
1784
+
1785
+ def build_domain_command_event(
1786
+ *,
1787
+ context: Mapping[str, JsonValue],
1788
+ status: str,
1789
+ command_key: str,
1790
+ command_label: str | None = None,
1791
+ duration_ms: int | None = None,
1792
+ span_id: str | None = None,
1793
+ parent_span_id: str | None = None,
1794
+ ) -> dict[str, JsonValue]:
1795
+ event_context = _internal_flow_context(
1796
+ context=context,
1797
+ span_id=span_id,
1798
+ parent_span_id=parent_span_id,
1799
+ expected_otel_span_kind="INTERNAL",
1800
+ )
1801
+ root_fields: dict[str, JsonValue] = {
1802
+ "command_key": command_key,
1803
+ "command_label": command_label,
1804
+ }
1805
+ root_fields.update(_duration_root_field(status, duration_ms))
1806
+ return _base_event(
1807
+ context=event_context,
1808
+ event_name=DOMAIN_COMMAND_EVENT_BY_STATUS[status],
1809
+ event_category=EVENT_CATEGORY_DOMAIN_COMMAND,
1810
+ status=status,
1811
+ surface_type=SURFACE_TYPE_SERVICE,
1812
+ privacy_level="default_masked",
1813
+ masking_state="masked",
1814
+ allowed_value_keys=[],
1815
+ root_fields=root_fields,
1816
+ properties={
1817
+ "service_name": event_context.get("service_name"),
1818
+ "command_key": command_key,
1819
+ "command_label": command_label,
1820
+ },
1821
+ metrics=_duration_root_field(status, duration_ms),
1822
+ )
1823
+
1824
+
1825
+ def build_repository_mutation_event(
1826
+ *,
1827
+ context: Mapping[str, JsonValue],
1828
+ status: str,
1829
+ mutation_key: str,
1830
+ mutation_kind: str,
1831
+ repository_name: str | None = None,
1832
+ changed_fields_schema: Sequence[str] = (),
1833
+ entity_id: object | None = None,
1834
+ duration_ms: int | None = None,
1835
+ span_id: str | None = None,
1836
+ parent_span_id: str | None = None,
1837
+ ) -> dict[str, JsonValue]:
1838
+ if mutation_kind not in BACKEND_MUTATION_KINDS:
1839
+ allowed_values = ", ".join(BACKEND_MUTATION_KINDS)
1840
+ raise ValueError(f"mutation_kind must be one of: {allowed_values}")
1841
+ event_context = _internal_flow_context(
1842
+ context=context,
1843
+ span_id=span_id,
1844
+ parent_span_id=parent_span_id,
1845
+ expected_otel_span_kind="CLIENT",
1846
+ )
1847
+ root_fields: dict[str, JsonValue] = {
1848
+ "mutation_key": mutation_key,
1849
+ "mutation_kind": mutation_kind,
1850
+ "repository_name": repository_name,
1851
+ "changed_fields_schema": list(changed_fields_schema),
1852
+ "entity_id_masked": _masked_identifier(entity_id),
1853
+ }
1854
+ root_fields.update(_duration_root_field(status, duration_ms))
1855
+ return _base_event(
1856
+ context=event_context,
1857
+ event_name=REPOSITORY_MUTATION_EVENT_BY_STATUS[status],
1858
+ event_category=EVENT_CATEGORY_REPOSITORY_MUTATION,
1859
+ status=status,
1860
+ surface_type=SURFACE_TYPE_SERVICE,
1861
+ privacy_level="default_masked",
1862
+ masking_state="masked",
1863
+ allowed_value_keys=[],
1864
+ root_fields=root_fields,
1865
+ properties={
1866
+ "service_name": event_context.get("service_name"),
1867
+ "mutation_key": mutation_key,
1868
+ "mutation_kind": mutation_kind,
1869
+ "repository_name": repository_name,
1870
+ "changed_fields_schema": list(changed_fields_schema),
1871
+ },
1872
+ metrics=_duration_root_field(status, duration_ms),
1873
+ )
1874
+
1875
+
1876
+ def build_state_transition_event(
1877
+ *,
1878
+ context: Mapping[str, JsonValue],
1879
+ status: str,
1880
+ transition_key: str,
1881
+ from_state: str,
1882
+ to_state: str,
1883
+ transition_axis: str | None = None,
1884
+ entity_id: object | None = None,
1885
+ duration_ms: int | None = None,
1886
+ span_id: str | None = None,
1887
+ parent_span_id: str | None = None,
1888
+ ) -> dict[str, JsonValue]:
1889
+ event_context = _internal_flow_context(
1890
+ context=context,
1891
+ span_id=span_id,
1892
+ parent_span_id=parent_span_id,
1893
+ expected_otel_span_kind="INTERNAL",
1894
+ )
1895
+ root_fields: dict[str, JsonValue] = {
1896
+ "transition_key": transition_key,
1897
+ "from_state": from_state,
1898
+ "to_state": to_state,
1899
+ "transition_axis": transition_axis,
1900
+ "entity_id_masked": _masked_identifier(entity_id),
1901
+ }
1902
+ root_fields.update(_duration_root_field(status, duration_ms))
1903
+ return _base_event(
1904
+ context=event_context,
1905
+ event_name=STATE_TRANSITION_EVENT_BY_STATUS[status],
1906
+ event_category=EVENT_CATEGORY_STATE_TRANSITION,
1907
+ status=status,
1908
+ surface_type=SURFACE_TYPE_SERVICE,
1909
+ privacy_level="default_masked",
1910
+ masking_state="masked",
1911
+ allowed_value_keys=[],
1912
+ root_fields=root_fields,
1913
+ properties={
1914
+ "service_name": event_context.get("service_name"),
1915
+ "transition_key": transition_key,
1916
+ "from_state": from_state,
1917
+ "to_state": to_state,
1918
+ "transition_axis": transition_axis,
1919
+ },
1920
+ metrics=_duration_root_field(status, duration_ms),
1921
+ )
1922
+
1923
+
1924
+ def _sanitize_properties(
1925
+ properties: Mapping[str, object] | None,
1926
+ denied_keys: Sequence[str],
1927
+ ) -> dict[str, JsonValue]:
1928
+ if properties is None:
1929
+ return {}
1930
+ sanitized = safe_sanitize_object(properties, denied_keys)
1931
+ if not isinstance(sanitized, dict):
1932
+ return {}
1933
+ result = dict(sanitized)
1934
+ result.pop("custom_event_name", None)
1935
+ result.pop("customEventName", None)
1936
+ return result
1937
+
1938
+
1939
+ def _sanitize_metrics(metrics: Mapping[str, object] | None) -> dict[str, int | float]:
1940
+ if metrics is None:
1941
+ return {}
1942
+ result: dict[str, int | float] = {}
1943
+ for key, value in metrics.items():
1944
+ if isinstance(value, bool):
1945
+ continue
1946
+ if isinstance(value, (int, float)) and math.isfinite(value):
1947
+ result[str(key)] = value
1948
+ return result
1949
+
1950
+
1951
+ def build_custom_event(
1952
+ *,
1953
+ context: Mapping[str, JsonValue],
1954
+ event_name: str,
1955
+ properties: Mapping[str, object] | None = None,
1956
+ metrics: Mapping[str, object] | None = None,
1957
+ denied_keys: Sequence[str] = (),
1958
+ ) -> dict[str, JsonValue]:
1959
+ custom_event_name = event_name.strip()
1960
+ if not custom_event_name:
1961
+ raise ValueError("event_name is required")
1962
+
1963
+ return _base_event(
1964
+ context=context,
1965
+ event_name=CUSTOM_EVENT_EMITTED,
1966
+ custom_event_name=custom_event_name,
1967
+ event_category=EVENT_CATEGORY_CUSTOM,
1968
+ status=BACKEND_STATUS_OBSERVED,
1969
+ surface_type=SURFACE_TYPE_SERVICE,
1970
+ privacy_level="default_masked",
1971
+ masking_state="masked",
1972
+ allowed_value_keys=[],
1973
+ properties=_sanitize_properties(properties, denied_keys),
1974
+ metrics=_sanitize_metrics(metrics),
1975
+ )
1976
+
1977
+
1978
+ def _summary_event_category(event_name: str) -> str:
1979
+ if event_name == REPOSITORY_MUTATION_SUMMARY_EVENT:
1980
+ return EVENT_CATEGORY_REPOSITORY_MUTATION
1981
+ if event_name == QUEUE_LIFECYCLE_SUMMARY_EVENT:
1982
+ return EVENT_CATEGORY_JOB
1983
+ if event_name == SDK_DIAGNOSTIC_EVENT:
1984
+ return EVENT_CATEGORY_CUSTOM
1985
+ return EVENT_CATEGORY_OUTBOUND_REQUEST
1986
+
1987
+
1988
+ def build_summary_event(
1989
+ *,
1990
+ context: Mapping[str, JsonValue],
1991
+ event_name: str,
1992
+ collector_name: str,
1993
+ aggregation_kind: str,
1994
+ summary_window_ms: int,
1995
+ summary_count: int,
1996
+ dropped_count: int = 0,
1997
+ sample_rate: float = 1.0,
1998
+ budget_window_ms: int | None = None,
1999
+ rate_limit_key: str | None = None,
2000
+ properties: Mapping[str, object] | None = None,
2001
+ metrics: Mapping[str, object] | None = None,
2002
+ denied_keys: Sequence[str] = (),
2003
+ ) -> dict[str, JsonValue]:
2004
+ if event_name not in {
2005
+ REPOSITORY_MUTATION_SUMMARY_EVENT,
2006
+ QUEUE_LIFECYCLE_SUMMARY_EVENT,
2007
+ DEPENDENCY_SUMMARY_EVENT,
2008
+ CACHE_OPERATION_SUMMARY_EVENT,
2009
+ APPLICATION_LOG_SUMMARY_EVENT,
2010
+ SDK_DIAGNOSTIC_EVENT,
2011
+ }:
2012
+ raise ValueError("event_name must be a backend summary event")
2013
+
2014
+ summary_metrics: dict[str, object] = {
2015
+ "summary_count": max(0, int(summary_count)),
2016
+ "dropped_count": max(0, int(dropped_count)),
2017
+ "sample_rate": max(0.0, min(1.0, float(sample_rate))),
2018
+ }
2019
+ if metrics is not None:
2020
+ summary_metrics.update(dict(metrics))
2021
+
2022
+ return _base_event(
2023
+ context=context,
2024
+ event_name=event_name,
2025
+ event_category=_summary_event_category(event_name),
2026
+ status=BACKEND_STATUS_OBSERVED,
2027
+ surface_type=SURFACE_TYPE_SERVICE,
2028
+ privacy_level="default_masked",
2029
+ masking_state="masked",
2030
+ allowed_value_keys=[],
2031
+ root_fields={
2032
+ "collector_name": collector_name,
2033
+ "aggregation_kind": aggregation_kind,
2034
+ "summary_window_ms": max(0, int(summary_window_ms)),
2035
+ "summary_count": max(0, int(summary_count)),
2036
+ "dropped_count": max(0, int(dropped_count)),
2037
+ "sample_rate": max(0.0, min(1.0, float(sample_rate))),
2038
+ "budget_window_ms": (
2039
+ max(0, int(budget_window_ms))
2040
+ if budget_window_ms is not None
2041
+ else None
2042
+ ),
2043
+ "rate_limit_key": _string_or_none(rate_limit_key),
2044
+ },
2045
+ properties=_sanitize_properties(properties, denied_keys),
2046
+ metrics=_sanitize_metrics(summary_metrics),
2047
+ )
2048
+
2049
+
2050
+ def build_sdk_diagnostic_event(
2051
+ *,
2052
+ context: Mapping[str, JsonValue],
2053
+ metrics: Mapping[str, object],
2054
+ capture_disabled_reason: str | None = None,
2055
+ instrumentation_active: Mapping[str, object] | None = None,
2056
+ last_flush_status: str | None = None,
2057
+ denied_keys: Sequence[str] = (),
2058
+ ) -> dict[str, JsonValue]:
2059
+ properties: dict[str, object] = {}
2060
+ if capture_disabled_reason is not None:
2061
+ properties["capture_disabled_reason"] = capture_disabled_reason
2062
+ if instrumentation_active is not None:
2063
+ properties["instrumentation_active"] = dict(instrumentation_active)
2064
+ if last_flush_status is not None:
2065
+ properties["last_flush_status"] = last_flush_status
2066
+
2067
+ return build_summary_event(
2068
+ context=context,
2069
+ event_name=SDK_DIAGNOSTIC_EVENT,
2070
+ collector_name="sdk_internal",
2071
+ aggregation_kind="diagnostic",
2072
+ summary_window_ms=0,
2073
+ summary_count=1,
2074
+ properties=properties,
2075
+ metrics=metrics,
2076
+ denied_keys=denied_keys,
2077
+ )
2078
+
2079
+
2080
+ def build_identity_identified_event(
2081
+ *,
2082
+ context: Mapping[str, JsonValue],
2083
+ user_id: str,
2084
+ traits: Mapping[str, object] | None = None,
2085
+ denied_keys: Sequence[str] = (),
2086
+ ) -> dict[str, JsonValue]:
2087
+ event_context = dict(context)
2088
+ event_context["user_id"] = user_id
2089
+ event_context["user_profile"] = build_subject_profile(traits)
2090
+ return build_custom_event(
2091
+ context=event_context,
2092
+ event_name=CUSTOM_EVENT_IDENTITY_IDENTIFIED,
2093
+ properties={
2094
+ "backend_scope": "identity",
2095
+ "identity_action": "identify",
2096
+ "profile_keys": _visible_trait_keys(traits, denied_keys),
2097
+ },
2098
+ metrics={"count": 1},
2099
+ denied_keys=denied_keys,
2100
+ )
2101
+
2102
+
2103
+ def build_account_associated_event(
2104
+ *,
2105
+ context: Mapping[str, JsonValue],
2106
+ account_id: str,
2107
+ traits: Mapping[str, object] | None = None,
2108
+ denied_keys: Sequence[str] = (),
2109
+ ) -> dict[str, JsonValue]:
2110
+ event_context = dict(context)
2111
+ event_context["account_id"] = account_id
2112
+ event_context["account_profile"] = build_subject_profile(traits)
2113
+ workspace_id = _workspace_id_from_traits(traits)
2114
+ if workspace_id is not None:
2115
+ event_context["workspace_id"] = workspace_id
2116
+ return build_custom_event(
2117
+ context=event_context,
2118
+ event_name=CUSTOM_EVENT_ACCOUNT_ASSOCIATED,
2119
+ properties={
2120
+ "backend_scope": "account",
2121
+ "account_action": "associate",
2122
+ "profile_keys": _visible_trait_keys(traits, denied_keys),
2123
+ },
2124
+ metrics={"count": 1},
2125
+ denied_keys=denied_keys,
2126
+ )
2127
+
2128
+
2129
+ def build_identity_logged_out_event(
2130
+ *,
2131
+ context: Mapping[str, JsonValue],
2132
+ reason: str | None = None,
2133
+ denied_keys: Sequence[str] = (),
2134
+ ) -> dict[str, JsonValue]:
2135
+ properties: dict[str, object] = {
2136
+ "backend_scope": "identity",
2137
+ "identity_action": "logout",
2138
+ }
2139
+ if reason is not None:
2140
+ properties["reason"] = reason
2141
+ return build_custom_event(
2142
+ context=context,
2143
+ event_name=CUSTOM_EVENT_IDENTITY_LOGGED_OUT,
2144
+ properties=properties,
2145
+ metrics={"count": 1},
2146
+ denied_keys=denied_keys,
2147
+ )
2148
+
2149
+
2150
+ def build_backend_envelope(
2151
+ *,
2152
+ project_key: str,
2153
+ environment: str,
2154
+ events: Sequence[Mapping[str, JsonValue]],
2155
+ producer_id: str | None = None,
2156
+ api_key: str | None = None,
2157
+ batch_id: str | None = None,
2158
+ idempotency_key: str | None = None,
2159
+ sent_at: str | None = None,
2160
+ ) -> dict[str, JsonValue]:
2161
+ envelope: dict[str, JsonValue] = {
2162
+ "project_key": project_key,
2163
+ "environment": environment,
2164
+ "producer_id": producer_id,
2165
+ "sdk_type": BACKEND_SDK_TYPE,
2166
+ "sdk_version": BACKEND_SDK_VERSION,
2167
+ "schema_version": BACKEND_SDK_SCHEMA_VERSION,
2168
+ "sent_at": sent_at or _now_iso(),
2169
+ "events": [dict(event) for event in events],
2170
+ }
2171
+ if api_key is not None:
2172
+ envelope["api_key"] = api_key
2173
+ if batch_id is not None:
2174
+ envelope["batch_id"] = batch_id
2175
+ if idempotency_key is not None:
2176
+ envelope["idempotency_key"] = idempotency_key
2177
+ return envelope