openbox-sdk-python 0.2.0__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.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,840 @@
1
+ """HTTP wrappers — requests + httpx (sync/async) via OTel instrumentor hooks.
2
+
3
+ The instrumentor creates the OTel span and invokes our request hook BEFORE
4
+ the real request is sent; raising from the hook (via hook runtime -> adapter)
5
+ prevents the request. The wrapper itself never interprets verdicts.
6
+
7
+ Self-instrumentation guard: URLs under any ignored prefix (always including
8
+ the OpenBox ``api_url``) are skipped so evaluate calls never govern
9
+ themselves (no recursion).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import contextvars
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ from ..contracts.otel_spans import HookType
21
+ from .shared import get_hook_runtime
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ __all__ = [
26
+ "set_ignored_url_prefixes",
27
+ "should_ignore_url",
28
+ "sanitize_headers",
29
+ "install_requests",
30
+ "uninstall_requests",
31
+ "install_httpx",
32
+ "uninstall_httpx",
33
+ "install_httpx_body_capture",
34
+ "uninstall_httpx_body_capture",
35
+ "install_urllib3",
36
+ "uninstall_urllib3",
37
+ "install_urllib",
38
+ "uninstall_urllib",
39
+ ]
40
+
41
+ # Content-type markers safe to capture as a text body.
42
+ _TEXT_CONTENT_MARKERS = ("json", "text", "xml")
43
+
44
+
45
+ def _is_text_content_type(content_type: str | None) -> bool:
46
+ """True when the content type indicates text (safe to read as a body)."""
47
+ if not content_type:
48
+ return True # assume text when unspecified
49
+ return any(marker in content_type.lower() for marker in _TEXT_CONTENT_MARKERS)
50
+
51
+
52
+ _ignored_url_prefixes: set[str] = set()
53
+
54
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
55
+
56
+
57
+ def _normalize_url_prefix(url: str) -> str:
58
+ """Canonical prefix form: lowercase scheme/host, explicit default port,
59
+ no trailing slash. httpx normalizes request URLs (lowercased host,
60
+ default port stripped) — comparing RAW config strings against that would
61
+ miss e.g. ``https://Core.example`` vs ``https://core.example`` and let
62
+ the evaluate call govern itself (unbounded recursion)."""
63
+ from urllib.parse import urlsplit
64
+
65
+ try:
66
+ parts = urlsplit(url)
67
+ except ValueError:
68
+ return url.rstrip("/")
69
+ if not parts.scheme or not parts.hostname:
70
+ return url.rstrip("/")
71
+ scheme = parts.scheme.lower()
72
+ port = parts.port if parts.port is not None else _DEFAULT_PORTS.get(scheme)
73
+ netloc = parts.hostname.lower() + (f":{port}" if port is not None else "")
74
+ return f"{scheme}://{netloc}{parts.path}".rstrip("/")
75
+
76
+
77
+ # span_id -> perf_counter at request start (duration for the response hook).
78
+ _HOOK_TIMINGS_MAX = 4096
79
+ _hook_timings: dict[int, float] = {}
80
+
81
+
82
+ def set_ignored_url_prefixes(prefixes: set[str]) -> None:
83
+ global _ignored_url_prefixes
84
+ _ignored_url_prefixes = {_normalize_url_prefix(p) for p in prefixes if p}
85
+
86
+
87
+ # Headers whose values are credentials/secrets — never ship them into
88
+ # governance payloads (they land in Core logs verbatim otherwise).
89
+ _SENSITIVE_HEADERS = frozenset(
90
+ {
91
+ "authorization",
92
+ "proxy-authorization",
93
+ "cookie",
94
+ "set-cookie",
95
+ "x-api-key",
96
+ "api-key",
97
+ "x-auth-token",
98
+ "x-amz-security-token",
99
+ }
100
+ )
101
+
102
+
103
+ def sanitize_headers(headers: Any) -> dict | None:
104
+ """Copy headers with credential values redacted; bytes decoded."""
105
+ if not headers:
106
+ return None
107
+ sanitized = {}
108
+ for key, value in dict(headers).items():
109
+ if isinstance(key, bytes):
110
+ key = key.decode("latin-1", errors="ignore")
111
+ if isinstance(value, bytes):
112
+ value = value.decode("latin-1", errors="ignore")
113
+ sanitized[key] = "[REDACTED]" if str(key).lower() in _SENSITIVE_HEADERS else value
114
+ return sanitized
115
+
116
+
117
+ def should_ignore_url(url: str | None) -> bool:
118
+ if not url:
119
+ return True
120
+ normalized = _normalize_url_prefix(url)
121
+ return any(normalized.startswith(prefix) for prefix in _ignored_url_prefixes)
122
+
123
+
124
+ def _record_timing(span: Any) -> None:
125
+ try:
126
+ span_id = span.get_span_context().span_id
127
+ except Exception:
128
+ return
129
+ if len(_hook_timings) >= _HOOK_TIMINGS_MAX:
130
+ _hook_timings.clear()
131
+ _hook_timings[span_id] = time.perf_counter()
132
+
133
+
134
+ def _pop_duration_ns(span: Any) -> int | None:
135
+ try:
136
+ span_id = span.get_span_context().span_id
137
+ except Exception:
138
+ return None
139
+ started = _hook_timings.pop(span_id, None)
140
+ return int((time.perf_counter() - started) * 1e9) if started is not None else None
141
+
142
+
143
+ # ── requests ─────────────────────────────────────────────────────────────────
144
+
145
+
146
+ def _requests_request_hook(span: Any, request: Any) -> None:
147
+ runtime = get_hook_runtime()
148
+ if runtime is None:
149
+ return
150
+ url = str(getattr(request, "url", "") or "")
151
+ if should_ignore_url(url):
152
+ return
153
+ body = None
154
+ try:
155
+ raw = getattr(request, "body", None)
156
+ if raw:
157
+ body = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw)
158
+ except Exception:
159
+ pass
160
+ headers = sanitize_headers(getattr(request, "headers", None))
161
+ _record_timing(span)
162
+ runtime.preflight(
163
+ span,
164
+ hook_type=HookType.HTTP_REQUEST,
165
+ identifier=url,
166
+ fields={
167
+ "http_method": getattr(request, "method", None) or "UNKNOWN",
168
+ "http_url": url,
169
+ "request_body": body,
170
+ "request_headers": headers,
171
+ },
172
+ )
173
+
174
+
175
+ def _requests_body(request: Any) -> str | None:
176
+ """Best-effort request body from a requests PreparedRequest."""
177
+ try:
178
+ raw = getattr(request, "body", None)
179
+ if not raw:
180
+ return None
181
+ return (
182
+ raw.decode("utf-8", errors="ignore")
183
+ if isinstance(raw, (bytes, bytearray))
184
+ else str(raw)
185
+ )
186
+ except Exception:
187
+ return None
188
+
189
+
190
+ def _requests_response_hook(span: Any, request: Any, response: Any) -> None:
191
+ runtime = get_hook_runtime()
192
+ if runtime is None:
193
+ return
194
+ url = str(getattr(request, "url", "") or "")
195
+ if should_ignore_url(url):
196
+ return
197
+ status_code = getattr(response, "status_code", None)
198
+ body = None
199
+ try:
200
+ content_type = response.headers.get("content-type", "") if response.headers else ""
201
+ if _is_text_content_type(content_type):
202
+ body = response.text
203
+ except Exception:
204
+ pass
205
+ # Completed retains the request body/headers alongside the response so the
206
+ # stage carries the full exchange.
207
+ runtime.completed(
208
+ span,
209
+ hook_type=HookType.HTTP_REQUEST,
210
+ fields={
211
+ "http_method": getattr(request, "method", None) or "UNKNOWN",
212
+ "http_url": url,
213
+ "http_status_code": status_code,
214
+ "request_body": _requests_body(request),
215
+ "request_headers": sanitize_headers(getattr(request, "headers", None)),
216
+ "response_body": body,
217
+ "response_headers": sanitize_headers(getattr(response, "headers", None)),
218
+ "duration_ns": _pop_duration_ns(span),
219
+ "error": f"HTTP {status_code}" if status_code and status_code >= 400 else None,
220
+ },
221
+ )
222
+
223
+
224
+ def install_requests() -> bool:
225
+ try:
226
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
227
+ except ImportError:
228
+ logger.info("requests instrumentation not available (install extra [http]) — deferred")
229
+ return False
230
+ RequestsInstrumentor().instrument(
231
+ request_hook=_requests_request_hook, response_hook=_requests_response_hook
232
+ )
233
+ return True
234
+
235
+
236
+ def uninstall_requests() -> None:
237
+ try:
238
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
239
+
240
+ RequestsInstrumentor().uninstrument()
241
+ except Exception:
242
+ logger.debug("requests uninstrument skipped", exc_info=True)
243
+
244
+
245
+ # ── httpx (sync + async) ─────────────────────────────────────────────────────
246
+ #
247
+ # httpx bodies are NOT available in the OTel hooks: the request hook sees an
248
+ # unread stream and the response hook sees a ResponseInfo whose stream cannot be
249
+ # consumed safely. The send patch owns both stages so prebuilt/custom clients
250
+ # still get deterministic started + completed governance. The OTel request hook
251
+ # remains as a compatibility fallback for environments where the send patch is
252
+ # not installed.
253
+
254
+ # Task/thread-local stash: the request hook publishes the httpx CLIENT span here
255
+ # so the send patch (running after that span has ended) attaches the completed
256
+ # event to the SAME span identity.
257
+ _httpx_span_var: contextvars.ContextVar = contextvars.ContextVar("_httpx_span", default=None)
258
+ _httpx_patch_span_var: contextvars.ContextVar = contextvars.ContextVar(
259
+ "_httpx_patch_span", default=None
260
+ )
261
+
262
+
263
+ def _httpx_url(request_info: Any) -> str:
264
+ return str(getattr(request_info, "url", "") or "")
265
+
266
+
267
+ def _decode_method(method: Any) -> str:
268
+ """httpx methods arrive as bytes (b"POST"); plain str() would mangle them."""
269
+ if isinstance(method, bytes):
270
+ return method.decode("ascii", errors="ignore") or "UNKNOWN"
271
+ return str(method) if method else "UNKNOWN"
272
+
273
+
274
+ def _httpx_request_body(request_info: Any) -> str | None:
275
+ """Best-effort request body from an OTel httpx RequestInfo.
276
+
277
+ Reads ONLY already-buffered bytes (stream ``_stream``/``body``/``_body`` or
278
+ a bytes ``_content``) — never triggers a property that could consume a live
279
+ stream.
280
+ """
281
+ try:
282
+ raw: Any = None
283
+ stream = getattr(request_info, "stream", None)
284
+ if stream is not None:
285
+ for attr in ("_stream", "body", "_body"):
286
+ candidate = getattr(stream, attr, None)
287
+ if isinstance(candidate, (bytes, bytearray)):
288
+ raw = candidate
289
+ break
290
+ if raw is None and isinstance(stream, (bytes, bytearray)):
291
+ raw = stream
292
+ if raw is None:
293
+ content = getattr(request_info, "_content", None)
294
+ if isinstance(content, (bytes, bytearray)):
295
+ raw = content
296
+ if raw is None:
297
+ return None
298
+ return bytes(raw).decode("utf-8", errors="ignore")
299
+ except Exception:
300
+ return None
301
+
302
+
303
+ def _httpx_started_fields(request_info: Any) -> dict:
304
+ return {
305
+ "http_method": _decode_method(getattr(request_info, "method", b"")),
306
+ "http_url": _httpx_url(request_info),
307
+ "request_headers": sanitize_headers(getattr(request_info, "headers", None)),
308
+ "request_body": _httpx_request_body(request_info),
309
+ }
310
+
311
+
312
+ def _httpx_request_hook(span: Any, request_info: Any) -> None:
313
+ runtime = get_hook_runtime()
314
+ if runtime is None:
315
+ return
316
+ if _httpx_patch_span_var.get(None) is not None:
317
+ return
318
+ url = _httpx_url(request_info)
319
+ if should_ignore_url(url):
320
+ return
321
+ # Stash the span for the send patch's completed stage, then preflight.
322
+ _httpx_span_var.set(span)
323
+ runtime.preflight(
324
+ span,
325
+ hook_type=HookType.HTTP_REQUEST,
326
+ identifier=url,
327
+ fields=_httpx_started_fields(request_info),
328
+ )
329
+
330
+
331
+ async def _httpx_async_request_hook(span: Any, request_info: Any) -> None:
332
+ runtime = get_hook_runtime()
333
+ if runtime is None:
334
+ return
335
+ if _httpx_patch_span_var.get(None) is not None:
336
+ return
337
+ url = _httpx_url(request_info)
338
+ if should_ignore_url(url):
339
+ return
340
+ _httpx_span_var.set(span)
341
+ await runtime.apreflight(
342
+ span,
343
+ hook_type=HookType.HTTP_REQUEST,
344
+ identifier=url,
345
+ fields=_httpx_started_fields(request_info),
346
+ )
347
+
348
+
349
+ def install_httpx() -> bool:
350
+ try:
351
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
352
+ except ImportError:
353
+ logger.info("httpx instrumentation not available (install extra [http]) — deferred")
354
+ return False
355
+ # Only request hooks: completed is emitted by the send patch (see below).
356
+ HTTPXClientInstrumentor().instrument(
357
+ request_hook=_httpx_request_hook,
358
+ async_request_hook=_httpx_async_request_hook,
359
+ )
360
+ return True
361
+
362
+
363
+ def uninstall_httpx() -> None:
364
+ try:
365
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
366
+
367
+ HTTPXClientInstrumentor().uninstrument()
368
+ except Exception:
369
+ logger.debug("httpx uninstrument skipped", exc_info=True)
370
+
371
+
372
+ # ── httpx body capture (Client.send / AsyncClient.send patch) ────────────────
373
+ #
374
+ # Separate from OTel instrumentation: OTel hooks receive streams that cannot be
375
+ # consumed. Patching send lets us read the request body (buffered before send)
376
+ # and the response body (cached by httpx after a non-streaming send) safely.
377
+
378
+ _original_httpx_send: Any = None
379
+ _original_httpx_async_send: Any = None
380
+
381
+
382
+ def _pop_httpx_span() -> Any:
383
+ """Retrieve + clear the span stashed by the request hook (fallback: current
384
+ span, so completed still resolves context if the stash missed)."""
385
+ span = _httpx_span_var.get(None)
386
+ _httpx_span_var.set(None)
387
+ if span is None:
388
+ from opentelemetry import trace
389
+
390
+ span = trace.get_current_span()
391
+ return span
392
+
393
+
394
+ def _capture_httpx_request(request: Any) -> tuple[str | None, dict | None]:
395
+ """(request_body, request_headers) — reads only buffered content."""
396
+ body = None
397
+ headers = None
398
+ try:
399
+ raw = getattr(request, "_content", None)
400
+ if isinstance(raw, (bytes, bytearray)):
401
+ body = bytes(raw).decode("utf-8", errors="ignore")
402
+ headers = sanitize_headers(getattr(request, "headers", None))
403
+ except Exception:
404
+ pass
405
+ return body, headers
406
+
407
+
408
+ def _start_httpx_span(request: Any) -> Any:
409
+ from opentelemetry import trace
410
+
411
+ method = _decode_method(getattr(request, "method", None))
412
+ url = str(getattr(request, "url", "") or "")
413
+ span = trace.get_tracer("openbox-core.httpx").start_span(
414
+ f"HTTP {method}", kind=trace.SpanKind.CLIENT
415
+ )
416
+ with contextlib.suppress(Exception):
417
+ span.set_attribute("http.request.method", method)
418
+ span.set_attribute("url.full", url)
419
+ return span
420
+
421
+
422
+ def _capture_httpx_response(response: Any) -> tuple[str | None, dict | None]:
423
+ """(response_body, response_headers) — never consumes a stream.
424
+
425
+ ``response.text`` raises ``ResponseNotRead`` for an unread streaming
426
+ response; that is caught, leaving the body None (safe, no consumption).
427
+ """
428
+ body = None
429
+ headers = None
430
+ try:
431
+ headers = sanitize_headers(getattr(response, "headers", None))
432
+ content_type = ""
433
+ try:
434
+ content_type = response.headers.get("content-type", "") if response.headers else ""
435
+ except Exception:
436
+ content_type = ""
437
+ if _is_text_content_type(content_type):
438
+ try:
439
+ body = response.text
440
+ except Exception:
441
+ body = None
442
+ except Exception:
443
+ pass
444
+ return body, headers
445
+
446
+
447
+ def _httpx_completed_fields(
448
+ request: Any,
449
+ response: Any,
450
+ duration_ns: int | None,
451
+ request_body: str | None,
452
+ request_headers: dict | None,
453
+ response_body: str | None,
454
+ response_headers: dict | None,
455
+ ) -> dict:
456
+ status_code = getattr(response, "status_code", None)
457
+ return {
458
+ "http_method": _decode_method(getattr(request, "method", None)),
459
+ "http_url": str(getattr(request, "url", "") or ""),
460
+ "http_status_code": status_code,
461
+ "request_body": request_body,
462
+ "request_headers": request_headers,
463
+ "response_body": response_body,
464
+ "response_headers": response_headers,
465
+ "duration_ns": duration_ns,
466
+ "error": f"HTTP {status_code}"
467
+ if isinstance(status_code, int) and status_code >= 400
468
+ else None,
469
+ }
470
+
471
+
472
+ def install_httpx_body_capture() -> bool:
473
+ """Patch httpx ``Client.send``/``AsyncClient.send`` for completed body
474
+ capture. Idempotent; must be installed AFTER ``install_httpx`` so the
475
+ captured original send already carries the OTel request hook."""
476
+ global _original_httpx_send, _original_httpx_async_send
477
+ if _original_httpx_send is not None:
478
+ return True
479
+ try:
480
+ import httpx
481
+ except ImportError:
482
+ logger.info("httpx not available for body capture — deferred")
483
+ return False
484
+
485
+ _original_httpx_send = httpx.Client.send
486
+ _original_httpx_async_send = httpx.AsyncClient.send
487
+
488
+ def _patched_send(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
489
+ runtime = get_hook_runtime()
490
+ url = str(getattr(request, "url", "") or "")
491
+ if runtime is None or should_ignore_url(url):
492
+ return _original_httpx_send(self, request, *args, **kwargs)
493
+ request_body, request_headers = _capture_httpx_request(request)
494
+ span = _start_httpx_span(request)
495
+ patch_token = _httpx_patch_span_var.set(span)
496
+ start = time.perf_counter()
497
+ try:
498
+ runtime.preflight(
499
+ span,
500
+ hook_type=HookType.HTTP_REQUEST,
501
+ identifier=url,
502
+ fields={
503
+ "http_method": _decode_method(getattr(request, "method", None)),
504
+ "http_url": url,
505
+ "request_headers": request_headers,
506
+ "request_body": request_body,
507
+ },
508
+ )
509
+ response = _original_httpx_send(self, request, *args, **kwargs)
510
+ duration_ns = int((time.perf_counter() - start) * 1e9)
511
+ response_body, response_headers = _capture_httpx_response(response)
512
+ runtime.completed(
513
+ span,
514
+ hook_type=HookType.HTTP_REQUEST,
515
+ fields=_httpx_completed_fields(
516
+ request,
517
+ response,
518
+ duration_ns,
519
+ request_body,
520
+ request_headers,
521
+ response_body,
522
+ response_headers,
523
+ ),
524
+ )
525
+ return response
526
+ finally:
527
+ _httpx_patch_span_var.reset(patch_token)
528
+ with contextlib.suppress(Exception):
529
+ span.end()
530
+
531
+ async def _patched_async_send(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
532
+ runtime = get_hook_runtime()
533
+ url = str(getattr(request, "url", "") or "")
534
+ if runtime is None or should_ignore_url(url):
535
+ return await _original_httpx_async_send(self, request, *args, **kwargs)
536
+ request_body, request_headers = _capture_httpx_request(request)
537
+ span = _start_httpx_span(request)
538
+ patch_token = _httpx_patch_span_var.set(span)
539
+ start = time.perf_counter()
540
+ try:
541
+ await runtime.apreflight(
542
+ span,
543
+ hook_type=HookType.HTTP_REQUEST,
544
+ identifier=url,
545
+ fields={
546
+ "http_method": _decode_method(getattr(request, "method", None)),
547
+ "http_url": url,
548
+ "request_headers": request_headers,
549
+ "request_body": request_body,
550
+ },
551
+ )
552
+ response = await _original_httpx_async_send(self, request, *args, **kwargs)
553
+ duration_ns = int((time.perf_counter() - start) * 1e9)
554
+ response_body, response_headers = _capture_httpx_response(response)
555
+ await runtime.acompleted(
556
+ span,
557
+ hook_type=HookType.HTTP_REQUEST,
558
+ fields=_httpx_completed_fields(
559
+ request,
560
+ response,
561
+ duration_ns,
562
+ request_body,
563
+ request_headers,
564
+ response_body,
565
+ response_headers,
566
+ ),
567
+ )
568
+ return response
569
+ finally:
570
+ _httpx_patch_span_var.reset(patch_token)
571
+ with contextlib.suppress(Exception):
572
+ span.end()
573
+
574
+ httpx.Client.send = _patched_send
575
+ httpx.AsyncClient.send = _patched_async_send
576
+ return True
577
+
578
+
579
+ def uninstall_httpx_body_capture() -> None:
580
+ """Restore httpx send (idempotent). Call BEFORE ``uninstall_httpx`` so the
581
+ original chain is unwound in reverse install order."""
582
+ global _original_httpx_send, _original_httpx_async_send
583
+ if _original_httpx_send is None:
584
+ return
585
+ try:
586
+ import httpx
587
+
588
+ httpx.Client.send = _original_httpx_send
589
+ httpx.AsyncClient.send = _original_httpx_async_send
590
+ except Exception:
591
+ logger.debug("httpx body-capture restore skipped", exc_info=True)
592
+ _original_httpx_send = None
593
+ _original_httpx_async_send = None
594
+
595
+
596
+ # ── urllib3 (OTel URLLib3Instrumentor hooks) ─────────────────────────────────
597
+ #
598
+ # ``requests`` runs on urllib3; OTel suppresses the nested urllib3 span while a
599
+ # RequestsInstrumentor call is active, so a requests call does NOT double-fire
600
+ # these hooks. urllib3-native calls reach here.
601
+
602
+ _urllib3_installed = False
603
+
604
+
605
+ def _urllib3_url(pool: Any, request_info: Any) -> str:
606
+ # The OTel urllib3 instrumentor resolves the FULL url on RequestInfo.url;
607
+ # use it as-is. Only reconstruct if we somehow got a bare path.
608
+ url = getattr(request_info, "url", None) or getattr(request_info, "request_url", None)
609
+ if isinstance(url, str) and url.startswith(("http://", "https://")):
610
+ return url
611
+ scheme = getattr(pool, "scheme", "http")
612
+ host = getattr(pool, "host", "unknown")
613
+ port = getattr(pool, "port", None)
614
+ path = url or "/"
615
+ if port and port not in (80, 443):
616
+ return f"{scheme}://{host}:{port}{path}"
617
+ return f"{scheme}://{host}{path}"
618
+
619
+
620
+ def _urllib3_body(request_info: Any) -> str | None:
621
+ try:
622
+ raw = getattr(request_info, "body", None)
623
+ if not raw:
624
+ return None
625
+ return (
626
+ raw.decode("utf-8", errors="ignore")
627
+ if isinstance(raw, (bytes, bytearray))
628
+ else str(raw)
629
+ )
630
+ except Exception:
631
+ return None
632
+
633
+
634
+ def _urllib3_request_hook(span: Any, pool: Any, request_info: Any) -> None:
635
+ runtime = get_hook_runtime()
636
+ if runtime is None:
637
+ return
638
+ url = _urllib3_url(pool, request_info)
639
+ if should_ignore_url(url):
640
+ return
641
+ _record_timing(span)
642
+ runtime.preflight(
643
+ span,
644
+ hook_type=HookType.HTTP_REQUEST,
645
+ identifier=url,
646
+ fields={
647
+ "http_method": getattr(request_info, "method", None) or "UNKNOWN",
648
+ "http_url": url,
649
+ "request_body": _urllib3_body(request_info),
650
+ "request_headers": sanitize_headers(getattr(request_info, "headers", None)),
651
+ },
652
+ )
653
+
654
+
655
+ def _urllib3_response_hook(span: Any, pool: Any, response: Any) -> None:
656
+ runtime = get_hook_runtime()
657
+ if runtime is None:
658
+ return
659
+ # The response hook has no request_info; the URL degrades to the host root.
660
+ # Read only ALREADY-BUFFERED content (``_body``, set when preload_content=True)
661
+ # so a live streaming response is never consumed.
662
+ scheme = getattr(pool, "scheme", "http")
663
+ host = getattr(pool, "host", "unknown")
664
+ port = getattr(pool, "port", None)
665
+ url = f"{scheme}://{host}:{port}/" if port and port not in (80, 443) else f"{scheme}://{host}/"
666
+ if should_ignore_url(url):
667
+ return
668
+ status_code = getattr(response, "status", None)
669
+ body = None
670
+ try:
671
+ content_type = (
672
+ response.headers.get("content-type", "") if getattr(response, "headers", None) else ""
673
+ )
674
+ if _is_text_content_type(content_type):
675
+ raw = getattr(response, "_body", None)
676
+ if isinstance(raw, (bytes, bytearray)):
677
+ body = bytes(raw).decode("utf-8", errors="ignore")
678
+ elif isinstance(raw, str):
679
+ body = raw
680
+ except Exception:
681
+ pass
682
+ runtime.completed(
683
+ span,
684
+ hook_type=HookType.HTTP_REQUEST,
685
+ fields={
686
+ "http_method": "UNKNOWN",
687
+ "http_url": url,
688
+ "http_status_code": status_code,
689
+ "response_body": body,
690
+ "response_headers": sanitize_headers(getattr(response, "headers", None)),
691
+ "duration_ns": _pop_duration_ns(span),
692
+ "error": f"HTTP {status_code}"
693
+ if isinstance(status_code, int) and status_code >= 400
694
+ else None,
695
+ },
696
+ )
697
+
698
+
699
+ def install_urllib3() -> bool:
700
+ global _urllib3_installed
701
+ if _urllib3_installed:
702
+ return True
703
+ try:
704
+ from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor
705
+ except ImportError:
706
+ logger.info("urllib3 instrumentation not available (install extra [http]) — deferred")
707
+ return False
708
+ URLLib3Instrumentor().instrument(
709
+ request_hook=_urllib3_request_hook, response_hook=_urllib3_response_hook
710
+ )
711
+ _urllib3_installed = True
712
+ return True
713
+
714
+
715
+ def uninstall_urllib3() -> None:
716
+ global _urllib3_installed
717
+ if not _urllib3_installed:
718
+ return
719
+ try:
720
+ from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor
721
+
722
+ URLLib3Instrumentor().uninstrument()
723
+ except Exception:
724
+ logger.debug("urllib3 uninstrument skipped", exc_info=True)
725
+ _urllib3_installed = False
726
+
727
+
728
+ # ── urllib (stdlib, OTel URLLibInstrumentor hooks) ───────────────────────────
729
+ #
730
+ # Response body is NOT captured: urllib's HTTPResponse.read() consumes the
731
+ # socket stream and would break the caller. Completed carries status only.
732
+
733
+ _urllib_installed = False
734
+
735
+
736
+ def _urllib_url(request: Any) -> str:
737
+ url = getattr(request, "full_url", None)
738
+ if url:
739
+ return str(url)
740
+ getter = getattr(request, "get_full_url", None)
741
+ if callable(getter):
742
+ try:
743
+ return str(getter())
744
+ except Exception:
745
+ return ""
746
+ return ""
747
+
748
+
749
+ def _urllib_method(request: Any) -> str:
750
+ getter = getattr(request, "get_method", None)
751
+ if callable(getter):
752
+ try:
753
+ return str(getter()) or "UNKNOWN"
754
+ except Exception:
755
+ pass
756
+ return str(getattr(request, "method", None) or "UNKNOWN")
757
+
758
+
759
+ def _urllib_request_hook(span: Any, request: Any) -> None:
760
+ runtime = get_hook_runtime()
761
+ if runtime is None:
762
+ return
763
+ url = _urllib_url(request)
764
+ if should_ignore_url(url):
765
+ return
766
+ _record_timing(span)
767
+ body = None
768
+ try:
769
+ raw = getattr(request, "data", None)
770
+ if raw:
771
+ body = (
772
+ raw.decode("utf-8", errors="ignore")
773
+ if isinstance(raw, (bytes, bytearray))
774
+ else str(raw)
775
+ )
776
+ except Exception:
777
+ pass
778
+ runtime.preflight(
779
+ span,
780
+ hook_type=HookType.HTTP_REQUEST,
781
+ identifier=url,
782
+ fields={
783
+ "http_method": _urllib_method(request),
784
+ "http_url": url,
785
+ "request_body": body,
786
+ "request_headers": sanitize_headers(getattr(request, "headers", None)),
787
+ },
788
+ )
789
+
790
+
791
+ def _urllib_response_hook(span: Any, request: Any, response: Any) -> None:
792
+ runtime = get_hook_runtime()
793
+ if runtime is None:
794
+ return
795
+ url = _urllib_url(request)
796
+ if should_ignore_url(url):
797
+ return
798
+ status_code = getattr(response, "status", None) or getattr(response, "code", None)
799
+ runtime.completed(
800
+ span,
801
+ hook_type=HookType.HTTP_REQUEST,
802
+ fields={
803
+ "http_method": _urllib_method(request),
804
+ "http_url": url,
805
+ "http_status_code": status_code,
806
+ "duration_ns": _pop_duration_ns(span),
807
+ "error": f"HTTP {status_code}"
808
+ if isinstance(status_code, int) and status_code >= 400
809
+ else None,
810
+ },
811
+ )
812
+
813
+
814
+ def install_urllib() -> bool:
815
+ global _urllib_installed
816
+ if _urllib_installed:
817
+ return True
818
+ try:
819
+ from opentelemetry.instrumentation.urllib import URLLibInstrumentor
820
+ except ImportError:
821
+ logger.info("urllib instrumentation not available (install extra [http]) — deferred")
822
+ return False
823
+ URLLibInstrumentor().instrument(
824
+ request_hook=_urllib_request_hook, response_hook=_urllib_response_hook
825
+ )
826
+ _urllib_installed = True
827
+ return True
828
+
829
+
830
+ def uninstall_urllib() -> None:
831
+ global _urllib_installed
832
+ if not _urllib_installed:
833
+ return
834
+ try:
835
+ from opentelemetry.instrumentation.urllib import URLLibInstrumentor
836
+
837
+ URLLibInstrumentor().uninstrument()
838
+ except Exception:
839
+ logger.debug("urllib uninstrument skipped", exc_info=True)
840
+ _urllib_installed = False