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.
Files changed (53) hide show
  1. {insider_python-0.1.6 → insider_python-0.1.8}/PKG-INFO +1 -1
  2. {insider_python-0.1.6 → insider_python-0.1.8}/pyproject.toml +1 -1
  3. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_version.py +1 -1
  4. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/client.py +12 -1
  5. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/asgi.py +29 -2
  6. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/handler.py +7 -0
  7. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/request.py +78 -0
  8. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/PKG-INFO +1 -1
  9. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_django_integration.py +25 -0
  10. {insider_python-0.1.6 → insider_python-0.1.8}/README.md +0 -0
  11. {insider_python-0.1.6 → insider_python-0.1.8}/setup.cfg +0 -0
  12. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/__init__.py +0 -0
  13. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_envelope.py +0 -0
  14. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/_footprint.py +0 -0
  15. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/__init__.py +0 -0
  16. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/__init__.py +0 -0
  17. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/apps.py +0 -0
  18. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/contrib/django/middleware.py +0 -0
  19. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/dsn.py +0 -0
  20. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/__init__.py +0 -0
  21. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/__init__.py +0 -0
  22. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/capture.py +0 -0
  23. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/drf.py +0 -0
  24. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/perf.py +0 -0
  25. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/signals.py +0 -0
  26. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/django/wsgi.py +0 -0
  27. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/__init__.py +0 -0
  28. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/handler.py +0 -0
  29. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/integrations/logging/levels.py +0 -0
  30. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/py.typed +0 -0
  31. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/safety.py +0 -0
  32. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/scope.py +0 -0
  33. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/scrubbing.py +0 -0
  34. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/stacktrace.py +0 -0
  35. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider/transport.py +0 -0
  36. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/SOURCES.txt +0 -0
  37. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/dependency_links.txt +0 -0
  38. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/requires.txt +0 -0
  39. {insider_python-0.1.6 → insider_python-0.1.8}/src/insider_python.egg-info/top_level.txt +0 -0
  40. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_asgi_integration.py +0 -0
  41. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture.py +0 -0
  42. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture_log.py +0 -0
  43. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_capture_perf.py +0 -0
  44. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_django.py +0 -0
  45. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_drf_integration.py +0 -0
  46. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_dsn.py +0 -0
  47. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_envelope.py +0 -0
  48. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_logging_integration.py +0 -0
  49. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_never_crash.py +0 -0
  50. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_safety.py +0 -0
  51. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_scrubbing.py +0 -0
  52. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_stacktrace.py +0 -0
  53. {insider_python-0.1.6 → insider_python-0.1.8}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: insider-python
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Python SDK for Insider — ship Beacons to your Insider server.
5
5
  Author: Insider
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "insider-python"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Python SDK for Insider — ship Beacons to your Insider server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -5,4 +5,4 @@ lookup on every beacon. Bump this and `[project].version` together when
5
5
  cutting a release.
6
6
  """
7
7
 
8
- __version__ = "0.1.6"
8
+ __version__ = "0.1.8"
@@ -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=str(request_ctx.get("user") or "anonymous"),
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: insider-python
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Python SDK for Insider — ship Beacons to your Insider server.
5
5
  Author: Insider
6
6
  License-Expression: MIT
@@ -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