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.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- 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
|