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.
- insider/__init__.py +37 -0
- insider/_envelope.py +189 -0
- insider/_version.py +8 -0
- insider/client.py +344 -0
- insider/contrib/__init__.py +0 -0
- insider/contrib/django/__init__.py +27 -0
- insider/contrib/django/apps.py +60 -0
- insider/contrib/django/middleware.py +164 -0
- insider/dsn.py +92 -0
- insider/py.typed +0 -0
- insider/safety.py +61 -0
- insider/scope.py +54 -0
- insider/scrubbing.py +91 -0
- insider/stacktrace.py +153 -0
- insider/transport.py +213 -0
- insider_python-0.1.0.dist-info/METADATA +125 -0
- insider_python-0.1.0.dist-info/RECORD +19 -0
- insider_python-0.1.0.dist-info/WHEEL +5 -0
- insider_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|