insider-python 0.1.6__tar.gz → 0.1.8__tar.gz
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.
- {insider_python-0.1.6 → insider_python-0.1.8}/PKG-INFO +1 -1
- {insider_python-0.1.6 → insider_python-0.1.8}/pyproject.toml +1 -1
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_version.py +1 -1
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/client.py +12 -1
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/asgi.py +29 -2
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/handler.py +7 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/request.py +78 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/PKG-INFO +1 -1
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_django_integration.py +25 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/README.md +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/setup.cfg +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_envelope.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_footprint.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/apps.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/middleware.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/dsn.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/capture.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/drf.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/perf.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/signals.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/wsgi.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/__init__.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/handler.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/levels.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/py.typed +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/safety.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/scope.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/scrubbing.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/stacktrace.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/transport.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/SOURCES.txt +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/dependency_links.txt +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/requires.txt +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/top_level.txt +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_asgi_integration.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture_log.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture_perf.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_django.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_drf_integration.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_dsn.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_envelope.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_logging_integration.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_never_crash.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_safety.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_scrubbing.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_stacktrace.py +0 -0
- {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_transport.py +0 -0
|
@@ -37,6 +37,16 @@ VALID_KINDS = {"error", "perf", "log", "custom", "request"}
|
|
|
37
37
|
VALID_LEVELS = {"debug", "info", "warning", "error", "fatal"}
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _format_request_user(user_val: Any) -> str:
|
|
41
|
+
if not user_val:
|
|
42
|
+
return "anonymous"
|
|
43
|
+
if isinstance(user_val, dict):
|
|
44
|
+
uid = user_val.get("id")
|
|
45
|
+
if uid is not None:
|
|
46
|
+
return str(uid)
|
|
47
|
+
return str(user_val)
|
|
48
|
+
|
|
49
|
+
|
|
40
50
|
def _byte_len_footprint(obj: Dict[str, Any]) -> int:
|
|
41
51
|
try:
|
|
42
52
|
return len(json.dumps(obj, default=str, ensure_ascii=False).encode("utf-8"))
|
|
@@ -290,8 +300,9 @@ class Client:
|
|
|
290
300
|
request_id=trace_id or self.scope.current_trace_id(),
|
|
291
301
|
request_path=op.strip(),
|
|
292
302
|
request_method=method,
|
|
293
|
-
request_user=
|
|
303
|
+
request_user=_format_request_user(request_ctx.get("user")),
|
|
294
304
|
request_body=request_ctx.get("body"),
|
|
305
|
+
response_body=request_ctx.get("response_body"),
|
|
295
306
|
response_time=float(duration_ms),
|
|
296
307
|
status_code=code,
|
|
297
308
|
system_logs=logs or None,
|
|
@@ -19,7 +19,7 @@ from ...client import _client
|
|
|
19
19
|
from ...safety import debug, safe
|
|
20
20
|
from ...stacktrace import exception_payload
|
|
21
21
|
from .perf import emit_http_footprint
|
|
22
|
-
from .request import build_request_ctx_from_scope
|
|
22
|
+
from .request import build_request_ctx_from_scope, parse_response_body_text
|
|
23
23
|
|
|
24
24
|
ASGIApp = Callable[..., Any]
|
|
25
25
|
|
|
@@ -95,11 +95,29 @@ class _InsiderAsgiHttpWrapper:
|
|
|
95
95
|
|
|
96
96
|
start = time.perf_counter()
|
|
97
97
|
status_code: Optional[int] = None
|
|
98
|
+
response_content_type = ""
|
|
99
|
+
response_chunks: list[bytes] = []
|
|
100
|
+
response_bytes = 0
|
|
101
|
+
max_response_bytes = 8192
|
|
98
102
|
|
|
99
103
|
async def send_wrapper(message: Dict[str, Any]) -> None:
|
|
100
|
-
nonlocal status_code
|
|
104
|
+
nonlocal status_code, response_bytes, response_content_type
|
|
101
105
|
if message.get("type") == "http.response.start":
|
|
102
106
|
status_code = int(message.get("status", 500))
|
|
107
|
+
for key, value in message.get("headers") or []:
|
|
108
|
+
if key.lower() == b"content-type":
|
|
109
|
+
response_content_type = value.decode("latin-1")
|
|
110
|
+
break
|
|
111
|
+
elif (
|
|
112
|
+
client.send_default_pii
|
|
113
|
+
and message.get("type") == "http.response.body"
|
|
114
|
+
and response_bytes < max_response_bytes
|
|
115
|
+
):
|
|
116
|
+
chunk = message.get("body") or b""
|
|
117
|
+
if chunk:
|
|
118
|
+
room = max_response_bytes - response_bytes
|
|
119
|
+
response_chunks.append(chunk[:room])
|
|
120
|
+
response_bytes += min(len(chunk), room)
|
|
103
121
|
await send(message)
|
|
104
122
|
|
|
105
123
|
try:
|
|
@@ -111,6 +129,15 @@ class _InsiderAsgiHttpWrapper:
|
|
|
111
129
|
client.scope.set_pending_exception(block)
|
|
112
130
|
raise
|
|
113
131
|
finally:
|
|
132
|
+
if client.send_default_pii and response_chunks:
|
|
133
|
+
body_text = b"".join(response_chunks).decode("utf-8", errors="replace")
|
|
134
|
+
if response_bytes >= max_response_bytes:
|
|
135
|
+
body_text += "...[truncated]"
|
|
136
|
+
ctx = dict(client.scope.current_request() or {})
|
|
137
|
+
ctx["response_body"] = parse_response_body_text(
|
|
138
|
+
body_text, response_content_type
|
|
139
|
+
)
|
|
140
|
+
client.scope.set_request(ctx)
|
|
114
141
|
path = str(scope.get("path") or "/")
|
|
115
142
|
method = str(scope.get("method") or "GET")
|
|
116
143
|
emit_http_footprint(
|
|
@@ -24,6 +24,7 @@ from ...client import _client
|
|
|
24
24
|
from ...safety import debug, safe
|
|
25
25
|
from .perf import emit_request_envelope
|
|
26
26
|
from .capture import sync_pending_from_request
|
|
27
|
+
from .request import read_response_body
|
|
27
28
|
|
|
28
29
|
_patched = False
|
|
29
30
|
_auto_perf = True
|
|
@@ -45,6 +46,12 @@ def _finalize_request_cycle(
|
|
|
45
46
|
status_code = getattr(response, "status_code", None)
|
|
46
47
|
sync_pending_from_request(request)
|
|
47
48
|
if _auto_perf:
|
|
49
|
+
if client.send_default_pii and response is not None:
|
|
50
|
+
body = read_response_body(response)
|
|
51
|
+
if body is not None:
|
|
52
|
+
ctx = dict(client.scope.current_request() or {})
|
|
53
|
+
ctx["response_body"] = body
|
|
54
|
+
client.scope.set_request(ctx)
|
|
48
55
|
emit_request_envelope(
|
|
49
56
|
request,
|
|
50
57
|
duration_ms=duration_ms,
|
|
@@ -179,3 +179,81 @@ def _read_body(request: Any) -> Optional[str]:
|
|
|
179
179
|
return str(raw)
|
|
180
180
|
except Exception:
|
|
181
181
|
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_RESPONSE_BODY_MAX_BYTES = 8192
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_response_body_text(text: str, content_type: str = "") -> Any:
|
|
188
|
+
"""Decode captured body text; parse JSON API responses into objects."""
|
|
189
|
+
if not text:
|
|
190
|
+
return text
|
|
191
|
+
ct = content_type.lower()
|
|
192
|
+
if "application/json" in ct or ct.endswith("+json"):
|
|
193
|
+
try:
|
|
194
|
+
import json
|
|
195
|
+
|
|
196
|
+
return json.loads(text)
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
pass
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _response_content_type(response: Any) -> str:
|
|
203
|
+
try:
|
|
204
|
+
if hasattr(response, "get"):
|
|
205
|
+
value = response.get("Content-Type")
|
|
206
|
+
if value:
|
|
207
|
+
return str(value)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
try:
|
|
211
|
+
headers = getattr(response, "headers", None)
|
|
212
|
+
if headers is not None:
|
|
213
|
+
value = headers.get("Content-Type")
|
|
214
|
+
if value:
|
|
215
|
+
return str(value)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_response_body(
|
|
222
|
+
response: Any, *, max_bytes: int = _RESPONSE_BODY_MAX_BYTES
|
|
223
|
+
) -> Any:
|
|
224
|
+
"""Return response content; JSON responses are parsed to dict/list."""
|
|
225
|
+
if response is None:
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
from django.http import StreamingHttpResponse
|
|
229
|
+
|
|
230
|
+
if isinstance(response, StreamingHttpResponse):
|
|
231
|
+
return None
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
try:
|
|
235
|
+
content = getattr(response, "content", None)
|
|
236
|
+
if content is None:
|
|
237
|
+
return None
|
|
238
|
+
if not isinstance(content, (bytes, bytearray)):
|
|
239
|
+
return parse_response_body_text(str(content), _response_content_type(response))
|
|
240
|
+
raw = bytes(content)
|
|
241
|
+
truncated = len(raw) > max_bytes
|
|
242
|
+
if truncated:
|
|
243
|
+
raw = raw[:max_bytes]
|
|
244
|
+
text = raw.decode("utf-8", errors="replace")
|
|
245
|
+
if truncated:
|
|
246
|
+
text += "...[truncated]"
|
|
247
|
+
return parse_response_body_text(text, _response_content_type(response))
|
|
248
|
+
except Exception:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def format_request_user(user_val: Any) -> str:
|
|
253
|
+
if not user_val:
|
|
254
|
+
return "anonymous"
|
|
255
|
+
if isinstance(user_val, dict):
|
|
256
|
+
uid = user_val.get("id")
|
|
257
|
+
if uid is not None:
|
|
258
|
+
return str(uid)
|
|
259
|
+
return str(user_val)
|
|
@@ -43,6 +43,31 @@ def test_integration_captures_view_exception(client, fake_transport):
|
|
|
43
43
|
assert fp["stack_trace"]["value"] == "intentional explosion"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
@pytest.mark.django_db
|
|
47
|
+
def test_integration_captures_response_body_when_pii_enabled(
|
|
48
|
+
sdk_client, client, fake_transport
|
|
49
|
+
):
|
|
50
|
+
sdk_client.send_default_pii = True
|
|
51
|
+
response = client.get("/ok/")
|
|
52
|
+
assert response.status_code == 200
|
|
53
|
+
assert len(fake_transport.envelopes) == 1
|
|
54
|
+
assert fake_transport.envelopes[0].get("response_body") == "ok"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.django_db
|
|
58
|
+
def test_integration_captures_json_response_body_as_object(
|
|
59
|
+
sdk_client, client, fake_transport
|
|
60
|
+
):
|
|
61
|
+
sdk_client.send_default_pii = True
|
|
62
|
+
response = client.get("/json/")
|
|
63
|
+
assert response.status_code == 200
|
|
64
|
+
assert len(fake_transport.envelopes) == 1
|
|
65
|
+
assert fake_transport.envelopes[0].get("response_body") == {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"count": 2,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
46
71
|
@pytest.mark.django_db
|
|
47
72
|
def test_integration_clean_request_emits_one_footprint(client, fake_transport):
|
|
48
73
|
response = client.get("/ok/")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|