logsetu-django 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.
- logsetu/__init__.py +41 -0
- logsetu/apps.py +6 -0
- logsetu/client.py +250 -0
- logsetu/conf.py +41 -0
- logsetu/context.py +36 -0
- logsetu/handler.py +188 -0
- logsetu/middleware.py +186 -0
- logsetu/py.typed +0 -0
- logsetu_django-0.1.0.dist-info/METADATA +153 -0
- logsetu_django-0.1.0.dist-info/RECORD +12 -0
- logsetu_django-0.1.0.dist-info/WHEEL +4 -0
- logsetu_django-0.1.0.dist-info/licenses/LICENSE +21 -0
logsetu/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""LogSetu SDK for Django & DRF.
|
|
2
|
+
|
|
3
|
+
Quick start (settings.py)::
|
|
4
|
+
|
|
5
|
+
LOGGING = {
|
|
6
|
+
"version": 1,
|
|
7
|
+
"handlers": {
|
|
8
|
+
"logsetu": {
|
|
9
|
+
"class": "logsetu.handler.LogSetuHandler",
|
|
10
|
+
"api_key": env("LOGSETU_API_KEY"),
|
|
11
|
+
"endpoint": env("LOGSETU_ENDPOINT"),
|
|
12
|
+
"environment": "production",
|
|
13
|
+
"source": "django-backend",
|
|
14
|
+
"level": "WARNING",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"root": {"handlers": ["logsetu"], "level": "INFO"},
|
|
18
|
+
}
|
|
19
|
+
MIDDLEWARE = [..., "logsetu.middleware.LogSetuMiddleware"]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .client import LogSetuClient, get_client, __version__
|
|
23
|
+
from .context import get_request_context, update_request_context
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"LogSetuClient",
|
|
27
|
+
"get_client",
|
|
28
|
+
"get_request_context",
|
|
29
|
+
"update_request_context",
|
|
30
|
+
"capture_exception",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
default_app_config = "logsetu.apps.LogSetuConfig"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def capture_exception(exc: BaseException, message: str = "", **meta: object) -> None:
|
|
38
|
+
"""Report an exception through the configured ``LogSetuHandler`` (via the stdlib logger)."""
|
|
39
|
+
import logging
|
|
40
|
+
|
|
41
|
+
logging.getLogger("logsetu.capture").error(message or f"{type(exc).__name__}: {exc}", exc_info=exc, extra=meta)
|
logsetu/apps.py
ADDED
logsetu/client.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Thread-safe, batching HTTP client for the LogSetu ingestion API.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library so the package has zero runtime dependencies.
|
|
4
|
+
Never raises into the host application: transport failures are reported once on
|
|
5
|
+
stderr and the affected batch is dropped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import queue
|
|
13
|
+
import random
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
LogDict = Dict[str, Any]
|
|
24
|
+
|
|
25
|
+
_MAX_BATCH = 500
|
|
26
|
+
_FLUSH: Any = object() # queue sentinel: send whatever is buffered now
|
|
27
|
+
_STOP: Any = object() # queue sentinel: send and exit
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_json_safe(value: Any, depth: int = 0) -> Any:
|
|
31
|
+
"""Coerce arbitrary values into something ``json.dumps`` accepts."""
|
|
32
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
33
|
+
return value
|
|
34
|
+
if depth > 6:
|
|
35
|
+
return repr(value)
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
return {str(k): to_json_safe(v, depth + 1) for k, v in list(value.items())[:200]}
|
|
38
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
39
|
+
return [to_json_safe(v, depth + 1) for v in list(value)[:200]]
|
|
40
|
+
if isinstance(value, bytes):
|
|
41
|
+
return value.decode("utf-8", errors="replace")[:2000]
|
|
42
|
+
if hasattr(value, "isoformat"):
|
|
43
|
+
try:
|
|
44
|
+
return value.isoformat()
|
|
45
|
+
except Exception: # pragma: no cover
|
|
46
|
+
pass
|
|
47
|
+
return repr(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LogSetuClient:
|
|
51
|
+
"""Queues log dicts and ships them in batches from a background daemon thread."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
endpoint: str,
|
|
57
|
+
*,
|
|
58
|
+
environment: str = "production",
|
|
59
|
+
source: str = "django",
|
|
60
|
+
flush_interval: float = 2.0,
|
|
61
|
+
batch_size: int = 10,
|
|
62
|
+
max_queue_size: int = 1000,
|
|
63
|
+
max_retries: int = 3,
|
|
64
|
+
timeout: float = 5.0,
|
|
65
|
+
debug: bool = False,
|
|
66
|
+
enabled: bool = True,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.api_key = api_key or ""
|
|
69
|
+
self.endpoint = (endpoint or "").rstrip("/")
|
|
70
|
+
self.ingest_url = f"{self.endpoint}/api/v1/ingest"
|
|
71
|
+
self.environment = environment
|
|
72
|
+
self.source = source
|
|
73
|
+
self.flush_interval = max(0.05, float(flush_interval))
|
|
74
|
+
self.batch_size = max(1, min(int(batch_size), _MAX_BATCH))
|
|
75
|
+
self.max_queue_size = max(1, int(max_queue_size))
|
|
76
|
+
self.max_retries = max(0, int(max_retries))
|
|
77
|
+
self.timeout = float(timeout)
|
|
78
|
+
self.debug = debug
|
|
79
|
+
self.enabled = bool(enabled and self.api_key and self.endpoint)
|
|
80
|
+
|
|
81
|
+
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=self.max_queue_size + 2)
|
|
82
|
+
self._dropped = 0
|
|
83
|
+
self._warned = False
|
|
84
|
+
self._lock = threading.Lock()
|
|
85
|
+
self._idle = threading.Event()
|
|
86
|
+
self._idle.set()
|
|
87
|
+
self._stopped = False
|
|
88
|
+
self._thread: Optional[threading.Thread] = None
|
|
89
|
+
|
|
90
|
+
if not self.enabled:
|
|
91
|
+
if enabled:
|
|
92
|
+
self._warn("api_key/endpoint not configured; logs will not be sent")
|
|
93
|
+
return
|
|
94
|
+
self._thread = threading.Thread(target=self._run, name="logsetu-worker", daemon=True)
|
|
95
|
+
self._thread.start()
|
|
96
|
+
atexit.register(self.close)
|
|
97
|
+
|
|
98
|
+
# ---------- public ----------
|
|
99
|
+
|
|
100
|
+
def log(
|
|
101
|
+
self,
|
|
102
|
+
level: str,
|
|
103
|
+
message: str,
|
|
104
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
105
|
+
*,
|
|
106
|
+
timestamp: Optional[str] = None,
|
|
107
|
+
source: Optional[str] = None,
|
|
108
|
+
environment: Optional[str] = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
if not self.enabled or self._stopped:
|
|
111
|
+
return
|
|
112
|
+
entry: LogDict = {
|
|
113
|
+
"level": level,
|
|
114
|
+
"message": str(message)[:10_000],
|
|
115
|
+
"source": source or self.source,
|
|
116
|
+
"environment": environment or self.environment,
|
|
117
|
+
}
|
|
118
|
+
if timestamp:
|
|
119
|
+
entry["timestamp"] = timestamp
|
|
120
|
+
if meta:
|
|
121
|
+
entry["meta"] = to_json_safe(meta)
|
|
122
|
+
self.enqueue(entry)
|
|
123
|
+
|
|
124
|
+
def enqueue(self, entry: LogDict) -> None:
|
|
125
|
+
if not self.enabled or self._stopped:
|
|
126
|
+
return
|
|
127
|
+
self._idle.clear()
|
|
128
|
+
with self._lock:
|
|
129
|
+
if self._queue.qsize() >= self.max_queue_size:
|
|
130
|
+
# Drop the oldest to make room; a log SDK must never block the request cycle.
|
|
131
|
+
try:
|
|
132
|
+
self._queue.get_nowait()
|
|
133
|
+
except queue.Empty: # pragma: no cover
|
|
134
|
+
pass
|
|
135
|
+
self._dropped += 1
|
|
136
|
+
try:
|
|
137
|
+
self._queue.put_nowait(entry)
|
|
138
|
+
except queue.Full: # pragma: no cover
|
|
139
|
+
self._dropped += 1
|
|
140
|
+
|
|
141
|
+
def flush(self, timeout: Optional[float] = 10.0) -> bool:
|
|
142
|
+
"""Block until everything currently queued has been sent (or *timeout* elapses)."""
|
|
143
|
+
if not self.enabled or self._stopped:
|
|
144
|
+
return True
|
|
145
|
+
self._idle.clear()
|
|
146
|
+
try:
|
|
147
|
+
self._queue.put_nowait(_FLUSH)
|
|
148
|
+
except queue.Full: # pragma: no cover
|
|
149
|
+
pass
|
|
150
|
+
return self._idle.wait(timeout)
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
if not self.enabled or self._stopped:
|
|
154
|
+
return
|
|
155
|
+
self._stopped = True
|
|
156
|
+
self._queue.put(_STOP)
|
|
157
|
+
if self._thread is not None:
|
|
158
|
+
self._thread.join(timeout=10.0)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def dropped(self) -> int:
|
|
162
|
+
return self._dropped
|
|
163
|
+
|
|
164
|
+
# ---------- worker ----------
|
|
165
|
+
|
|
166
|
+
def _run(self) -> None:
|
|
167
|
+
batch: List[LogDict] = []
|
|
168
|
+
deadline = time.monotonic() + self.flush_interval
|
|
169
|
+
while True:
|
|
170
|
+
try:
|
|
171
|
+
item = self._queue.get(timeout=max(0.0, deadline - time.monotonic()))
|
|
172
|
+
except queue.Empty:
|
|
173
|
+
item = _FLUSH
|
|
174
|
+
if item is _STOP:
|
|
175
|
+
self._send(batch + self._drain())
|
|
176
|
+
self._idle.set()
|
|
177
|
+
return
|
|
178
|
+
if item is not _FLUSH:
|
|
179
|
+
batch.append(item)
|
|
180
|
+
if batch and (len(batch) >= self.batch_size or item is _FLUSH):
|
|
181
|
+
self._send(batch + self._drain(_MAX_BATCH - len(batch)))
|
|
182
|
+
batch = []
|
|
183
|
+
if item is _FLUSH or not batch:
|
|
184
|
+
deadline = time.monotonic() + self.flush_interval
|
|
185
|
+
if not batch and self._queue.empty():
|
|
186
|
+
self._idle.set()
|
|
187
|
+
|
|
188
|
+
def _drain(self, limit: int = _MAX_BATCH) -> List[LogDict]:
|
|
189
|
+
out: List[LogDict] = []
|
|
190
|
+
while len(out) < limit:
|
|
191
|
+
try:
|
|
192
|
+
more = self._queue.get_nowait()
|
|
193
|
+
except queue.Empty:
|
|
194
|
+
break
|
|
195
|
+
if more is _STOP:
|
|
196
|
+
self._queue.put_nowait(_STOP)
|
|
197
|
+
break
|
|
198
|
+
if more is not _FLUSH:
|
|
199
|
+
out.append(more)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
def _send(self, batch: List[LogDict]) -> None:
|
|
203
|
+
if not batch:
|
|
204
|
+
return
|
|
205
|
+
body = json.dumps(batch, default=str).encode("utf-8")
|
|
206
|
+
headers = {
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
209
|
+
"User-Agent": f"logsetu-django/{__version__}",
|
|
210
|
+
}
|
|
211
|
+
last_error = "unknown error"
|
|
212
|
+
for attempt in range(self.max_retries + 1):
|
|
213
|
+
try:
|
|
214
|
+
req = urllib.request.Request(self.ingest_url, data=body, headers=headers, method="POST")
|
|
215
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res: # noqa: S310 - user-configured URL
|
|
216
|
+
if 200 <= res.status < 300:
|
|
217
|
+
return
|
|
218
|
+
last_error = f"HTTP {res.status}"
|
|
219
|
+
except urllib.error.HTTPError as e:
|
|
220
|
+
if e.code < 500 and e.code != 429:
|
|
221
|
+
self._warn(f"server rejected batch (HTTP {e.code}); dropped {len(batch)} logs")
|
|
222
|
+
return
|
|
223
|
+
last_error = f"HTTP {e.code}"
|
|
224
|
+
except Exception as e: # network errors, timeouts, DNS
|
|
225
|
+
last_error = repr(e)
|
|
226
|
+
if attempt < self.max_retries:
|
|
227
|
+
time.sleep(min(30.0, 0.5 * (2**attempt)) + random.random() * 0.25)
|
|
228
|
+
self._warn(f"giving up after {self.max_retries + 1} attempts ({last_error}); dropped {len(batch)} logs")
|
|
229
|
+
|
|
230
|
+
def _warn(self, msg: str) -> None:
|
|
231
|
+
# Deliberately bypass the logging module: the LogSetu handler may be attached to the root logger.
|
|
232
|
+
if self.debug or not self._warned:
|
|
233
|
+
self._warned = True
|
|
234
|
+
suffix = "" if self.debug else " (further transport warnings suppressed; set debug=True to see them)"
|
|
235
|
+
sys.stderr.write(f"[logsetu] {msg}{suffix}\n")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_registry: Dict[Tuple[str, str], LogSetuClient] = {}
|
|
239
|
+
_registry_lock = threading.Lock()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_client(api_key: str, endpoint: str, **kwargs: Any) -> LogSetuClient:
|
|
243
|
+
"""Return a shared client per (api_key, endpoint) so the handler and middleware use one queue."""
|
|
244
|
+
key = (api_key or "", (endpoint or "").rstrip("/"))
|
|
245
|
+
with _registry_lock:
|
|
246
|
+
client = _registry.get(key)
|
|
247
|
+
if client is None or client._stopped:
|
|
248
|
+
client = LogSetuClient(api_key, endpoint, **kwargs)
|
|
249
|
+
_registry[key] = client
|
|
250
|
+
return client
|
logsetu/conf.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Configuration helpers: resolve callables from dotted paths and read ``settings.LOGSETU``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
7
|
+
|
|
8
|
+
TenantGetter = Callable[[Any], Optional[str]]
|
|
9
|
+
|
|
10
|
+
DEFAULT_REDACT_HEADERS = ("authorization", "cookie", "set-cookie", "x-api-key", "x-csrftoken", "proxy-authorization")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def import_callable(value: Union[str, Callable[..., Any], None]) -> Optional[Callable[..., Any]]:
|
|
14
|
+
"""Accept a callable or a dotted path like ``"myapp.tenants.get_tenant_id"``."""
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
if callable(value):
|
|
18
|
+
return value
|
|
19
|
+
if not isinstance(value, str):
|
|
20
|
+
raise TypeError(f"expected callable or dotted path, got {type(value)!r}")
|
|
21
|
+
module_path, _, attr = value.rpartition(".")
|
|
22
|
+
if not module_path:
|
|
23
|
+
raise ImportError(f"'{value}' is not a dotted path")
|
|
24
|
+
module = importlib.import_module(module_path)
|
|
25
|
+
try:
|
|
26
|
+
return getattr(module, attr)
|
|
27
|
+
except AttributeError as e:
|
|
28
|
+
raise ImportError(f"module '{module_path}' has no attribute '{attr}'") from e
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def django_settings() -> Dict[str, Any]:
|
|
32
|
+
"""``settings.LOGSETU`` if Django is configured, else ``{}``. Never raises."""
|
|
33
|
+
try:
|
|
34
|
+
from django.conf import settings
|
|
35
|
+
|
|
36
|
+
if not settings.configured:
|
|
37
|
+
return {}
|
|
38
|
+
value = getattr(settings, "LOGSETU", None)
|
|
39
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
40
|
+
except Exception:
|
|
41
|
+
return {}
|
logsetu/context.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Per-request context (path, user, tenant, request id) shared between middleware and handler.
|
|
2
|
+
|
|
3
|
+
Backed by ``contextvars`` so it works for both sync (threaded) and async Django.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextvars
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
_request_context: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
|
|
12
|
+
"logsetu_request_context", default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_request_context(ctx: Optional[Dict[str, Any]]) -> contextvars.Token:
|
|
17
|
+
return _request_context.set(ctx)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reset_request_context(token: contextvars.Token) -> None:
|
|
21
|
+
try:
|
|
22
|
+
_request_context.reset(token)
|
|
23
|
+
except ValueError: # token from another context (async edge cases)
|
|
24
|
+
_request_context.set(None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_request_context() -> Dict[str, Any]:
|
|
28
|
+
return dict(_request_context.get() or {})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_request_context(**fields: Any) -> None:
|
|
32
|
+
"""Attach extra fields (e.g. tenant_id) to every log emitted during the current request."""
|
|
33
|
+
current = _request_context.get()
|
|
34
|
+
merged = dict(current or {})
|
|
35
|
+
merged.update(fields)
|
|
36
|
+
_request_context.set(merged)
|
logsetu/handler.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``logging.Handler`` that ships records to LogSetu.
|
|
2
|
+
|
|
3
|
+
Configure it through Django's ``LOGGING`` dict (or ``logging.config.dictConfig``)::
|
|
4
|
+
|
|
5
|
+
"handlers": {
|
|
6
|
+
"logsetu": {
|
|
7
|
+
"class": "logsetu.handler.LogSetuHandler",
|
|
8
|
+
"api_key": env("LOGSETU_API_KEY"),
|
|
9
|
+
"endpoint": env("LOGSETU_ENDPOINT"),
|
|
10
|
+
"environment": "production",
|
|
11
|
+
"source": "django-backend",
|
|
12
|
+
"level": "WARNING",
|
|
13
|
+
# optional:
|
|
14
|
+
# "get_tenant_id": "myapp.tenants.get_tenant_id",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
24
|
+
|
|
25
|
+
from .client import LogSetuClient, get_client
|
|
26
|
+
from .conf import TenantGetter, django_settings, import_callable
|
|
27
|
+
from .context import get_request_context
|
|
28
|
+
|
|
29
|
+
LEVEL_MAP = {
|
|
30
|
+
logging.DEBUG: "debug",
|
|
31
|
+
logging.INFO: "info",
|
|
32
|
+
logging.WARNING: "warn",
|
|
33
|
+
logging.ERROR: "error",
|
|
34
|
+
logging.CRITICAL: "fatal",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Attributes every LogRecord has; anything else came from ``extra=`` and is forwarded as meta.
|
|
38
|
+
_STANDARD_ATTRS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"name", "msg", "args", "levelname", "levelno", "pathname", "filename", "module", "exc_info", "exc_text",
|
|
41
|
+
"stack_info", "lineno", "funcName", "created", "msecs", "relativeCreated", "thread", "threadName",
|
|
42
|
+
"processName", "process", "message", "asctime", "taskName",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def level_name(levelno: int) -> str:
|
|
48
|
+
if levelno >= logging.CRITICAL:
|
|
49
|
+
return "fatal"
|
|
50
|
+
if levelno >= logging.ERROR:
|
|
51
|
+
return "error"
|
|
52
|
+
if levelno >= logging.WARNING:
|
|
53
|
+
return "warn"
|
|
54
|
+
if levelno >= logging.INFO:
|
|
55
|
+
return "info"
|
|
56
|
+
return "debug"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LogSetuHandler(logging.Handler):
|
|
60
|
+
"""Send Python logging records to a LogSetu server, batched on a background thread."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
api_key: Optional[str] = None,
|
|
65
|
+
endpoint: Optional[str] = None,
|
|
66
|
+
*,
|
|
67
|
+
environment: Optional[str] = None,
|
|
68
|
+
source: Optional[str] = None,
|
|
69
|
+
level: Union[int, str] = logging.NOTSET,
|
|
70
|
+
get_tenant_id: Union[str, TenantGetter, None] = None,
|
|
71
|
+
include_extra: bool = True,
|
|
72
|
+
include_request_context: bool = True,
|
|
73
|
+
flush_interval: float = 2.0,
|
|
74
|
+
batch_size: int = 10,
|
|
75
|
+
max_queue_size: int = 1000,
|
|
76
|
+
max_retries: int = 3,
|
|
77
|
+
timeout: float = 5.0,
|
|
78
|
+
debug: bool = False,
|
|
79
|
+
client: Optional[LogSetuClient] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(level)
|
|
82
|
+
cfg = django_settings()
|
|
83
|
+
self.api_key = api_key or cfg.get("API_KEY") or ""
|
|
84
|
+
self.endpoint = endpoint or cfg.get("ENDPOINT") or ""
|
|
85
|
+
self.environment = environment or cfg.get("ENVIRONMENT") or "production"
|
|
86
|
+
self.source = source or cfg.get("SOURCE") or "django"
|
|
87
|
+
self.include_extra = include_extra
|
|
88
|
+
self.include_request_context = include_request_context
|
|
89
|
+
self.get_tenant_id: Optional[Callable[[Any], Optional[str]]] = import_callable(
|
|
90
|
+
get_tenant_id or cfg.get("GET_TENANT_ID")
|
|
91
|
+
)
|
|
92
|
+
self.client = client or get_client(
|
|
93
|
+
self.api_key,
|
|
94
|
+
self.endpoint,
|
|
95
|
+
environment=self.environment,
|
|
96
|
+
source=self.source,
|
|
97
|
+
flush_interval=flush_interval,
|
|
98
|
+
batch_size=batch_size,
|
|
99
|
+
max_queue_size=max_queue_size,
|
|
100
|
+
max_retries=max_retries,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
debug=bool(debug or cfg.get("DEBUG", False)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
106
|
+
try:
|
|
107
|
+
# Django's own ``django.request`` logger re-logs 5xx responses; skip those the
|
|
108
|
+
# middleware already reported with richer context.
|
|
109
|
+
req = record.__dict__.get("request")
|
|
110
|
+
if req is not None and getattr(req, "_logsetu_reported", False):
|
|
111
|
+
return
|
|
112
|
+
self.client.enqueue(self.build_entry(record))
|
|
113
|
+
except Exception:
|
|
114
|
+
self.handleError(record)
|
|
115
|
+
|
|
116
|
+
def build_entry(self, record: logging.LogRecord) -> Dict[str, Any]:
|
|
117
|
+
meta: Dict[str, Any] = {
|
|
118
|
+
"logger": record.name,
|
|
119
|
+
"module": record.module,
|
|
120
|
+
"function": record.funcName,
|
|
121
|
+
"line": record.lineno,
|
|
122
|
+
"process": record.process,
|
|
123
|
+
"thread": record.threadName,
|
|
124
|
+
}
|
|
125
|
+
if record.exc_info:
|
|
126
|
+
exc_type = record.exc_info[0]
|
|
127
|
+
meta["exception"] = exc_type.__name__ if exc_type else None
|
|
128
|
+
if not record.exc_text:
|
|
129
|
+
record.exc_text = logging.Formatter().formatException(record.exc_info)
|
|
130
|
+
if record.exc_text:
|
|
131
|
+
meta["stack"] = record.exc_text
|
|
132
|
+
if record.stack_info:
|
|
133
|
+
meta["stack_info"] = record.stack_info
|
|
134
|
+
if self.include_extra:
|
|
135
|
+
for key, value in record.__dict__.items():
|
|
136
|
+
if key not in _STANDARD_ATTRS and not key.startswith("_") and key not in meta:
|
|
137
|
+
meta[key] = _summarize_request(value) if key == "request" else value
|
|
138
|
+
if self.include_request_context:
|
|
139
|
+
ctx = get_request_context()
|
|
140
|
+
if ctx:
|
|
141
|
+
meta["request"] = ctx
|
|
142
|
+
if "tenant_id" in ctx and "tenant_id" not in meta:
|
|
143
|
+
meta["tenant_id"] = ctx["tenant_id"]
|
|
144
|
+
# Allow logger.info("...", extra={"source": "celery"}) to override per-record.
|
|
145
|
+
source = meta.pop("source", None) or self.source
|
|
146
|
+
environment = meta.pop("environment", None) or self.environment
|
|
147
|
+
return {
|
|
148
|
+
"level": level_name(record.levelno),
|
|
149
|
+
"message": self.format_message(record),
|
|
150
|
+
"source": str(source),
|
|
151
|
+
"environment": str(environment),
|
|
152
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
153
|
+
"meta": _json_safe(meta),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def format_message(self, record: logging.LogRecord) -> str:
|
|
157
|
+
try:
|
|
158
|
+
msg = record.getMessage()
|
|
159
|
+
except Exception:
|
|
160
|
+
msg = str(record.msg)
|
|
161
|
+
return msg[:10_000]
|
|
162
|
+
|
|
163
|
+
def flush(self) -> None:
|
|
164
|
+
self.client.flush(timeout=5.0)
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
try:
|
|
168
|
+
self.client.flush(timeout=5.0)
|
|
169
|
+
finally:
|
|
170
|
+
super().close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _summarize_request(value: Any) -> Any:
|
|
174
|
+
"""``extra={"request": HttpRequest}`` (as Django does) → a small dict instead of a repr."""
|
|
175
|
+
meta = getattr(value, "META", None)
|
|
176
|
+
if not isinstance(meta, dict):
|
|
177
|
+
return value
|
|
178
|
+
return {
|
|
179
|
+
"method": getattr(value, "method", None),
|
|
180
|
+
"path": getattr(value, "path", None),
|
|
181
|
+
"query": meta.get("QUERY_STRING", "")[:2000],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json_safe(meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
186
|
+
from .client import to_json_safe
|
|
187
|
+
|
|
188
|
+
return {k: to_json_safe(v) for k, v in meta.items() if v is not None}
|
logsetu/middleware.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Django middleware: attaches request context to every log and reports unhandled exceptions.
|
|
2
|
+
|
|
3
|
+
Add it to ``MIDDLEWARE`` (after ``AuthenticationMiddleware`` if you want user ids)::
|
|
4
|
+
|
|
5
|
+
MIDDLEWARE = [..., "logsetu.middleware.LogSetuMiddleware"]
|
|
6
|
+
|
|
7
|
+
It reuses the client of a configured ``LogSetuHandler``; alternatively configure it directly::
|
|
8
|
+
|
|
9
|
+
LOGSETU = {
|
|
10
|
+
"API_KEY": env("LOGSETU_API_KEY"),
|
|
11
|
+
"ENDPOINT": env("LOGSETU_ENDPOINT"),
|
|
12
|
+
"ENVIRONMENT": "production",
|
|
13
|
+
"SOURCE": "django-backend",
|
|
14
|
+
"GET_TENANT_ID": "myapp.tenants.get_tenant_id", # callable(request) -> str | None
|
|
15
|
+
"REDACT_HEADERS": ["authorization", "cookie"], # default list already covers the usual suspects
|
|
16
|
+
"CAPTURE_5XX": True, # also log responses with status >= 500 that didn't raise
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import traceback
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any, Callable, Dict, Optional
|
|
26
|
+
|
|
27
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
28
|
+
|
|
29
|
+
from .client import LogSetuClient, get_client
|
|
30
|
+
from .conf import DEFAULT_REDACT_HEADERS, django_settings, import_callable
|
|
31
|
+
from .context import get_request_context, reset_request_context, set_request_context
|
|
32
|
+
from .handler import LogSetuHandler
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_handler() -> Optional[LogSetuHandler]:
|
|
36
|
+
"""The first LogSetuHandler attached to any configured logger (root first)."""
|
|
37
|
+
loggers = [logging.getLogger()] + [
|
|
38
|
+
lg for lg in logging.Logger.manager.loggerDict.values() if isinstance(lg, logging.Logger)
|
|
39
|
+
]
|
|
40
|
+
for lg in loggers:
|
|
41
|
+
for h in lg.handlers:
|
|
42
|
+
if isinstance(h, LogSetuHandler):
|
|
43
|
+
return h
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LogSetuMiddleware(MiddlewareMixin):
|
|
48
|
+
"""Capture request metadata for every log line and report unhandled exceptions with full context."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, get_response: Callable[..., Any]) -> None:
|
|
51
|
+
super().__init__(get_response)
|
|
52
|
+
cfg = django_settings()
|
|
53
|
+
handler = _find_handler()
|
|
54
|
+
self.client: LogSetuClient = (
|
|
55
|
+
handler.client
|
|
56
|
+
if handler is not None
|
|
57
|
+
else get_client(
|
|
58
|
+
cfg.get("API_KEY", ""),
|
|
59
|
+
cfg.get("ENDPOINT", ""),
|
|
60
|
+
environment=cfg.get("ENVIRONMENT", "production"),
|
|
61
|
+
source=cfg.get("SOURCE", "django"),
|
|
62
|
+
debug=bool(cfg.get("DEBUG", False)),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
self.source: str = cfg.get("SOURCE") or (handler.source if handler else "django")
|
|
66
|
+
self.environment: str = cfg.get("ENVIRONMENT") or (handler.environment if handler else "production")
|
|
67
|
+
self.get_tenant_id = import_callable(cfg.get("GET_TENANT_ID")) or (handler.get_tenant_id if handler else None)
|
|
68
|
+
self.redact_headers = {h.lower() for h in cfg.get("REDACT_HEADERS", DEFAULT_REDACT_HEADERS)}
|
|
69
|
+
self.capture_5xx: bool = bool(cfg.get("CAPTURE_5XX", True))
|
|
70
|
+
|
|
71
|
+
# ---------- request lifecycle ----------
|
|
72
|
+
|
|
73
|
+
def process_request(self, request: Any) -> None:
|
|
74
|
+
ctx: Dict[str, Any] = {
|
|
75
|
+
"request_id": request.META.get("HTTP_X_REQUEST_ID") or uuid.uuid4().hex[:16],
|
|
76
|
+
"method": request.method,
|
|
77
|
+
"path": request.path,
|
|
78
|
+
}
|
|
79
|
+
ip = self._client_ip(request)
|
|
80
|
+
if ip:
|
|
81
|
+
ctx["ip"] = ip
|
|
82
|
+
tenant = self._tenant(request)
|
|
83
|
+
if tenant is not None:
|
|
84
|
+
ctx["tenant_id"] = tenant
|
|
85
|
+
request._logsetu_token = set_request_context(ctx) # noqa: SLF001
|
|
86
|
+
request.logsetu_request_id = ctx["request_id"]
|
|
87
|
+
|
|
88
|
+
def process_response(self, request: Any, response: Any) -> Any:
|
|
89
|
+
try:
|
|
90
|
+
status = getattr(response, "status_code", 0)
|
|
91
|
+
if self.capture_5xx and status >= 500 and not getattr(request, "_logsetu_reported", False):
|
|
92
|
+
self._report(
|
|
93
|
+
request,
|
|
94
|
+
level="error",
|
|
95
|
+
message=f"HTTP {status} on {request.method} {request.path}",
|
|
96
|
+
extra={"status": status},
|
|
97
|
+
)
|
|
98
|
+
request._logsetu_reported = True # noqa: SLF001
|
|
99
|
+
finally:
|
|
100
|
+
token = getattr(request, "_logsetu_token", None)
|
|
101
|
+
if token is not None:
|
|
102
|
+
reset_request_context(token)
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
def process_exception(self, request: Any, exception: BaseException) -> None:
|
|
106
|
+
stack = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
|
|
107
|
+
self._report(
|
|
108
|
+
request,
|
|
109
|
+
level="error",
|
|
110
|
+
message=f"{type(exception).__name__}: {exception}",
|
|
111
|
+
extra={"exception": type(exception).__name__, "stack": stack},
|
|
112
|
+
)
|
|
113
|
+
request._logsetu_reported = True # noqa: SLF001
|
|
114
|
+
return None # let Django produce the 500 response as usual
|
|
115
|
+
|
|
116
|
+
# ---------- helpers ----------
|
|
117
|
+
|
|
118
|
+
def _report(self, request: Any, *, level: str, message: str, extra: Dict[str, Any]) -> None:
|
|
119
|
+
try:
|
|
120
|
+
meta: Dict[str, Any] = {**get_request_context(), **extra, "request": self.request_info(request)}
|
|
121
|
+
user = self.user_info(request)
|
|
122
|
+
if user:
|
|
123
|
+
meta["user"] = user
|
|
124
|
+
self.client.log(level, message, meta, source=self.source, environment=self.environment)
|
|
125
|
+
except Exception: # never break the response cycle
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def request_info(self, request: Any) -> Dict[str, Any]:
|
|
129
|
+
info: Dict[str, Any] = {
|
|
130
|
+
"method": request.method,
|
|
131
|
+
"path": request.path,
|
|
132
|
+
"query": request.META.get("QUERY_STRING", "")[:2000],
|
|
133
|
+
"headers": self.redacted_headers(request),
|
|
134
|
+
}
|
|
135
|
+
ip = self._client_ip(request)
|
|
136
|
+
if ip:
|
|
137
|
+
info["ip"] = ip
|
|
138
|
+
try:
|
|
139
|
+
view = request.resolver_match.view_name if request.resolver_match else None
|
|
140
|
+
if view:
|
|
141
|
+
info["view"] = view
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
return info
|
|
145
|
+
|
|
146
|
+
def redacted_headers(self, request: Any) -> Dict[str, str]:
|
|
147
|
+
out: Dict[str, str] = {}
|
|
148
|
+
headers = getattr(request, "headers", None)
|
|
149
|
+
items = headers.items() if headers is not None else (
|
|
150
|
+
(k[5:].replace("_", "-"), v) for k, v in request.META.items() if k.startswith("HTTP_")
|
|
151
|
+
)
|
|
152
|
+
for name, value in items:
|
|
153
|
+
key = str(name).lower()
|
|
154
|
+
out[key] = "[REDACTED]" if key in self.redact_headers else str(value)[:500]
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
def user_info(self, request: Any) -> Optional[Dict[str, Any]]:
|
|
158
|
+
user = getattr(request, "user", None)
|
|
159
|
+
if user is None:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
if not getattr(user, "is_authenticated", False):
|
|
163
|
+
return None
|
|
164
|
+
info: Dict[str, Any] = {"id": getattr(user, "pk", None)}
|
|
165
|
+
username = getattr(user, "get_username", None)
|
|
166
|
+
if callable(username):
|
|
167
|
+
info["username"] = username()
|
|
168
|
+
return info
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def _tenant(self, request: Any) -> Optional[str]:
|
|
173
|
+
if self.get_tenant_id is None:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
value = self.get_tenant_id(request)
|
|
177
|
+
return None if value is None else str(value)
|
|
178
|
+
except Exception:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _client_ip(request: Any) -> Optional[str]:
|
|
183
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
184
|
+
if forwarded:
|
|
185
|
+
return forwarded.split(",")[0].strip()
|
|
186
|
+
return request.META.get("REMOTE_ADDR")
|
logsetu/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: logsetu-django
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LogSetu SDK for Django & DRF — ship Python logging records and request errors to your self-hosted LogSetu server
|
|
5
|
+
Project-URL: Homepage, https://github.com/itsrajverma/logsetu
|
|
6
|
+
Project-URL: Documentation, https://github.com/itsrajverma/logsetu/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/itsrajverma/logsetu
|
|
8
|
+
Project-URL: Issues, https://github.com/itsrajverma/logsetu/issues
|
|
9
|
+
Author: Raj Verma
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: django,drf,error-tracking,logging,logsetu,self-hosted,sentry-alternative
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: System :: Logging
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build; extra == 'dev'
|
|
23
|
+
Requires-Dist: django>=3.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: twine; extra == 'dev'
|
|
27
|
+
Provides-Extra: django
|
|
28
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# logsetu-django
|
|
32
|
+
|
|
33
|
+
Ship Django / DRF logs and unhandled errors to your self-hosted [LogSetu](https://github.com/itsrajverma/logsetu) server.
|
|
34
|
+
Zero runtime dependencies, non-blocking (background thread + batching), and it never crashes your app if the log server is down.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install logsetu-django
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Setup (2 minutes)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# settings.py
|
|
44
|
+
INSTALLED_APPS = [..., "logsetu"] # optional, but nice for discoverability
|
|
45
|
+
|
|
46
|
+
LOGGING = {
|
|
47
|
+
"version": 1,
|
|
48
|
+
"disable_existing_loggers": False,
|
|
49
|
+
"handlers": {
|
|
50
|
+
"logsetu": {
|
|
51
|
+
"class": "logsetu.handler.LogSetuHandler",
|
|
52
|
+
"api_key": env("LOGSETU_API_KEY"), # from the LogSetu dashboard → Projects
|
|
53
|
+
"endpoint": env("LOGSETU_ENDPOINT"), # e.g. https://logs.example.com
|
|
54
|
+
"environment": env("ENVIRONMENT", default="production"),
|
|
55
|
+
"source": "django-backend",
|
|
56
|
+
"level": "WARNING",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"root": {"handlers": ["logsetu"], "level": "INFO"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
MIDDLEWARE = [
|
|
63
|
+
...,
|
|
64
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
65
|
+
"logsetu.middleware.LogSetuMiddleware", # after auth middleware so user ids are captured
|
|
66
|
+
]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That's it. Every `logging` call at or above the handler's level is sent to LogSetu, and the middleware
|
|
70
|
+
reports unhandled exceptions (and 5xx responses) with the request path, method, view, user, redacted headers and full traceback.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import logging
|
|
74
|
+
log = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
log.warning("Checkout is slow", extra={"duration_ms": 1830, "cart_items": 12})
|
|
77
|
+
try:
|
|
78
|
+
charge(card)
|
|
79
|
+
except PaymentError:
|
|
80
|
+
log.exception("Payment failed") # traceback is attached automatically
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Multi-tenant apps
|
|
84
|
+
|
|
85
|
+
Tag every log with the current tenant by pointing the SDK at a callable `(request) -> str | None`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# settings.py
|
|
89
|
+
LOGGING["handlers"]["logsetu"]["get_tenant_id"] = "myapp.tenants.get_tenant_id"
|
|
90
|
+
|
|
91
|
+
# myapp/tenants.py
|
|
92
|
+
def get_tenant_id(request):
|
|
93
|
+
return getattr(request, "tenant", None) and request.tenant.slug
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or, from anywhere inside a request (e.g. after you resolve the tenant in your own middleware):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from logsetu import update_request_context
|
|
100
|
+
update_request_context(tenant_id=tenant.slug, plan=tenant.plan)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Handler options
|
|
104
|
+
|
|
105
|
+
| Option | Default | Description |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `api_key` / `endpoint` | — | Required (or set `LOGSETU = {"API_KEY": ..., "ENDPOINT": ...}` in settings) |
|
|
108
|
+
| `environment` | `"production"` | Tag on every log |
|
|
109
|
+
| `source` | `"django"` | Tag on every log; override per record with `extra={"source": "celery"}` |
|
|
110
|
+
| `get_tenant_id` | `None` | Dotted path or callable `(request) -> str` |
|
|
111
|
+
| `include_extra` | `True` | Forward `extra={...}` fields as metadata |
|
|
112
|
+
| `include_request_context` | `True` | Attach request id / path / method / tenant set by the middleware |
|
|
113
|
+
| `flush_interval` | `2.0` | Seconds between background flushes |
|
|
114
|
+
| `batch_size` | `10` | Flush as soon as this many logs are queued |
|
|
115
|
+
| `max_queue_size` | `1000` | Drop oldest logs beyond this |
|
|
116
|
+
| `max_retries` | `3` | Retries with exponential backoff on 5xx / network errors |
|
|
117
|
+
| `timeout` | `5.0` | HTTP timeout in seconds |
|
|
118
|
+
| `debug` | `False` | Print every transport failure to stderr (otherwise only the first) |
|
|
119
|
+
|
|
120
|
+
## Middleware options (`settings.LOGSETU`)
|
|
121
|
+
|
|
122
|
+
The middleware reuses the client and options of a configured `LogSetuHandler`. Without a handler it can run standalone:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
LOGSETU = {
|
|
126
|
+
"API_KEY": env("LOGSETU_API_KEY"),
|
|
127
|
+
"ENDPOINT": env("LOGSETU_ENDPOINT"),
|
|
128
|
+
"ENVIRONMENT": "production",
|
|
129
|
+
"SOURCE": "django-backend",
|
|
130
|
+
"GET_TENANT_ID": "myapp.tenants.get_tenant_id",
|
|
131
|
+
"REDACT_HEADERS": ["authorization", "cookie", "x-api-key"], # defaults already cover these
|
|
132
|
+
"CAPTURE_5XX": True, # also report 5xx responses that didn't raise
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Every request gets a `request_id` (taken from `X-Request-ID` if present, otherwise generated) which is
|
|
137
|
+
attached to all logs emitted during that request and exposed as `request.logsetu_request_id`.
|
|
138
|
+
|
|
139
|
+
## Guarantees
|
|
140
|
+
|
|
141
|
+
- **Never blocks** the request cycle: `emit()` only appends to an in-memory queue.
|
|
142
|
+
- **Never raises** into your app: transport errors are printed once to stderr, then suppressed.
|
|
143
|
+
- **Never leaks secrets**: `Authorization`, `Cookie`, `X-Api-Key`, `X-CSRFToken` and `Proxy-Authorization` are redacted.
|
|
144
|
+
- **Never loses the last logs** on shutdown: the queue is flushed via `atexit`.
|
|
145
|
+
|
|
146
|
+
## Celery / management commands
|
|
147
|
+
|
|
148
|
+
Nothing special — the handler works anywhere Python's `logging` does. Set `extra={"source": "celery"}` to distinguish
|
|
149
|
+
workers, or configure a second handler with a different `source`.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
logsetu/__init__.py,sha256=YzywcF8O7SfPcR3tOMSHfILwTdKBBQ4TcK-RF7Sk1-Y,1247
|
|
2
|
+
logsetu/apps.py,sha256=IHOVQ8M-3QyZWBVckt-YdgQ7P8I_SvFn_Pl4aWLQmEQ,118
|
|
3
|
+
logsetu/client.py,sha256=4HuC7TsHPFC8MjxpDqE8j7V_PrR7qevLQjznYD6l_WA,9050
|
|
4
|
+
logsetu/conf.py,sha256=W6kFaFRRl6I3g_LtJr7tTsBVG8hFlNyQ-4KsD_zoWAk,1497
|
|
5
|
+
logsetu/context.py,sha256=swmn2NAbvKQSLEI9xWLf3zv-xin7AJQoljw_LGgNUdY,1120
|
|
6
|
+
logsetu/handler.py,sha256=vJVlP4SKQv4AFJ8jw_l5DbhZiDGVaaBtNbz14Nmly5Y,6844
|
|
7
|
+
logsetu/middleware.py,sha256=YnYFdUdkr1V36RWMoNNdJ1Ydt2vJfsr3iZtaduM-ksg,7502
|
|
8
|
+
logsetu/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
logsetu_django-0.1.0.dist-info/METADATA,sha256=6WuXuEAO79uK7YZP32sKM8HtRukp6Zfdv-lVY86ek7E,5903
|
|
10
|
+
logsetu_django-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
11
|
+
logsetu_django-0.1.0.dist-info/licenses/LICENSE,sha256=VSNbiwzQEl94c_0klrG3xFGSyCCuTPklb97l9I1e3LU,1066
|
|
12
|
+
logsetu_django-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raj Verma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|