clue-python-sdk-core 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clue_python_sdk_core/__init__.py +135 -0
- clue_python_sdk_core/adapters.py +2177 -0
- clue_python_sdk_core/bootstrap.py +166 -0
- clue_python_sdk_core/celery.py +734 -0
- clue_python_sdk_core/client.py +335 -0
- clue_python_sdk_core/contracts.py +224 -0
- clue_python_sdk_core/event_size.py +96 -0
- clue_python_sdk_core/http_extraction.py +146 -0
- clue_python_sdk_core/orm.py +534 -0
- clue_python_sdk_core/otel_bridge.py +164 -0
- clue_python_sdk_core/parameter_snapshot.py +152 -0
- clue_python_sdk_core/privacy.py +124 -0
- clue_python_sdk_core/requests_instrumentation.py +341 -0
- clue_python_sdk_core/resources.py +44 -0
- clue_python_sdk_core/runtime.py +894 -0
- clue_python_sdk_core-0.0.1.dist-info/METADATA +11 -0
- clue_python_sdk_core-0.0.1.dist-info/RECORD +19 -0
- clue_python_sdk_core-0.0.1.dist-info/WHEEL +5 -0
- clue_python_sdk_core-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_otel_request_span_id(trace_id: str, span_id: str) -> str:
|
|
8
|
+
return f"otel:{trace_id}:{span_id}"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_current_otel_span_context(
|
|
12
|
+
expected_kind: str | None = None,
|
|
13
|
+
) -> dict[str, str] | None:
|
|
14
|
+
try:
|
|
15
|
+
from opentelemetry import trace
|
|
16
|
+
except ImportError:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
span = trace.get_current_span()
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
if span is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
span_context_getter = getattr(span, "get_span_context", None)
|
|
28
|
+
if not callable(span_context_getter):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
span_context = span_context_getter()
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
if not getattr(span_context, "is_valid", False):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
kind_name = _span_kind_name(getattr(span, "kind", None))
|
|
40
|
+
if expected_kind is not None and kind_name != expected_kind:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
trace_id_raw = getattr(span_context, "trace_id", 0)
|
|
44
|
+
span_id_raw = getattr(span_context, "span_id", 0)
|
|
45
|
+
if not isinstance(trace_id_raw, int) or trace_id_raw <= 0:
|
|
46
|
+
return None
|
|
47
|
+
if not isinstance(span_id_raw, int) or span_id_raw <= 0:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
parent_context = getattr(span, "parent", None)
|
|
51
|
+
parent_span_id = None
|
|
52
|
+
if getattr(parent_context, "is_valid", False):
|
|
53
|
+
parent_span_id_raw = getattr(parent_context, "span_id", 0)
|
|
54
|
+
if isinstance(parent_span_id_raw, int) and parent_span_id_raw > 0:
|
|
55
|
+
parent_span_id = f"{parent_span_id_raw:016x}"
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"trace_id": f"{trace_id_raw:032x}",
|
|
59
|
+
"span_id": f"{span_id_raw:016x}",
|
|
60
|
+
"parent_span_id": parent_span_id or "",
|
|
61
|
+
"kind": kind_name or "",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def merge_current_otel_span_context(
|
|
66
|
+
context: Mapping[str, object],
|
|
67
|
+
*,
|
|
68
|
+
expected_kind: str | None = None,
|
|
69
|
+
use_otel_request_span_id: bool = False,
|
|
70
|
+
) -> dict[str, object]:
|
|
71
|
+
otel_context = resolve_current_otel_span_context(expected_kind)
|
|
72
|
+
if otel_context is None:
|
|
73
|
+
return dict(context)
|
|
74
|
+
|
|
75
|
+
merged = dict(context)
|
|
76
|
+
trace_id = otel_context["trace_id"]
|
|
77
|
+
span_id = otel_context["span_id"]
|
|
78
|
+
parent_span_id = otel_context.get("parent_span_id") or None
|
|
79
|
+
merged["trace_id"] = trace_id
|
|
80
|
+
merged["span_id"] = span_id
|
|
81
|
+
merged["parent_span_id"] = parent_span_id
|
|
82
|
+
if use_otel_request_span_id and not _non_empty_string(merged.get("request_span_id")):
|
|
83
|
+
merged["request_span_id"] = build_otel_request_span_id(trace_id, span_id)
|
|
84
|
+
return merged
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def annotate_current_otel_span(
|
|
88
|
+
attributes: Mapping[str, object],
|
|
89
|
+
*,
|
|
90
|
+
expected_kind: str | None = None,
|
|
91
|
+
) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
from opentelemetry import trace
|
|
94
|
+
except ImportError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
otel_context = resolve_current_otel_span_context(expected_kind)
|
|
98
|
+
if otel_context is None:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
span = trace.get_current_span()
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
if span is None:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
set_attribute = getattr(span, "set_attribute", None)
|
|
110
|
+
if not callable(set_attribute):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
wrote = False
|
|
114
|
+
for key, value in attributes.items():
|
|
115
|
+
if not isinstance(key, str) or not key.strip() or value is None:
|
|
116
|
+
continue
|
|
117
|
+
normalized = _normalize_attribute_value(value)
|
|
118
|
+
if normalized is None:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
set_attribute(key, normalized)
|
|
122
|
+
wrote = True
|
|
123
|
+
except Exception:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
return wrote
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _span_kind_name(kind: object) -> str | None:
|
|
130
|
+
name = getattr(kind, "name", None)
|
|
131
|
+
if isinstance(name, str) and name.strip():
|
|
132
|
+
return name.strip().upper()
|
|
133
|
+
if isinstance(kind, str) and kind.strip():
|
|
134
|
+
return kind.strip().upper()
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _non_empty_string(value: object) -> str | None:
|
|
139
|
+
return value if isinstance(value, str) and value else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _normalize_attribute_value(value: object) -> str | bool | int | float | Sequence[str | bool | int | float] | None:
|
|
143
|
+
if isinstance(value, bool):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, int):
|
|
146
|
+
return value
|
|
147
|
+
if isinstance(value, float):
|
|
148
|
+
return value if value == value else None
|
|
149
|
+
if isinstance(value, str):
|
|
150
|
+
return value
|
|
151
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
152
|
+
items: list[str | bool | int | float] = []
|
|
153
|
+
for entry in value:
|
|
154
|
+
normalized = _normalize_attribute_value(entry)
|
|
155
|
+
if normalized is None or isinstance(normalized, Sequence) and not isinstance(normalized, str):
|
|
156
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
157
|
+
items.append(normalized)
|
|
158
|
+
return tuple(items)
|
|
159
|
+
if isinstance(value, Mapping):
|
|
160
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
161
|
+
try:
|
|
162
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from .contracts import (
|
|
7
|
+
CAPTURE_MODE_ALLOWED_PLAINTEXT,
|
|
8
|
+
CAPTURE_MODE_FORBIDDEN,
|
|
9
|
+
CAPTURE_MODE_MASKED_FINGERPRINT,
|
|
10
|
+
)
|
|
11
|
+
from .http_extraction import _to_json_value
|
|
12
|
+
from .privacy import JsonValue, fingerprint_value, infer_value_type, is_denied_key
|
|
13
|
+
|
|
14
|
+
FORBIDDEN_PARAMETER_VALUE = "__forbidden__"
|
|
15
|
+
PARAMETER_FINGERPRINT_SECRET = "clue-backend-sdk"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalize_parameter_lookup_path(path: str) -> str:
|
|
19
|
+
return re.sub(r"\[\d+\]", "[]", path)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parameter_leaf(value: object, *, capture_mode: str) -> dict[str, JsonValue]:
|
|
23
|
+
normalized = _to_json_value(value)
|
|
24
|
+
if capture_mode == CAPTURE_MODE_ALLOWED_PLAINTEXT:
|
|
25
|
+
leaf_value: JsonValue = normalized
|
|
26
|
+
elif capture_mode == CAPTURE_MODE_MASKED_FINGERPRINT:
|
|
27
|
+
leaf_value = fingerprint_value(normalized, PARAMETER_FINGERPRINT_SECRET)
|
|
28
|
+
else:
|
|
29
|
+
leaf_value = FORBIDDEN_PARAMETER_VALUE
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"type": infer_value_type(normalized),
|
|
33
|
+
"value": leaf_value,
|
|
34
|
+
"capture_mode": capture_mode,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_parameter_snapshot(
|
|
39
|
+
value: object,
|
|
40
|
+
*,
|
|
41
|
+
allowed_paths: Sequence[str],
|
|
42
|
+
denied_keys: Sequence[str],
|
|
43
|
+
path: str = "",
|
|
44
|
+
) -> tuple[JsonValue, list[str]]:
|
|
45
|
+
normalized_allowed_paths = {
|
|
46
|
+
_normalize_parameter_lookup_path(str(entry))
|
|
47
|
+
for entry in allowed_paths
|
|
48
|
+
if str(entry).strip()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if isinstance(value, Mapping):
|
|
52
|
+
snapshot: dict[str, JsonValue] = {}
|
|
53
|
+
allowed_value_keys: list[str] = []
|
|
54
|
+
for key, child in value.items():
|
|
55
|
+
key_text = str(key)
|
|
56
|
+
child_path = f"{path}.{key_text}" if path else key_text
|
|
57
|
+
if is_denied_key(key_text, denied_keys):
|
|
58
|
+
snapshot[key_text] = _parameter_leaf(
|
|
59
|
+
child,
|
|
60
|
+
capture_mode=CAPTURE_MODE_FORBIDDEN,
|
|
61
|
+
)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
child_snapshot, child_allowed_value_keys = _build_parameter_snapshot(
|
|
65
|
+
child,
|
|
66
|
+
allowed_paths=allowed_paths,
|
|
67
|
+
denied_keys=denied_keys,
|
|
68
|
+
path=child_path,
|
|
69
|
+
)
|
|
70
|
+
snapshot[key_text] = child_snapshot
|
|
71
|
+
allowed_value_keys.extend(child_allowed_value_keys)
|
|
72
|
+
|
|
73
|
+
return snapshot, allowed_value_keys
|
|
74
|
+
|
|
75
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
76
|
+
items = list(value)
|
|
77
|
+
if not items:
|
|
78
|
+
return [], []
|
|
79
|
+
|
|
80
|
+
representative_snapshot, allowed_value_keys = _build_parameter_snapshot(
|
|
81
|
+
items[0],
|
|
82
|
+
allowed_paths=allowed_paths,
|
|
83
|
+
denied_keys=denied_keys,
|
|
84
|
+
path=f"{path}[]",
|
|
85
|
+
)
|
|
86
|
+
return [representative_snapshot], allowed_value_keys
|
|
87
|
+
|
|
88
|
+
normalized_path = _normalize_parameter_lookup_path(path)
|
|
89
|
+
capture_mode = (
|
|
90
|
+
CAPTURE_MODE_ALLOWED_PLAINTEXT
|
|
91
|
+
if normalized_path in normalized_allowed_paths
|
|
92
|
+
else CAPTURE_MODE_MASKED_FINGERPRINT
|
|
93
|
+
)
|
|
94
|
+
allowed_value_keys = [path] if capture_mode == CAPTURE_MODE_ALLOWED_PLAINTEXT and path else []
|
|
95
|
+
return _parameter_leaf(value, capture_mode=capture_mode), allowed_value_keys
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_request_parameter_snapshot(
|
|
99
|
+
*,
|
|
100
|
+
query: Mapping[str, JsonValue],
|
|
101
|
+
body: JsonValue | None,
|
|
102
|
+
allowed_value_paths: Sequence[str],
|
|
103
|
+
denied_keys: Sequence[str],
|
|
104
|
+
) -> tuple[dict[str, JsonValue], list[str], str, str]:
|
|
105
|
+
snapshot: dict[str, JsonValue] = {}
|
|
106
|
+
allowed_value_keys: list[str] = []
|
|
107
|
+
|
|
108
|
+
if body is not None:
|
|
109
|
+
body_snapshot, body_allowed_value_keys = _build_parameter_snapshot(
|
|
110
|
+
body,
|
|
111
|
+
allowed_paths=allowed_value_paths,
|
|
112
|
+
denied_keys=denied_keys,
|
|
113
|
+
path="body",
|
|
114
|
+
)
|
|
115
|
+
snapshot["body"] = body_snapshot
|
|
116
|
+
allowed_value_keys.extend(body_allowed_value_keys)
|
|
117
|
+
|
|
118
|
+
if query:
|
|
119
|
+
query_snapshot, query_allowed_value_keys = _build_parameter_snapshot(
|
|
120
|
+
query,
|
|
121
|
+
allowed_paths=allowed_value_paths,
|
|
122
|
+
denied_keys=denied_keys,
|
|
123
|
+
path="query",
|
|
124
|
+
)
|
|
125
|
+
snapshot["query"] = query_snapshot
|
|
126
|
+
allowed_value_keys.extend(query_allowed_value_keys)
|
|
127
|
+
|
|
128
|
+
privacy_level = "allowlisted" if allowed_value_keys else "default_masked"
|
|
129
|
+
masking_state = "partially_unmasked" if allowed_value_keys else "masked"
|
|
130
|
+
return snapshot, sorted(dict.fromkeys(allowed_value_keys)), privacy_level, masking_state
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_response_parameter_snapshot(
|
|
134
|
+
*,
|
|
135
|
+
body: JsonValue | None,
|
|
136
|
+
allowed_value_paths: Sequence[str],
|
|
137
|
+
denied_keys: Sequence[str],
|
|
138
|
+
) -> tuple[dict[str, JsonValue], list[str], str, str]:
|
|
139
|
+
if body is None:
|
|
140
|
+
return {}, [], "default_masked", "masked"
|
|
141
|
+
|
|
142
|
+
response_snapshot, allowed_value_keys = _build_parameter_snapshot(
|
|
143
|
+
body,
|
|
144
|
+
allowed_paths=allowed_value_paths,
|
|
145
|
+
denied_keys=denied_keys,
|
|
146
|
+
path="response",
|
|
147
|
+
)
|
|
148
|
+
privacy_level = "allowlisted" if allowed_value_keys else "default_masked"
|
|
149
|
+
masking_state = "partially_unmasked" if allowed_value_keys else "masked"
|
|
150
|
+
return {
|
|
151
|
+
"response": response_snapshot,
|
|
152
|
+
}, sorted(dict.fromkeys(allowed_value_keys)), privacy_level, masking_state
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Dict, List, Union
|
|
9
|
+
|
|
10
|
+
JsonScalar = Union[str, int, float, bool, None]
|
|
11
|
+
JsonValue = Union[JsonScalar, List["JsonValue"], Dict[str, "JsonValue"]]
|
|
12
|
+
|
|
13
|
+
MASK_TOKEN_KEY = "__clue_masked__"
|
|
14
|
+
|
|
15
|
+
DEFAULT_DENIED_KEYS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"authorization",
|
|
18
|
+
"cookie",
|
|
19
|
+
"set-cookie",
|
|
20
|
+
"password",
|
|
21
|
+
"passwd",
|
|
22
|
+
"secret",
|
|
23
|
+
"token",
|
|
24
|
+
"session",
|
|
25
|
+
"session_token",
|
|
26
|
+
"refresh_token",
|
|
27
|
+
"api_key",
|
|
28
|
+
"apikey",
|
|
29
|
+
"private_key",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_lookup_key(key: str) -> str:
|
|
35
|
+
with_underscores = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key.strip())
|
|
36
|
+
return re.sub(r"[^a-z0-9]+", "_", with_underscores.lower()).strip("_")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_denied_key(key: str, denied_keys: Sequence[str] = ()) -> bool:
|
|
40
|
+
normalized = _normalize_lookup_key(key)
|
|
41
|
+
if not normalized:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
denied = {
|
|
45
|
+
_normalize_lookup_key(entry)
|
|
46
|
+
for entry in [*DEFAULT_DENIED_KEYS, *denied_keys]
|
|
47
|
+
if _normalize_lookup_key(entry)
|
|
48
|
+
}
|
|
49
|
+
if normalized in denied:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
padded = f"_{normalized}_"
|
|
53
|
+
return any(f"_{entry}_" in padded for entry in denied)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_json_value(value: object) -> JsonValue:
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, (int, float)):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return value
|
|
65
|
+
if isinstance(value, bytes):
|
|
66
|
+
return value.decode("utf-8", errors="replace")
|
|
67
|
+
if isinstance(value, Mapping):
|
|
68
|
+
return {str(key): _to_json_value(child) for key, child in value.items()}
|
|
69
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
70
|
+
return [_to_json_value(child) for child in value]
|
|
71
|
+
return str(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def safe_sanitize_object(value: object, denied_keys: Sequence[str] = ()) -> JsonValue:
|
|
75
|
+
if isinstance(value, Mapping):
|
|
76
|
+
sanitized: dict[str, JsonValue] = {}
|
|
77
|
+
for key, child in value.items():
|
|
78
|
+
key_text = str(key)
|
|
79
|
+
if is_denied_key(key_text, denied_keys):
|
|
80
|
+
continue
|
|
81
|
+
sanitized[key_text] = safe_sanitize_object(child, denied_keys)
|
|
82
|
+
return sanitized
|
|
83
|
+
|
|
84
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
85
|
+
return [safe_sanitize_object(child, denied_keys) for child in value]
|
|
86
|
+
|
|
87
|
+
return _to_json_value(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def infer_value_type(value: object) -> str:
|
|
91
|
+
if value is None:
|
|
92
|
+
return "null"
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return "boolean"
|
|
95
|
+
if isinstance(value, (int, float)):
|
|
96
|
+
return "number"
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return "string"
|
|
99
|
+
if isinstance(value, bytes):
|
|
100
|
+
return "bytes"
|
|
101
|
+
if isinstance(value, Mapping):
|
|
102
|
+
return "object"
|
|
103
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
104
|
+
return "array"
|
|
105
|
+
return type(value).__name__
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_mask_token(mode: str, value: object) -> dict[str, JsonValue]:
|
|
109
|
+
return {
|
|
110
|
+
MASK_TOKEN_KEY: True,
|
|
111
|
+
"mode": mode,
|
|
112
|
+
"valueType": infer_value_type(value),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fingerprint_value(value: object, secret: str) -> str:
|
|
117
|
+
normalized = json.dumps(_to_json_value(value), sort_keys=True, separators=(",", ":"))
|
|
118
|
+
digest = hmac.new(
|
|
119
|
+
secret.encode("utf-8"),
|
|
120
|
+
normalized.encode("utf-8"),
|
|
121
|
+
hashlib.sha256,
|
|
122
|
+
).hexdigest()
|
|
123
|
+
return f"hmac:{digest}"
|
|
124
|
+
|