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,341 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from importlib import import_module
5
+
6
+ from .adapters import (
7
+ build_summary_event,
8
+ build_outbound_request_failed_event,
9
+ build_outbound_request_finished_event,
10
+ build_outbound_request_started_event,
11
+ )
12
+ from .contracts import DEPENDENCY_SUMMARY_EVENT, SDK_COLLECTION_MODE_STANDARD
13
+ from .http_extraction import _safe_urlsplit, to_path_template
14
+ from .otel_bridge import annotate_current_otel_span, build_otel_request_span_id, resolve_current_otel_span_context
15
+ from .runtime import (
16
+ add_event,
17
+ create_request_span_id,
18
+ create_span_id,
19
+ get_current_client,
20
+ get_current_context,
21
+ load_settings,
22
+ )
23
+
24
+ _patched = False
25
+ _original_request = None
26
+
27
+
28
+ def _read_context_string(context: object, key: str) -> str | None:
29
+ if isinstance(context, dict):
30
+ value = context.get(key)
31
+ return value if isinstance(value, str) and value else None
32
+ return None
33
+
34
+
35
+ def _with_propagation_headers(
36
+ headers: object,
37
+ *,
38
+ context: object,
39
+ span_id: str,
40
+ request_span_id: str,
41
+ ) -> dict[str, str]:
42
+ propagated: dict[str, str] = {}
43
+ if isinstance(headers, dict):
44
+ propagated.update({str(key): str(value) for key, value in headers.items()})
45
+
46
+ def set_if_absent(name: str, value: str | None) -> None:
47
+ if not value:
48
+ return
49
+ if any(existing.lower() == name.lower() for existing in propagated):
50
+ return
51
+ propagated[name] = value
52
+
53
+ set_if_absent("x-clue-trace-id", _read_context_string(context, "trace_id"))
54
+ set_if_absent("x-clue-parent-span-id", span_id)
55
+ set_if_absent("x-clue-request-id", _read_context_string(context, "request_id"))
56
+ set_if_absent("x-clue-request-span-id", request_span_id)
57
+ set_if_absent("x-clue-interaction-id", _read_context_string(context, "interaction_id"))
58
+ set_if_absent("x-clue-session-id", _read_context_string(context, "session_id"))
59
+ set_if_absent("x-clue-anonymous-id", _read_context_string(context, "anonymous_id"))
60
+ set_if_absent("x-clue-user-id", _read_context_string(context, "user_id"))
61
+ set_if_absent("x-clue-account-id", _read_context_string(context, "account_id"))
62
+ set_if_absent("x-clue-tab-id", _read_context_string(context, "tab_id"))
63
+ return propagated
64
+
65
+
66
+ def _status_class(status_code: int) -> str:
67
+ if status_code >= 500:
68
+ return "5xx"
69
+ if status_code >= 400:
70
+ return "4xx"
71
+ if status_code >= 300:
72
+ return "3xx"
73
+ if status_code >= 200:
74
+ return "2xx"
75
+ return "unknown"
76
+
77
+
78
+ def _duration_bucket(duration_ms: int) -> str:
79
+ if duration_ms < 100:
80
+ return "lt_100ms"
81
+ if duration_ms < 500:
82
+ return "100_499ms"
83
+ if duration_ms < 2_000:
84
+ return "500_1999ms"
85
+ if duration_ms < 5_000:
86
+ return "2000_4999ms"
87
+ return "gte_5000ms"
88
+
89
+
90
+ def _dependency_context(
91
+ context: object,
92
+ *,
93
+ span_id: str,
94
+ parent_span_id: str | None,
95
+ request_span_id: str,
96
+ ) -> dict[str, object]:
97
+ event_context = dict(context) if isinstance(context, dict) else {}
98
+ event_context["span_id"] = span_id
99
+ event_context["parent_span_id"] = parent_span_id
100
+ event_context["request_span_id"] = request_span_id
101
+ return event_context
102
+
103
+
104
+ def _build_dependency_summary_event(
105
+ *,
106
+ context: object,
107
+ method: str,
108
+ url: str,
109
+ status_code: int,
110
+ duration_ms: int,
111
+ span_id: str,
112
+ parent_span_id: str | None,
113
+ request_span_id: str,
114
+ denied_keys: tuple[str, ...],
115
+ ):
116
+ scheme, host, path, _query = _safe_urlsplit(url)
117
+ path_template = to_path_template(path)
118
+ return build_summary_event(
119
+ context=_dependency_context(
120
+ context,
121
+ span_id=span_id,
122
+ parent_span_id=parent_span_id,
123
+ request_span_id=request_span_id,
124
+ ),
125
+ event_name=DEPENDENCY_SUMMARY_EVENT,
126
+ collector_name="requests",
127
+ aggregation_kind="request",
128
+ summary_window_ms=0,
129
+ summary_count=1,
130
+ budget_window_ms=60_000,
131
+ rate_limit_key=f"dependency:{host or 'unknown'}:{_status_class(status_code)}",
132
+ properties={
133
+ "target_service": host or None,
134
+ "method": method.upper(),
135
+ "path_template": path_template,
136
+ "protocol": scheme or None,
137
+ "status_class": _status_class(status_code),
138
+ "duration_bucket": _duration_bucket(duration_ms),
139
+ },
140
+ metrics={
141
+ "status_code": status_code,
142
+ "duration_ms": duration_ms,
143
+ "normal_success_count": 1 if 200 <= status_code < 500 else 0,
144
+ },
145
+ denied_keys=denied_keys,
146
+ )
147
+
148
+
149
+ def _record_summary_metric(client: object, count: int = 1) -> None:
150
+ record = getattr(client, "record_events_summarized", None)
151
+ if callable(record):
152
+ record(count)
153
+
154
+
155
+ def instrument_requests() -> bool:
156
+ global _patched
157
+ global _original_request
158
+
159
+ if _patched:
160
+ return True
161
+
162
+ settings = load_settings()
163
+ if not settings.enabled or not settings.capture_outbound_requests:
164
+ return False
165
+
166
+ try:
167
+ sessions_module = import_module("requests.sessions")
168
+ except ImportError:
169
+ return False
170
+
171
+ session_class = getattr(sessions_module, "Session", None)
172
+ if session_class is None:
173
+ return False
174
+
175
+ original_request = getattr(session_class, "request", None)
176
+ if not callable(original_request):
177
+ return False
178
+
179
+ def wrapped_request(self, method, url, **kwargs):
180
+ context = get_current_context()
181
+ if context is None:
182
+ return original_request(self, method, url, **kwargs)
183
+
184
+ otel_span = resolve_current_otel_span_context("CLIENT")
185
+ parent_span_id = (
186
+ otel_span.get("parent_span_id") or None
187
+ if otel_span is not None
188
+ else (
189
+ context.get("span_id")
190
+ if isinstance(context.get("span_id"), str)
191
+ else None
192
+ )
193
+ )
194
+ span_id = (
195
+ otel_span["span_id"]
196
+ if otel_span is not None and otel_span.get("span_id")
197
+ else create_span_id()
198
+ )
199
+ request_span_id = (
200
+ build_otel_request_span_id(otel_span["trace_id"], otel_span["span_id"])
201
+ if otel_span is not None
202
+ else create_request_span_id()
203
+ )
204
+ annotate_current_otel_span(
205
+ {
206
+ "clue.request_span_id": request_span_id,
207
+ },
208
+ expected_kind="CLIENT",
209
+ )
210
+ request_headers = _with_propagation_headers(
211
+ kwargs.get("headers"),
212
+ context=context,
213
+ span_id=span_id,
214
+ request_span_id=request_span_id,
215
+ )
216
+ request_kwargs = {**kwargs, "headers": request_headers}
217
+ started_at = time.perf_counter()
218
+ if settings.sdk_collection_mode != SDK_COLLECTION_MODE_STANDARD:
219
+ add_event(
220
+ build_outbound_request_started_event(
221
+ context=context,
222
+ method=str(method),
223
+ url=str(url),
224
+ request_body=request_kwargs.get("json", request_kwargs.get("data")),
225
+ request_headers=request_headers,
226
+ timeout_ms=_timeout_ms(request_kwargs.get("timeout")),
227
+ allowed_value_paths=settings.allowed_value_paths,
228
+ denied_keys=settings.denied_keys,
229
+ span_id=span_id,
230
+ parent_span_id=parent_span_id,
231
+ request_span_id=request_span_id,
232
+ )
233
+ )
234
+ try:
235
+ response = original_request(self, method, url, **request_kwargs)
236
+ except Exception as exc:
237
+ add_event(
238
+ build_outbound_request_failed_event(
239
+ context=context,
240
+ method=str(method),
241
+ url=str(url),
242
+ failure_type=exc.__class__.__name__,
243
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
244
+ status_code=0,
245
+ request_body=request_kwargs.get("json", request_kwargs.get("data")),
246
+ request_headers=request_headers,
247
+ allowed_value_paths=settings.allowed_value_paths,
248
+ denied_keys=settings.denied_keys,
249
+ timeout_ms=_timeout_ms(request_kwargs.get("timeout")),
250
+ span_id=span_id,
251
+ parent_span_id=parent_span_id,
252
+ request_span_id=request_span_id,
253
+ )
254
+ )
255
+ raise
256
+
257
+ status_code = int(getattr(response, "status_code", 0) or 0)
258
+ duration_ms = int((time.perf_counter() - started_at) * 1000)
259
+ if (
260
+ settings.sdk_collection_mode == SDK_COLLECTION_MODE_STANDARD
261
+ and status_code < 500
262
+ and duration_ms < 2_000
263
+ ):
264
+ summary_event = _build_dependency_summary_event(
265
+ context=context,
266
+ method=str(method),
267
+ url=str(url),
268
+ status_code=status_code,
269
+ duration_ms=duration_ms,
270
+ span_id=span_id,
271
+ parent_span_id=parent_span_id,
272
+ request_span_id=request_span_id,
273
+ denied_keys=settings.denied_keys,
274
+ )
275
+ if add_event(summary_event):
276
+ _record_summary_metric(get_current_client())
277
+ else:
278
+ add_event(
279
+ build_outbound_request_finished_event(
280
+ context=context,
281
+ method=str(method),
282
+ url=str(url),
283
+ status_code=status_code,
284
+ duration_ms=duration_ms,
285
+ request_body=request_kwargs.get("json", request_kwargs.get("data")),
286
+ request_headers=request_headers,
287
+ response_body=_response_body(response),
288
+ response_headers=getattr(response, "headers", None),
289
+ allowed_value_paths=settings.allowed_value_paths,
290
+ denied_keys=settings.denied_keys,
291
+ timeout_ms=_timeout_ms(request_kwargs.get("timeout")),
292
+ span_id=span_id,
293
+ parent_span_id=parent_span_id,
294
+ request_span_id=request_span_id,
295
+ )
296
+ )
297
+ return response
298
+
299
+ _original_request = original_request
300
+ session_class.request = wrapped_request
301
+ _patched = True
302
+ return True
303
+
304
+
305
+ def reset_requests_instrumentation() -> None:
306
+ global _patched
307
+ global _original_request
308
+
309
+ if not _patched or _original_request is None:
310
+ return
311
+
312
+ try:
313
+ sessions_module = import_module("requests.sessions")
314
+ except ImportError:
315
+ return
316
+
317
+ session_class = getattr(sessions_module, "Session", None)
318
+ if session_class is None:
319
+ return
320
+
321
+ session_class.request = _original_request
322
+ _original_request = None
323
+ _patched = False
324
+
325
+
326
+ def _timeout_ms(value: object) -> int | None:
327
+ if isinstance(value, (int, float)):
328
+ return int(float(value) * 1000)
329
+ if isinstance(value, tuple) and value:
330
+ first = value[0]
331
+ if isinstance(first, (int, float)):
332
+ return int(float(first) * 1000)
333
+ return None
334
+
335
+
336
+ def _response_body(response: object) -> object:
337
+ if hasattr(response, "content"):
338
+ return getattr(response, "content")
339
+ if hasattr(response, "text"):
340
+ return getattr(response, "text")
341
+ return None
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class CluePythonResourceConfig:
8
+ service_name: str
9
+ project_key: str
10
+ environment: str
11
+ deployment_environment: str
12
+ service_type: str
13
+ producer_id: str = "python-service"
14
+ service_version: str | None = None
15
+ service_namespace: str | None = None
16
+
17
+
18
+ def build_resource_attributes(
19
+ config: CluePythonResourceConfig,
20
+ ) -> dict[str, str]:
21
+ attributes = {
22
+ "service.name": config.service_name,
23
+ "clue.producer_id": config.producer_id,
24
+ "deployment.environment": config.deployment_environment,
25
+ "clue.project_key": config.project_key,
26
+ "clue.environment": config.environment,
27
+ "clue.runtime_language": "python",
28
+ "clue.service_type": config.service_type,
29
+ }
30
+
31
+ if config.service_version:
32
+ attributes["service.version"] = config.service_version
33
+ if config.service_namespace:
34
+ attributes["service.namespace"] = config.service_namespace
35
+
36
+ return attributes
37
+
38
+
39
+ def serialize_resource_attributes(attributes: dict[str, str]) -> str:
40
+ return ",".join(
41
+ f"{key}={value}"
42
+ for key, value in sorted(attributes.items())
43
+ if value
44
+ )