insider-python 0.1.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.
@@ -0,0 +1,164 @@
1
+ """
2
+ InsiderMiddleware: attach request context to the SDK scope, and
3
+ auto-capture any unhandled exception that escapes a view.
4
+
5
+ The middleware is a no-op when the SDK is in disabled mode.
6
+
7
+ What we attach to the scope:
8
+
9
+ - method, path, route (URL name if available), query_string
10
+ - headers (post-scrubbing — note that scrubbing happens at envelope
11
+ build time, not here, so headers go on as-is and get masked just
12
+ before transport)
13
+ - body and user.id ONLY when `send_default_pii=True` in init()
14
+
15
+ What we never touch:
16
+
17
+ - request.session
18
+ - file uploads
19
+ - anything from request.META not on the allowlist
20
+
21
+ `process_exception` is Django's hook for "an exception escaped a view
22
+ without being handled". We call `capture_exception` and then return
23
+ None so Django continues its normal 500 handling.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Callable, Dict, Optional
29
+
30
+ from ... import capture_exception
31
+ from ...client import _client
32
+ from ...safety import debug, safe
33
+
34
+
35
+ # Request headers that are safe to forward to the dashboard. We keep an
36
+ # allow-list rather than a deny-list because there are too many possible
37
+ # custom headers to enumerate scary ones. Scrubbing further masks names
38
+ # matching the default deny-list (Authorization, Cookie, etc.) at envelope
39
+ # build time.
40
+ _SAFE_HEADERS = {
41
+ "accept",
42
+ "accept-encoding",
43
+ "accept-language",
44
+ "content-type",
45
+ "content-length",
46
+ "host",
47
+ "referer",
48
+ "user-agent",
49
+ "x-forwarded-for",
50
+ "x-real-ip",
51
+ "x-request-id",
52
+ }
53
+
54
+
55
+ class InsiderMiddleware:
56
+ """
57
+ Django middleware. Installs request context on the SDK scope, then
58
+ captures any unhandled exception with that context attached.
59
+ """
60
+
61
+ def __init__(self, get_response: Callable[[Any], Any]) -> None:
62
+ self.get_response = get_response
63
+
64
+ @safe
65
+ def __call__(self, request: Any) -> Any:
66
+ client = _client()
67
+ if client is None:
68
+ return self.get_response(request)
69
+
70
+ ctx = self._build_request_ctx(request, client.send_default_pii)
71
+ client.scope.set_request(ctx)
72
+ try:
73
+ return self.get_response(request)
74
+ finally:
75
+ client.scope.clear_request()
76
+
77
+ @safe
78
+ def process_exception(self, request: Any, exception: BaseException) -> None:
79
+ # Scope's already set in __call__; capture inherits the request ctx.
80
+ capture_exception(exception)
81
+ return None # let Django render the 500
82
+
83
+ # ------------------------------------------------------------------
84
+
85
+ @staticmethod
86
+ def _build_request_ctx(request: Any, send_default_pii: bool) -> Dict[str, Any]:
87
+ try:
88
+ method = getattr(request, "method", None)
89
+ path = getattr(request, "path", None)
90
+ query = getattr(request, "META", {}).get("QUERY_STRING") or None
91
+ route = None
92
+ try:
93
+ # ResolverMatch is set after urls resolve; in middleware
94
+ # __call__ before view, it's usually None. We still try.
95
+ match = getattr(request, "resolver_match", None)
96
+ if match is not None:
97
+ route = match.view_name
98
+ except Exception:
99
+ pass
100
+
101
+ headers = _extract_headers(getattr(request, "META", {}))
102
+
103
+ ctx: Dict[str, Any] = {
104
+ "method": method,
105
+ "path": path,
106
+ "query_string": query,
107
+ "route": route,
108
+ "headers": headers,
109
+ }
110
+
111
+ if send_default_pii:
112
+ ctx["body"] = _read_body(request)
113
+ user = getattr(request, "user", None)
114
+ user_id = getattr(user, "id", None) if user is not None else None
115
+ if user_id is not None:
116
+ ctx["user"] = {"id": user_id}
117
+
118
+ return ctx
119
+ except Exception as exc:
120
+ debug(f"request ctx build failed: {exc}")
121
+ return {}
122
+
123
+
124
+ def _extract_headers(meta: Dict[str, Any]) -> Dict[str, str]:
125
+ """Convert Django's META dict into a real headers dict, allow-listed."""
126
+ headers: Dict[str, str] = {}
127
+ for key, value in meta.items():
128
+ if not key.startswith("HTTP_") and key not in (
129
+ "CONTENT_TYPE",
130
+ "CONTENT_LENGTH",
131
+ ):
132
+ continue
133
+ name = key
134
+ if key.startswith("HTTP_"):
135
+ name = key[len("HTTP_") :]
136
+ name = name.replace("_", "-").lower()
137
+ if name not in _SAFE_HEADERS:
138
+ # We still include unknown headers; the scrubber will mask any
139
+ # whose name is in the deny-list. But we cap obviously huge or
140
+ # weird ones here.
141
+ if len(str(value)) > 4096:
142
+ continue
143
+ try:
144
+ headers[name] = str(value)
145
+ except Exception:
146
+ pass
147
+ return headers
148
+
149
+
150
+ def _read_body(request: Any) -> Optional[str]:
151
+ """
152
+ Return a string version of the request body, or None.
153
+ We don't consume `request.body` if it hasn't been read yet, to avoid
154
+ breaking downstream views; if it's accessible, we take it.
155
+ """
156
+ try:
157
+ raw = getattr(request, "body", None)
158
+ if raw is None:
159
+ return None
160
+ if isinstance(raw, bytes):
161
+ return raw.decode("utf-8", errors="replace")
162
+ return str(raw)
163
+ except Exception:
164
+ return None
insider/dsn.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ DSN string parsing and validation.
3
+
4
+ A DSN is the single string the customer pastes into their config. It
5
+ encodes everything the SDK needs to know about which server to beam
6
+ beacons at and which credential to use:
7
+
8
+ {scheme}://{beacon_token}@{host}/{project_public_id}
9
+
10
+ Example:
11
+
12
+ https://abc...xyz@insider.example.com/3c29b8cb-1fe9-4b42-94a0-28d016cb20f9
13
+
14
+ The endpoint path (`/api/v1/beam/<uuid>/`) is *not* in the DSN. We hard-
15
+ code it on the SDK side so we can change the server's URL layout later
16
+ without breaking SDKs already in the wild.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from urllib.parse import urlparse
23
+ from uuid import UUID
24
+
25
+
26
+ class InvalidDSNError(ValueError):
27
+ """Raised when a DSN string can't be parsed into a usable shape."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DSN:
32
+ """A parsed DSN. Immutable; safe to share across threads."""
33
+
34
+ scheme: str
35
+ host: str
36
+ token: str
37
+ project_id: str
38
+
39
+ @classmethod
40
+ def parse(cls, raw: str) -> "DSN":
41
+ if not raw or not isinstance(raw, str):
42
+ raise InvalidDSNError("DSN must be a non-empty string.")
43
+
44
+ parsed = urlparse(raw.strip())
45
+
46
+ if parsed.scheme not in ("http", "https"):
47
+ raise InvalidDSNError(
48
+ f"DSN scheme must be http or https, got {parsed.scheme!r}."
49
+ )
50
+ if not parsed.username:
51
+ raise InvalidDSNError("DSN is missing the beacon token (the userinfo part).")
52
+ if parsed.password:
53
+ raise InvalidDSNError(
54
+ "DSN must not contain a password; only the beacon token belongs there."
55
+ )
56
+ if not parsed.hostname:
57
+ raise InvalidDSNError("DSN is missing the host.")
58
+
59
+ # The path is "/<project_public_id>" — strip the leading slash and any
60
+ # trailing slash. We deliberately reject extra path components so we
61
+ # catch typos like "/api/v1/beam/<uuid>" pasted by mistake.
62
+ path = parsed.path.strip("/")
63
+ if not path:
64
+ raise InvalidDSNError("DSN is missing the project id in the path.")
65
+ if "/" in path:
66
+ raise InvalidDSNError(
67
+ "DSN path must be just the project id, with no extra segments."
68
+ )
69
+ try:
70
+ UUID(path)
71
+ except ValueError as exc:
72
+ raise InvalidDSNError(f"Project id {path!r} is not a valid UUID.") from exc
73
+
74
+ host = parsed.hostname
75
+ if parsed.port:
76
+ host = f"{host}:{parsed.port}"
77
+
78
+ return cls(
79
+ scheme=parsed.scheme,
80
+ host=host,
81
+ token=parsed.username,
82
+ project_id=path,
83
+ )
84
+
85
+ @property
86
+ def beam_url(self) -> str:
87
+ """The URL the SDK POSTs beacons to."""
88
+ return f"{self.scheme}://{self.host}/api/v1/beam/{self.project_id}/"
89
+
90
+ def redacted(self) -> str:
91
+ """A safe-to-log version of the DSN with the token masked."""
92
+ return f"{self.scheme}://[redacted]@{self.host}/{self.project_id}"
insider/py.typed ADDED
File without changes
insider/safety.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ The never-crash machinery.
3
+
4
+ Every public entrypoint of the SDK is wrapped with `@safe`, which catches
5
+ any `Exception` raised by the wrapped function and routes it through the
6
+ SDK's debug logger. The host application sees `None` instead of an
7
+ exception. That is the contract: the SDK cannot crash the customer's app.
8
+
9
+ Why `print` to stderr instead of the `logging` module?
10
+ The SDK may be initialized *before* the customer's logging config is
11
+ in place. Using the stdlib `logging` module risks recursing into a
12
+ customer LoggingHandler that itself uses Insider (phase 4 of the
13
+ plan). Writing directly to stderr is dependency-free and recursion-free.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import sys
20
+ from typing import Any, Callable, TypeVar
21
+
22
+ F = TypeVar("F", bound=Callable[..., Any])
23
+
24
+ _debug_enabled: bool = False
25
+
26
+
27
+ def set_debug(enabled: bool) -> None:
28
+ """Toggle SDK-internal debug logging. Called from `Client.__init__`."""
29
+ global _debug_enabled
30
+ _debug_enabled = bool(enabled)
31
+
32
+
33
+ def debug(message: str) -> None:
34
+ """Write a single line to stderr if debug mode is on. Never raises."""
35
+ if not _debug_enabled:
36
+ return
37
+ try:
38
+ sys.stderr.write(f"[insider] {message}\n")
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ def safe(fn: F) -> F:
44
+ """
45
+ Decorator: swallow every exception raised by `fn`, return `None`
46
+ instead, and emit a debug line. The original return value is passed
47
+ through on success.
48
+
49
+ Use on every public entrypoint. Internal helpers can raise normally;
50
+ the boundary functions catch them.
51
+ """
52
+
53
+ @functools.wraps(fn)
54
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
55
+ try:
56
+ return fn(*args, **kwargs)
57
+ except Exception as exc:
58
+ debug(f"swallowed {type(exc).__name__} in {fn.__qualname__}: {exc}")
59
+ return None
60
+
61
+ return wrapper # type: ignore[return-value]
insider/scope.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Global + per-thread scope.
3
+
4
+ The scope holds the context that every Beacon should be enriched with:
5
+
6
+ - Static, set once at `init()`: environment, release, in_app_include.
7
+ - Dynamic, per-thread: the current HTTP request (when running inside a
8
+ framework integration), other transient enrichments.
9
+
10
+ We use a threading.local for the dynamic part because Django still runs
11
+ one request per thread under wsgi. When we add asgi / asyncio support in
12
+ a later phase we'll swap this for `contextvars.ContextVar` — the public
13
+ API doesn't change.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Dict, List, Optional
21
+
22
+
23
+ @dataclass
24
+ class StaticScope:
25
+ """Process-global, set by `Client.__init__` and read-only thereafter."""
26
+
27
+ environment: str = "production"
28
+ release: Optional[str] = None
29
+ in_app_include: Optional[List[str]] = None
30
+
31
+
32
+ class _ThreadLocal(threading.local):
33
+ """Per-thread state. Each thread gets its own `request` slot."""
34
+
35
+ request: Optional[Dict[str, Any]] = None
36
+
37
+
38
+ @dataclass
39
+ class Scope:
40
+ static: StaticScope = field(default_factory=StaticScope)
41
+ _local: _ThreadLocal = field(default_factory=_ThreadLocal)
42
+
43
+ # -- request context ---------------------------------------------------
44
+
45
+ def set_request(self, request: Dict[str, Any]) -> None:
46
+ """Attach a request-context dict to the current thread."""
47
+ self._local.request = request
48
+
49
+ def clear_request(self) -> None:
50
+ """Drop the current thread's request context."""
51
+ self._local.request = None
52
+
53
+ def current_request(self) -> Optional[Dict[str, Any]]:
54
+ return getattr(self._local, "request", None)
insider/scrubbing.py ADDED
@@ -0,0 +1,91 @@
1
+ """
2
+ Default and custom data scrubbing.
3
+
4
+ This is the SDK-side defence against accidental PII / secret leakage.
5
+ We assume the customer's code routinely has dicts named `headers`,
6
+ `form`, `cookies`, etc. that contain sensitive values, and that we are
7
+ about to serialize and ship those dicts off-host. So before we do, we
8
+ walk the structure and mask any key name that looks dangerous.
9
+
10
+ Match is case-insensitive on the *key name only*. We never inspect
11
+ values for sensitive content (regex-on-everything is expensive and
12
+ unreliable). If a customer wants extra keys filtered they pass
13
+ `scrub_keys=[...]` into `init`, and the deny-list grows.
14
+
15
+ A future phase can add value-level patterns (credit-card regex, etc.)
16
+ but the v1 contract is "we mask anything whose key looks scary."
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Iterable, Optional, Set
22
+
23
+ # Lowercase. Match is `key.lower() in DEFAULT_DENY_KEYS`.
24
+ DEFAULT_DENY_KEYS: frozenset[str] = frozenset(
25
+ {
26
+ "password",
27
+ "passwd",
28
+ "pwd",
29
+ "secret",
30
+ "token",
31
+ "access_token",
32
+ "refresh_token",
33
+ "api_key",
34
+ "apikey",
35
+ "authorization",
36
+ "auth",
37
+ "x-api-key",
38
+ "cookie",
39
+ "set-cookie",
40
+ "session",
41
+ "sessionid",
42
+ "csrf",
43
+ "csrftoken",
44
+ "x-csrf-token",
45
+ "credit_card",
46
+ "card_number",
47
+ "cc_number",
48
+ "cvv",
49
+ "ssn",
50
+ }
51
+ )
52
+
53
+ FILTERED = "[Filtered]"
54
+
55
+ # Walk depth cap. Pathological customer payloads (deeply nested dicts) shouldn't
56
+ # burn the SDK's stack or budget. Anything deeper than this is replaced with a
57
+ # marker — the customer's data was already going to be lossily truncated by
58
+ # the size budget anyway.
59
+ _MAX_DEPTH = 16
60
+
61
+
62
+ def build_deny_set(extra_keys: Optional[Iterable[str]] = None) -> Set[str]:
63
+ """Combine the default deny-list with caller-supplied keys."""
64
+ deny = set(DEFAULT_DENY_KEYS)
65
+ if extra_keys:
66
+ deny.update(k.lower() for k in extra_keys if isinstance(k, str))
67
+ return deny
68
+
69
+
70
+ def scrub(data: Any, *, extra_keys: Optional[Iterable[str]] = None) -> Any:
71
+ """
72
+ Return a new structure with sensitive values replaced by `[Filtered]`.
73
+ The input is not mutated.
74
+ """
75
+ deny = build_deny_set(extra_keys)
76
+ return _scrub(data, deny, depth=0)
77
+
78
+
79
+ def _scrub(value: Any, deny: Set[str], depth: int) -> Any:
80
+ if depth > _MAX_DEPTH:
81
+ return "[TooDeep]"
82
+ if isinstance(value, dict):
83
+ return {
84
+ k: (FILTERED if isinstance(k, str) and k.lower() in deny
85
+ else _scrub(v, deny, depth + 1))
86
+ for k, v in value.items()
87
+ }
88
+ if isinstance(value, (list, tuple)):
89
+ scrubbed = [_scrub(item, deny, depth + 1) for item in value]
90
+ return scrubbed if isinstance(value, list) else tuple(scrubbed)
91
+ return value
insider/stacktrace.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Stack frame extraction.
3
+
4
+ Given an exception (and therefore a traceback), produce a serializable
5
+ list of frames the dashboard can render. We deliberately *don't* read
6
+ source files in v1 — that's I/O on the error path and a chunk of
7
+ deferred work (see docs/python-sdk-plan -> Non-goals). We also don't
8
+ capture frame-local variables (security risk, opt-in feature later).
9
+
10
+ `in_app` is a hint to the dashboard about which frames are "the
11
+ customer's code" vs library / stdlib code. Default heuristic:
12
+
13
+ in_app == filename does not live under site-packages / dist-packages
14
+ and is not under the stdlib path
15
+
16
+ The customer can override with `in_app_include=["/srv/myapp", ...]`,
17
+ which switches to an explicit allow-list.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+ from types import TracebackType
25
+ from typing import Any, Dict, Iterable, List, Optional
26
+
27
+ from .safety import debug
28
+
29
+ # Computed once at import time. Falls back to "" if `os.__file__` isn't a
30
+ # usable path (e.g. frozen Python build).
31
+ _STDLIB_PATH = os.path.dirname(getattr(os, "__file__", "") or "")
32
+
33
+
34
+ def is_in_app(
35
+ filename: Optional[str],
36
+ in_app_include: Optional[Iterable[str]] = None,
37
+ ) -> bool:
38
+ """Return True if `filename` should be flagged as customer code."""
39
+ if not filename or filename.startswith("<"):
40
+ return False
41
+ if in_app_include:
42
+ return any(filename.startswith(prefix) for prefix in in_app_include)
43
+ if "site-packages" in filename or "dist-packages" in filename:
44
+ return False
45
+ if _STDLIB_PATH and filename.startswith(_STDLIB_PATH):
46
+ return False
47
+ return True
48
+
49
+
50
+ def extract_frames(
51
+ tb: Optional[TracebackType],
52
+ *,
53
+ in_app_include: Optional[Iterable[str]] = None,
54
+ max_frames: int = 200,
55
+ ) -> List[Dict[str, Any]]:
56
+ """
57
+ Walk a traceback into a list of frame dicts.
58
+
59
+ Order: innermost (where the exception was raised) is last, matching
60
+ Python's own `traceback` module. The dashboard renders top-down.
61
+ `max_frames` is a guardrail against pathological recursion.
62
+ """
63
+ frames: List[Dict[str, Any]] = []
64
+ walked = 0
65
+ while tb is not None and walked < max_frames:
66
+ try:
67
+ frame = tb.tb_frame
68
+ code = frame.f_code
69
+ filename = code.co_filename
70
+ frames.append(
71
+ {
72
+ "filename": filename,
73
+ "function": code.co_name,
74
+ "module": frame.f_globals.get("__name__", ""),
75
+ "lineno": tb.tb_lineno,
76
+ "in_app": is_in_app(filename, in_app_include),
77
+ }
78
+ )
79
+ except Exception as exc:
80
+ debug(f"frame extraction skipped one frame: {exc}")
81
+ tb = tb.tb_next
82
+ walked += 1
83
+ return frames
84
+
85
+
86
+ def exception_payload(
87
+ exc: BaseException,
88
+ *,
89
+ in_app_include: Optional[Iterable[str]] = None,
90
+ ) -> Dict[str, Any]:
91
+ """
92
+ Build the `payload.exception` block.
93
+
94
+ Walks `__cause__` / `__context__` and stops at a small chain depth so
95
+ we don't accidentally serialize a circular chain forever.
96
+ """
97
+ chain: List[Dict[str, Any]] = []
98
+ seen: set[int] = set()
99
+ current: Optional[BaseException] = exc
100
+ while current is not None and len(chain) < 10 and id(current) not in seen:
101
+ seen.add(id(current))
102
+ chain.append(
103
+ {
104
+ "type": type(current).__name__,
105
+ "module": type(current).__module__,
106
+ "value": str(current),
107
+ "frames": extract_frames(
108
+ current.__traceback__,
109
+ in_app_include=in_app_include,
110
+ ),
111
+ }
112
+ )
113
+ current = current.__cause__ or current.__context__
114
+
115
+ head = chain[0]
116
+ if len(chain) > 1:
117
+ head["chain"] = chain[1:]
118
+ return head
119
+
120
+
121
+ def runtime_payload(sdk_version: str) -> Dict[str, Any]:
122
+ """Static runtime info to attach to every error envelope."""
123
+ return {
124
+ "sdk": "insider-python",
125
+ "sdk_version": sdk_version,
126
+ "python_version": (
127
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
128
+ ),
129
+ "platform": sys.platform,
130
+ }
131
+
132
+
133
+ def caller_source(skip: int = 1) -> Optional[str]:
134
+ """
135
+ Return `<module>.<function>` of the first non-SDK caller above this
136
+ function. `skip` is the minimum number of frames to skip before we
137
+ start looking for a non-internal frame.
138
+
139
+ We walk frames because the `@safe` decorator and the module-level
140
+ facade each add a stack frame on top of `Client.capture_message`;
141
+ a fixed `skip` would be brittle.
142
+ """
143
+ try:
144
+ frame = sys._getframe(skip)
145
+ while frame is not None:
146
+ module = frame.f_globals.get("__name__", "") or ""
147
+ if not module.startswith("insider"):
148
+ function = frame.f_code.co_name
149
+ return f"{module}.{function}" if module else function
150
+ frame = frame.f_back
151
+ return None
152
+ except Exception:
153
+ return None