insider-python 0.1.0__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 (33) hide show
  1. insider_python-0.1.0/PKG-INFO +125 -0
  2. insider_python-0.1.0/README.md +99 -0
  3. insider_python-0.1.0/pyproject.toml +50 -0
  4. insider_python-0.1.0/setup.cfg +4 -0
  5. insider_python-0.1.0/src/insider/__init__.py +37 -0
  6. insider_python-0.1.0/src/insider/_envelope.py +189 -0
  7. insider_python-0.1.0/src/insider/_version.py +8 -0
  8. insider_python-0.1.0/src/insider/client.py +344 -0
  9. insider_python-0.1.0/src/insider/contrib/__init__.py +0 -0
  10. insider_python-0.1.0/src/insider/contrib/django/__init__.py +27 -0
  11. insider_python-0.1.0/src/insider/contrib/django/apps.py +60 -0
  12. insider_python-0.1.0/src/insider/contrib/django/middleware.py +164 -0
  13. insider_python-0.1.0/src/insider/dsn.py +92 -0
  14. insider_python-0.1.0/src/insider/py.typed +0 -0
  15. insider_python-0.1.0/src/insider/safety.py +61 -0
  16. insider_python-0.1.0/src/insider/scope.py +54 -0
  17. insider_python-0.1.0/src/insider/scrubbing.py +91 -0
  18. insider_python-0.1.0/src/insider/stacktrace.py +153 -0
  19. insider_python-0.1.0/src/insider/transport.py +213 -0
  20. insider_python-0.1.0/src/insider_python.egg-info/PKG-INFO +125 -0
  21. insider_python-0.1.0/src/insider_python.egg-info/SOURCES.txt +31 -0
  22. insider_python-0.1.0/src/insider_python.egg-info/dependency_links.txt +1 -0
  23. insider_python-0.1.0/src/insider_python.egg-info/requires.txt +9 -0
  24. insider_python-0.1.0/src/insider_python.egg-info/top_level.txt +1 -0
  25. insider_python-0.1.0/tests/test_capture.py +107 -0
  26. insider_python-0.1.0/tests/test_django.py +101 -0
  27. insider_python-0.1.0/tests/test_dsn.py +57 -0
  28. insider_python-0.1.0/tests/test_envelope.py +95 -0
  29. insider_python-0.1.0/tests/test_never_crash.py +138 -0
  30. insider_python-0.1.0/tests/test_safety.py +44 -0
  31. insider_python-0.1.0/tests/test_scrubbing.py +55 -0
  32. insider_python-0.1.0/tests/test_stacktrace.py +99 -0
  33. insider_python-0.1.0/tests/test_transport.py +124 -0
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: insider-python
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Insider — ship Beacons to your Insider server.
5
+ Author: Insider
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Insider-Inc/insider-python
8
+ Keywords: insider,telemetry,errors,monitoring,sdk
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: urllib3>=1.26
20
+ Provides-Extra: django
21
+ Requires-Dist: django>=4.2; extra == "django"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Requires-Dist: pytest-django>=4.8; extra == "dev"
25
+ Requires-Dist: django>=4.2; extra == "dev"
26
+
27
+ # insider-python
28
+
29
+ The Python SDK for [Insider](https://insider.moraks.cloud/).
30
+
31
+ Beam Beacons from your Python service to your Insider server with a
32
+ one-line setup. No runtime overhead on your request path. Never raises
33
+ into your code, no matter what.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install insider-python
39
+ ```
40
+
41
+ For the Django integration:
42
+
43
+ ```bash
44
+ pip install "insider-python[django]"
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ### Plain Python
50
+
51
+ ```python
52
+ import insider
53
+
54
+ insider.init(
55
+ dsn="https://<beacon_token>@insider.example.com/<project_uuid>",
56
+ environment="production",
57
+ release="1.2.3",
58
+ )
59
+
60
+ try:
61
+ risky()
62
+ except Exception as exc:
63
+ insider.capture_exception(exc)
64
+
65
+ insider.capture_message("cache miss spiked", level="warning")
66
+ ```
67
+
68
+ ### Django
69
+
70
+ Add the integration to `INSTALLED_APPS` and configure via settings:
71
+
72
+ ```python
73
+ INSTALLED_APPS = [
74
+ # ...
75
+ "insider.contrib.django",
76
+ ]
77
+
78
+ MIDDLEWARE = [
79
+ # ...
80
+ "insider.contrib.django.middleware.InsiderMiddleware",
81
+ ]
82
+
83
+ INSIDER_DSN = "https://<beacon_token>@insider.example.com/<project_uuid>"
84
+ INSIDER_ENVIRONMENT = "production"
85
+ INSIDER_RELEASE = "1.2.3"
86
+ ```
87
+
88
+ That's the whole setup. Every unhandled exception in a view is now a
89
+ Beacon in your dashboard.
90
+
91
+ ## Configuration
92
+
93
+ Order of precedence (first wins):
94
+
95
+ 1. Keyword args to `insider.init(...)`.
96
+ 2. `INSIDER_*` environment variables.
97
+ 3. Django settings (when the Django integration is active).
98
+ 4. Hard-coded defaults.
99
+
100
+ If no DSN is found anywhere, the SDK enters **disabled mode**: every
101
+ public call is a no-op, no thread starts, no socket opens.
102
+
103
+ | Option | Default | Notes |
104
+ |--------|---------|-------|
105
+ | `dsn` | env `INSIDER_DSN` | If absent, SDK is disabled |
106
+ | `environment` | `"production"` | Top-level Beacon field |
107
+ | `release` | `None` | Top-level Beacon field |
108
+ | `send_default_pii` | `False` | Required to capture `user.id`, request bodies |
109
+ | `before_send` | `None` | `(beacon) -> beacon | None` hook |
110
+ | `scrub_keys` | `None` | Extra keys to filter (added to the default deny-list) |
111
+ | `in_app_include` | `None` | Filename prefixes considered "your code" |
112
+ | `transport_queue_size` | `1000` | Bounded; drops on overflow |
113
+ | `transport_flush_timeout` | `2.0` | Seconds. Used by `close()` / `flush()` |
114
+ | `debug` | `False` | Print SDK's own warnings to stderr |
115
+
116
+ ## Promise
117
+
118
+ The SDK never raises into your code. Every public function catches
119
+ `Exception` at its boundary; if something goes wrong inside the SDK,
120
+ you get nothing back and a debug log (if enabled). Your app keeps
121
+ running.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,99 @@
1
+ # insider-python
2
+
3
+ The Python SDK for [Insider](https://insider.moraks.cloud/).
4
+
5
+ Beam Beacons from your Python service to your Insider server with a
6
+ one-line setup. No runtime overhead on your request path. Never raises
7
+ into your code, no matter what.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install insider-python
13
+ ```
14
+
15
+ For the Django integration:
16
+
17
+ ```bash
18
+ pip install "insider-python[django]"
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ### Plain Python
24
+
25
+ ```python
26
+ import insider
27
+
28
+ insider.init(
29
+ dsn="https://<beacon_token>@insider.example.com/<project_uuid>",
30
+ environment="production",
31
+ release="1.2.3",
32
+ )
33
+
34
+ try:
35
+ risky()
36
+ except Exception as exc:
37
+ insider.capture_exception(exc)
38
+
39
+ insider.capture_message("cache miss spiked", level="warning")
40
+ ```
41
+
42
+ ### Django
43
+
44
+ Add the integration to `INSTALLED_APPS` and configure via settings:
45
+
46
+ ```python
47
+ INSTALLED_APPS = [
48
+ # ...
49
+ "insider.contrib.django",
50
+ ]
51
+
52
+ MIDDLEWARE = [
53
+ # ...
54
+ "insider.contrib.django.middleware.InsiderMiddleware",
55
+ ]
56
+
57
+ INSIDER_DSN = "https://<beacon_token>@insider.example.com/<project_uuid>"
58
+ INSIDER_ENVIRONMENT = "production"
59
+ INSIDER_RELEASE = "1.2.3"
60
+ ```
61
+
62
+ That's the whole setup. Every unhandled exception in a view is now a
63
+ Beacon in your dashboard.
64
+
65
+ ## Configuration
66
+
67
+ Order of precedence (first wins):
68
+
69
+ 1. Keyword args to `insider.init(...)`.
70
+ 2. `INSIDER_*` environment variables.
71
+ 3. Django settings (when the Django integration is active).
72
+ 4. Hard-coded defaults.
73
+
74
+ If no DSN is found anywhere, the SDK enters **disabled mode**: every
75
+ public call is a no-op, no thread starts, no socket opens.
76
+
77
+ | Option | Default | Notes |
78
+ |--------|---------|-------|
79
+ | `dsn` | env `INSIDER_DSN` | If absent, SDK is disabled |
80
+ | `environment` | `"production"` | Top-level Beacon field |
81
+ | `release` | `None` | Top-level Beacon field |
82
+ | `send_default_pii` | `False` | Required to capture `user.id`, request bodies |
83
+ | `before_send` | `None` | `(beacon) -> beacon | None` hook |
84
+ | `scrub_keys` | `None` | Extra keys to filter (added to the default deny-list) |
85
+ | `in_app_include` | `None` | Filename prefixes considered "your code" |
86
+ | `transport_queue_size` | `1000` | Bounded; drops on overflow |
87
+ | `transport_flush_timeout` | `2.0` | Seconds. Used by `close()` / `flush()` |
88
+ | `debug` | `False` | Print SDK's own warnings to stderr |
89
+
90
+ ## Promise
91
+
92
+ The SDK never raises into your code. Every public function catches
93
+ `Exception` at its boundary; if something goes wrong inside the SDK,
94
+ you get nothing back and a debug log (if enabled). Your app keeps
95
+ running.
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "insider-python"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Insider — ship Beacons to your Insider server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Insider" }]
13
+ keywords = ["insider", "telemetry", "errors", "monitoring", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "urllib3>=1.26",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ django = ["django>=4.2"]
30
+ dev = [
31
+ "pytest>=8",
32
+ "pytest-django>=4.8",
33
+ "django>=4.2",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Insider-Inc/insider-python"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ insider = ["py.typed"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ pythonpath = ["src", "."]
48
+ filterwarnings = ["ignore::DeprecationWarning"]
49
+ DJANGO_SETTINGS_MODULE = "tests.django_settings"
50
+ django_find_project = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """
2
+ Insider — Python SDK.
3
+
4
+ Public API:
5
+
6
+ insider.init(dsn=..., environment=..., release=..., ...)
7
+ insider.capture_exception(exc, level="error", tags=..., extra=...)
8
+ insider.capture_message("text", level="info", tags=..., extra=...)
9
+ insider.flush(timeout=2.0)
10
+ insider.close(timeout=2.0)
11
+
12
+ Nothing else is part of the public contract. Anything imported below
13
+ `_` is internal and may change without notice.
14
+ """
15
+
16
+ from ._version import __version__
17
+ from .client import (
18
+ Client,
19
+ capture_exception,
20
+ capture_message,
21
+ close,
22
+ flush,
23
+ init,
24
+ )
25
+ from .dsn import DSN, InvalidDSNError
26
+
27
+ __all__ = [
28
+ "Client",
29
+ "DSN",
30
+ "InvalidDSNError",
31
+ "__version__",
32
+ "capture_exception",
33
+ "capture_message",
34
+ "close",
35
+ "flush",
36
+ "init",
37
+ ]
@@ -0,0 +1,189 @@
1
+ """
2
+ Beacon envelope construction + size-budget enforcement.
3
+
4
+ `build_envelope` is called from the capture functions in `client.py`. It
5
+ takes the raw bits (kind, level, message, exception payload, scope,
6
+ tags, extra) and produces the top-level dict the transport will ship.
7
+
8
+ `enforce_size_budget` is the second-to-last step before submit. It
9
+ truncates progressively until the JSON-encoded envelope fits under the
10
+ server's 256 KB cap. The truncation order is intentional and matches
11
+ docs/python-sdk-plan -> "Payload size budget":
12
+
13
+ 1. message capped to 8 KB
14
+ 2. frame `vars` capped to 2 KB each (v1 has no vars, future-proof)
15
+ 3. request.body capped to 32 KB
16
+ 4. request.headers capped to 4 KB (after deny-listed keys are masked
17
+ by scrub.py upstream)
18
+ 5. drop frames from the outermost end of the stack until envelope fits,
19
+ keeping the innermost frames (closest to the error)
20
+ 6. drop payload.request entirely
21
+ 7. ship minimal envelope with payload.truncated = True
22
+
23
+ Step 7's existence is the property: we ship *something* truthful rather
24
+ than nothing.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from datetime import datetime, timezone
31
+ from typing import Any, Dict, Iterable, List, Optional
32
+
33
+ from .safety import debug
34
+
35
+ MAX_ENVELOPE_BYTES = 256 * 1024
36
+ MAX_MESSAGE_BYTES = 8 * 1024
37
+ MAX_REQUEST_BODY_BYTES = 32 * 1024
38
+ MAX_REQUEST_HEADERS_BYTES = 4 * 1024
39
+
40
+
41
+ def _now_iso() -> str:
42
+ """ISO-8601 UTC timestamp with microseconds, used as `occurred_at`."""
43
+ return datetime.now(timezone.utc).isoformat()
44
+
45
+
46
+ def build_envelope(
47
+ *,
48
+ kind: str,
49
+ level: str,
50
+ message: Optional[str],
51
+ source: Optional[str],
52
+ environment: str,
53
+ release: Optional[str],
54
+ trace_id: Optional[str],
55
+ payload: Optional[Dict[str, Any]] = None,
56
+ tags: Optional[Dict[str, Any]] = None,
57
+ extra: Optional[Dict[str, Any]] = None,
58
+ occurred_at: Optional[str] = None,
59
+ ) -> Dict[str, Any]:
60
+ """Assemble the Beacon envelope. Pure: no I/O, no globals."""
61
+ body: Dict[str, Any] = dict(payload or {})
62
+ if tags:
63
+ body["tags"] = tags
64
+ if extra:
65
+ body["extra"] = extra
66
+ return {
67
+ "kind": kind,
68
+ "level": level,
69
+ "environment": environment,
70
+ "release": release,
71
+ "source": source,
72
+ "message": message,
73
+ "occurred_at": occurred_at or _now_iso(),
74
+ "trace_id": trace_id,
75
+ "payload": body,
76
+ }
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Size budget
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _byte_len(obj: Any) -> int:
85
+ """Best-effort serialized size. Encode errors → infinity to force trim."""
86
+ try:
87
+ return len(json.dumps(obj, default=str, ensure_ascii=False).encode("utf-8"))
88
+ except Exception:
89
+ return 10**9
90
+
91
+
92
+ def _truncate_str_to_bytes(value: str, limit: int) -> str:
93
+ encoded = value.encode("utf-8")
94
+ if len(encoded) <= limit:
95
+ return value
96
+ return encoded[:limit].decode("utf-8", errors="ignore")
97
+
98
+
99
+ def enforce_size_budget(envelope: Dict[str, Any]) -> Dict[str, Any]:
100
+ """
101
+ Apply the truncation rules in order. Returns the same envelope dict,
102
+ mutated. The caller is expected to discard the original reference
103
+ after this call.
104
+ """
105
+ # 1. message
106
+ msg = envelope.get("message")
107
+ if isinstance(msg, str):
108
+ envelope["message"] = _truncate_str_to_bytes(msg, MAX_MESSAGE_BYTES)
109
+
110
+ payload = envelope.get("payload") or {}
111
+
112
+ # 3. request.body
113
+ request_ctx = payload.get("request")
114
+ if isinstance(request_ctx, dict):
115
+ body = request_ctx.get("body")
116
+ if isinstance(body, str):
117
+ request_ctx["body"] = _truncate_str_to_bytes(body, MAX_REQUEST_BODY_BYTES)
118
+ elif body is not None:
119
+ # Non-string body: dump to string and cap.
120
+ try:
121
+ as_str = json.dumps(body, default=str)
122
+ except Exception:
123
+ as_str = str(body)
124
+ request_ctx["body"] = _truncate_str_to_bytes(as_str, MAX_REQUEST_BODY_BYTES)
125
+
126
+ # 4. request.headers
127
+ headers = request_ctx.get("headers")
128
+ if isinstance(headers, dict):
129
+ if _byte_len(headers) > MAX_REQUEST_HEADERS_BYTES:
130
+ # Drop the largest-value headers until under budget. We drop
131
+ # whole entries rather than truncating individual values to
132
+ # avoid producing partial / misleading header strings.
133
+ items = sorted(
134
+ headers.items(),
135
+ key=lambda kv: _byte_len(kv[1]),
136
+ reverse=True,
137
+ )
138
+ trimmed = dict(items)
139
+ while items and _byte_len(trimmed) > MAX_REQUEST_HEADERS_BYTES:
140
+ k, _ = items.pop(0)
141
+ trimmed.pop(k, None)
142
+ request_ctx["headers"] = trimmed
143
+
144
+ # 5. drop frames from the outside until envelope fits
145
+ exception = payload.get("exception")
146
+ if isinstance(exception, dict) and isinstance(exception.get("frames"), list):
147
+ while _byte_len(envelope) > MAX_ENVELOPE_BYTES and exception["frames"]:
148
+ # Keep the *innermost* frames (the end of the list).
149
+ exception["frames"].pop(0)
150
+
151
+ # 6. drop payload.request entirely if still too big
152
+ if _byte_len(envelope) > MAX_ENVELOPE_BYTES and "request" in payload:
153
+ payload.pop("request", None)
154
+ debug("size budget: dropped payload.request")
155
+
156
+ # 7. minimal envelope of last resort
157
+ if _byte_len(envelope) > MAX_ENVELOPE_BYTES:
158
+ minimal_exception: Optional[Dict[str, Any]] = None
159
+ if isinstance(exception, dict):
160
+ minimal_exception = {
161
+ "type": exception.get("type"),
162
+ "value": _truncate_str_to_bytes(
163
+ str(exception.get("value", "")), 1024
164
+ ),
165
+ }
166
+ minimal_payload: Dict[str, Any] = {"truncated": True}
167
+ if minimal_exception is not None:
168
+ minimal_payload["exception"] = minimal_exception
169
+ envelope["payload"] = minimal_payload
170
+ debug("size budget: emitting minimal envelope")
171
+
172
+ return envelope
173
+
174
+
175
+ def safe_frame_subset(
176
+ frames: List[Dict[str, Any]],
177
+ in_app_only: bool = False,
178
+ ) -> List[Dict[str, Any]]:
179
+ """Optional filter for dashboards; unused in v1 but kept for callers."""
180
+ if not in_app_only:
181
+ return frames
182
+ return [f for f in frames if f.get("in_app")]
183
+
184
+
185
+ __all__: Iterable[str] = (
186
+ "MAX_ENVELOPE_BYTES",
187
+ "build_envelope",
188
+ "enforce_size_budget",
189
+ )
@@ -0,0 +1,8 @@
1
+ """Single source of truth for the SDK version.
2
+
3
+ Kept separate from `pyproject.toml` at runtime to avoid an `importlib.metadata`
4
+ lookup on every beacon. Bump this and `[project].version` together when
5
+ cutting a release.
6
+ """
7
+
8
+ __version__ = "0.1.0"