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 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
@@ -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"
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)