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
insider/__init__.py
ADDED
|
@@ -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
|
+
]
|
insider/_envelope.py
ADDED
|
@@ -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
|
+
)
|
insider/_version.py
ADDED
insider/client.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Client and the module-level facade.
|
|
3
|
+
|
|
4
|
+
There are two ways to talk to the SDK:
|
|
5
|
+
|
|
6
|
+
1. Module-level helpers (`insider.init`, `insider.capture_exception`,
|
|
7
|
+
etc.). These operate on a single process-global `Client`.
|
|
8
|
+
2. Explicit `Client` instance returned by `init()`. Useful for tests,
|
|
9
|
+
advanced users, and the eventual multi-DSN case.
|
|
10
|
+
|
|
11
|
+
Both paths route to the same code. The module-level functions are thin
|
|
12
|
+
@safe wrappers around `_active_client()`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
21
|
+
|
|
22
|
+
from ._envelope import build_envelope, enforce_size_budget
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .dsn import DSN, InvalidDSNError
|
|
25
|
+
from .safety import debug, safe, set_debug
|
|
26
|
+
from .scope import Scope, StaticScope
|
|
27
|
+
from .scrubbing import scrub
|
|
28
|
+
from .stacktrace import caller_source, exception_payload, runtime_payload
|
|
29
|
+
from .transport import BackgroundTransport
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
VALID_KINDS = {"error", "perf", "log", "custom"}
|
|
33
|
+
VALID_LEVELS = {"debug", "info", "warning", "error", "fatal"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# DSN resolution
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_dsn_string(explicit: Optional[str]) -> Optional[str]:
|
|
42
|
+
"""Find a DSN string from `init()` arg → env var → None."""
|
|
43
|
+
if explicit:
|
|
44
|
+
return explicit
|
|
45
|
+
env = os.environ.get("INSIDER_DSN")
|
|
46
|
+
if env:
|
|
47
|
+
return env
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Client
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Client:
|
|
57
|
+
"""
|
|
58
|
+
A configured SDK instance. Owns a `Scope`, a `BackgroundTransport`,
|
|
59
|
+
and the customer-supplied hooks.
|
|
60
|
+
|
|
61
|
+
Customers usually don't construct this directly — they call
|
|
62
|
+
`insider.init(...)` which returns a `Client` and also stashes it as
|
|
63
|
+
the process-global active client.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
dsn: DSN,
|
|
69
|
+
*,
|
|
70
|
+
environment: str = "production",
|
|
71
|
+
release: Optional[str] = None,
|
|
72
|
+
send_default_pii: bool = False,
|
|
73
|
+
before_send: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
|
|
74
|
+
scrub_keys: Optional[Iterable[str]] = None,
|
|
75
|
+
in_app_include: Optional[Iterable[str]] = None,
|
|
76
|
+
transport_queue_size: int = 1000,
|
|
77
|
+
transport_flush_timeout: float = 2.0,
|
|
78
|
+
debug: bool = False,
|
|
79
|
+
transport: Optional[BackgroundTransport] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
set_debug(debug)
|
|
82
|
+
self.dsn = dsn
|
|
83
|
+
self.send_default_pii = bool(send_default_pii)
|
|
84
|
+
self.before_send = before_send
|
|
85
|
+
self.scrub_keys: List[str] = list(scrub_keys or [])
|
|
86
|
+
self.scope = Scope(
|
|
87
|
+
static=StaticScope(
|
|
88
|
+
environment=environment,
|
|
89
|
+
release=release,
|
|
90
|
+
in_app_include=list(in_app_include) if in_app_include else None,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
self.transport: BackgroundTransport = transport or BackgroundTransport(
|
|
94
|
+
dsn,
|
|
95
|
+
queue_size=transport_queue_size,
|
|
96
|
+
flush_timeout=transport_flush_timeout,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Capture
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def capture_exception(
|
|
104
|
+
self,
|
|
105
|
+
exc: BaseException,
|
|
106
|
+
*,
|
|
107
|
+
level: str = "error",
|
|
108
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
109
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
110
|
+
trace_id: Optional[str] = None,
|
|
111
|
+
) -> Optional[str]:
|
|
112
|
+
if not isinstance(exc, BaseException):
|
|
113
|
+
debug(f"capture_exception got non-exception: {type(exc).__name__}")
|
|
114
|
+
return None
|
|
115
|
+
level = level if level in VALID_LEVELS else "error"
|
|
116
|
+
|
|
117
|
+
exception_block = exception_payload(
|
|
118
|
+
exc, in_app_include=self.scope.static.in_app_include
|
|
119
|
+
)
|
|
120
|
+
payload: Dict[str, Any] = {
|
|
121
|
+
"exception": exception_block,
|
|
122
|
+
"runtime": runtime_payload(__version__),
|
|
123
|
+
}
|
|
124
|
+
request_ctx = self.scope.current_request()
|
|
125
|
+
if request_ctx is not None:
|
|
126
|
+
payload["request"] = request_ctx
|
|
127
|
+
|
|
128
|
+
envelope = build_envelope(
|
|
129
|
+
kind="error",
|
|
130
|
+
level=level,
|
|
131
|
+
message=str(exc) or type(exc).__name__,
|
|
132
|
+
source=self._source_from_exception(exception_block),
|
|
133
|
+
environment=self.scope.static.environment,
|
|
134
|
+
release=self.scope.static.release,
|
|
135
|
+
trace_id=trace_id,
|
|
136
|
+
payload=payload,
|
|
137
|
+
tags=tags,
|
|
138
|
+
extra=extra,
|
|
139
|
+
)
|
|
140
|
+
return self._dispatch(envelope)
|
|
141
|
+
|
|
142
|
+
def capture_message(
|
|
143
|
+
self,
|
|
144
|
+
message: str,
|
|
145
|
+
*,
|
|
146
|
+
level: str = "info",
|
|
147
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
148
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
149
|
+
source: Optional[str] = None,
|
|
150
|
+
trace_id: Optional[str] = None,
|
|
151
|
+
kind: str = "log",
|
|
152
|
+
) -> Optional[str]:
|
|
153
|
+
if not isinstance(message, str):
|
|
154
|
+
debug(f"capture_message expects str, got {type(message).__name__}")
|
|
155
|
+
return None
|
|
156
|
+
level = level if level in VALID_LEVELS else "info"
|
|
157
|
+
kind = kind if kind in VALID_KINDS else "log"
|
|
158
|
+
|
|
159
|
+
payload: Dict[str, Any] = {"runtime": runtime_payload(__version__)}
|
|
160
|
+
request_ctx = self.scope.current_request()
|
|
161
|
+
if request_ctx is not None:
|
|
162
|
+
payload["request"] = request_ctx
|
|
163
|
+
|
|
164
|
+
envelope = build_envelope(
|
|
165
|
+
kind=kind,
|
|
166
|
+
level=level,
|
|
167
|
+
message=message,
|
|
168
|
+
source=source or caller_source(skip=2),
|
|
169
|
+
environment=self.scope.static.environment,
|
|
170
|
+
release=self.scope.static.release,
|
|
171
|
+
trace_id=trace_id,
|
|
172
|
+
payload=payload,
|
|
173
|
+
tags=tags,
|
|
174
|
+
extra=extra,
|
|
175
|
+
)
|
|
176
|
+
return self._dispatch(envelope)
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Lifecycle
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def flush(self, timeout: Optional[float] = None) -> bool:
|
|
183
|
+
return self.transport.flush(timeout)
|
|
184
|
+
|
|
185
|
+
def close(self, timeout: Optional[float] = None) -> None:
|
|
186
|
+
self.transport.close(timeout)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Internal helpers
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _dispatch(self, envelope: Dict[str, Any]) -> Optional[str]:
|
|
193
|
+
"""Scrub → before_send → size budget → transport submit."""
|
|
194
|
+
envelope["payload"] = scrub(envelope.get("payload"), extra_keys=self.scrub_keys)
|
|
195
|
+
if self.before_send is not None:
|
|
196
|
+
try:
|
|
197
|
+
envelope = self.before_send(envelope) # type: ignore[assignment]
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
debug(f"before_send raised {type(exc).__name__}: {exc}; dropping beacon")
|
|
200
|
+
return None
|
|
201
|
+
if envelope is None:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
envelope = enforce_size_budget(envelope)
|
|
205
|
+
accepted = self.transport.submit(envelope)
|
|
206
|
+
return envelope.get("occurred_at") if accepted else None
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _source_from_exception(exception_block: Dict[str, Any]) -> Optional[str]:
|
|
210
|
+
"""Pick the innermost in_app frame's module as the beacon `source`."""
|
|
211
|
+
frames = exception_block.get("frames") or []
|
|
212
|
+
for frame in reversed(frames):
|
|
213
|
+
if frame.get("in_app"):
|
|
214
|
+
return frame.get("module") or frame.get("function")
|
|
215
|
+
if frames:
|
|
216
|
+
tail = frames[-1]
|
|
217
|
+
return tail.get("module") or tail.get("function")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Module-level facade
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_active_client: Optional[Client] = None
|
|
227
|
+
_init_lock = threading.Lock()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _client() -> Optional[Client]:
|
|
231
|
+
return _active_client
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _set_active(client: Optional[Client]) -> None:
|
|
235
|
+
global _active_client
|
|
236
|
+
_active_client = client
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@safe
|
|
240
|
+
def init(
|
|
241
|
+
dsn: Optional[str] = None,
|
|
242
|
+
**kwargs: Any,
|
|
243
|
+
) -> Optional[Client]:
|
|
244
|
+
"""
|
|
245
|
+
Initialize the SDK. Returns the new `Client` on success, or `None`
|
|
246
|
+
when no DSN is configured (disabled mode).
|
|
247
|
+
|
|
248
|
+
Calling `init` a second time is allowed but logs a warning and
|
|
249
|
+
closes the previous client first. The new client becomes the
|
|
250
|
+
process-global one.
|
|
251
|
+
"""
|
|
252
|
+
global _active_client
|
|
253
|
+
raw = _resolve_dsn_string(dsn)
|
|
254
|
+
if not raw:
|
|
255
|
+
debug("no DSN configured; entering disabled mode")
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
parsed = DSN.parse(raw)
|
|
259
|
+
except InvalidDSNError as exc:
|
|
260
|
+
debug(f"invalid DSN: {exc}; entering disabled mode")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
with _init_lock:
|
|
264
|
+
if _active_client is not None:
|
|
265
|
+
debug("re-initializing; closing previous client")
|
|
266
|
+
try:
|
|
267
|
+
_active_client.close()
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
debug(f"previous client close failed: {exc}")
|
|
270
|
+
client = Client(parsed, **kwargs)
|
|
271
|
+
_set_active(client)
|
|
272
|
+
|
|
273
|
+
atexit.register(_atexit_close)
|
|
274
|
+
return client
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _atexit_close() -> None:
|
|
278
|
+
"""Hook registered with `atexit` to drain on process exit."""
|
|
279
|
+
client = _active_client
|
|
280
|
+
if client is None:
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
client.close()
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
debug(f"atexit close failed: {exc}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@safe
|
|
289
|
+
def capture_exception(
|
|
290
|
+
exc: BaseException,
|
|
291
|
+
*,
|
|
292
|
+
level: str = "error",
|
|
293
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
294
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
295
|
+
trace_id: Optional[str] = None,
|
|
296
|
+
) -> Optional[str]:
|
|
297
|
+
client = _client()
|
|
298
|
+
if client is None:
|
|
299
|
+
return None
|
|
300
|
+
return client.capture_exception(
|
|
301
|
+
exc, level=level, tags=tags, extra=extra, trace_id=trace_id
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@safe
|
|
306
|
+
def capture_message(
|
|
307
|
+
message: str,
|
|
308
|
+
*,
|
|
309
|
+
level: str = "info",
|
|
310
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
311
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
312
|
+
source: Optional[str] = None,
|
|
313
|
+
trace_id: Optional[str] = None,
|
|
314
|
+
kind: str = "log",
|
|
315
|
+
) -> Optional[str]:
|
|
316
|
+
client = _client()
|
|
317
|
+
if client is None:
|
|
318
|
+
return None
|
|
319
|
+
return client.capture_message(
|
|
320
|
+
message,
|
|
321
|
+
level=level,
|
|
322
|
+
tags=tags,
|
|
323
|
+
extra=extra,
|
|
324
|
+
source=source,
|
|
325
|
+
trace_id=trace_id,
|
|
326
|
+
kind=kind,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@safe
|
|
331
|
+
def flush(timeout: Optional[float] = None) -> bool:
|
|
332
|
+
client = _client()
|
|
333
|
+
if client is None:
|
|
334
|
+
return True
|
|
335
|
+
return client.flush(timeout)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@safe
|
|
339
|
+
def close(timeout: Optional[float] = None) -> None:
|
|
340
|
+
client = _client()
|
|
341
|
+
if client is None:
|
|
342
|
+
return
|
|
343
|
+
client.close(timeout)
|
|
344
|
+
_set_active(None)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django integration for insider-python.
|
|
3
|
+
|
|
4
|
+
How to enable:
|
|
5
|
+
|
|
6
|
+
# settings.py
|
|
7
|
+
INSTALLED_APPS = [
|
|
8
|
+
...
|
|
9
|
+
"insider.contrib.django",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
MIDDLEWARE = [
|
|
13
|
+
...
|
|
14
|
+
"insider.contrib.django.middleware.InsiderMiddleware",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
INSIDER_DSN = "https://<token>@insider.example.com/<project_uuid>"
|
|
18
|
+
INSIDER_ENVIRONMENT = "production"
|
|
19
|
+
INSIDER_RELEASE = "1.2.3"
|
|
20
|
+
|
|
21
|
+
The `AppConfig.ready()` hook reads `INSIDER_*` from settings and calls
|
|
22
|
+
`insider.init(...)`. If no DSN is configured (`INSIDER_DSN` absent or
|
|
23
|
+
empty), the SDK enters disabled mode and the middleware becomes a
|
|
24
|
+
no-op.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
default_app_config = "insider.contrib.django.apps.InsiderConfig"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppConfig that auto-initializes the SDK from Django settings.
|
|
3
|
+
|
|
4
|
+
We avoid importing `django.conf.settings` at module import time — Django
|
|
5
|
+
calls `ready()` once the settings are fully resolved, which is the safe
|
|
6
|
+
place to do it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from django.apps import AppConfig
|
|
14
|
+
|
|
15
|
+
from ... import init
|
|
16
|
+
from ...safety import debug
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _settings_kwargs() -> Dict[str, Any]:
|
|
20
|
+
"""Collect `INSIDER_*` keys from Django settings into init kwargs."""
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
|
|
23
|
+
kwargs: Dict[str, Any] = {}
|
|
24
|
+
for setting, key in (
|
|
25
|
+
("INSIDER_DSN", "dsn"),
|
|
26
|
+
("INSIDER_ENVIRONMENT", "environment"),
|
|
27
|
+
("INSIDER_RELEASE", "release"),
|
|
28
|
+
("INSIDER_SEND_DEFAULT_PII", "send_default_pii"),
|
|
29
|
+
("INSIDER_BEFORE_SEND", "before_send"),
|
|
30
|
+
("INSIDER_SCRUB_KEYS", "scrub_keys"),
|
|
31
|
+
("INSIDER_IN_APP_INCLUDE", "in_app_include"),
|
|
32
|
+
("INSIDER_TRANSPORT_QUEUE_SIZE", "transport_queue_size"),
|
|
33
|
+
("INSIDER_TRANSPORT_FLUSH_TIMEOUT", "transport_flush_timeout"),
|
|
34
|
+
("INSIDER_DEBUG", "debug"),
|
|
35
|
+
):
|
|
36
|
+
value = getattr(settings, setting, None)
|
|
37
|
+
if value is not None:
|
|
38
|
+
kwargs[key] = value
|
|
39
|
+
return kwargs
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InsiderConfig(AppConfig):
|
|
43
|
+
"""Side-effect-only Django app: initialize the SDK when ready."""
|
|
44
|
+
|
|
45
|
+
name = "insider.contrib.django"
|
|
46
|
+
label = "insider_django"
|
|
47
|
+
verbose_name = "Insider (telemetry)"
|
|
48
|
+
|
|
49
|
+
def ready(self) -> None:
|
|
50
|
+
try:
|
|
51
|
+
kwargs = _settings_kwargs()
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
debug(f"settings read failed: {exc}")
|
|
54
|
+
return
|
|
55
|
+
# No DSN means disabled mode — `init` itself debug-logs that and
|
|
56
|
+
# returns None. We pass kwargs.pop("dsn", None) explicitly so the
|
|
57
|
+
# env-var fallback in `init` still applies if Django's settings
|
|
58
|
+
# didn't define it.
|
|
59
|
+
dsn = kwargs.pop("dsn", None)
|
|
60
|
+
init(dsn, **kwargs)
|