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 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
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LogSetuConfig(AppConfig):
5
+ name = "logsetu"
6
+ verbose_name = "LogSetu"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.