logsetu 0.3.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/handler.py ADDED
@@ -0,0 +1,191 @@
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
+ # Same for Flask's "Exception on /path" and uvicorn's "Exception in ASGI application".
113
+ if record.exc_info and getattr(record.exc_info[1], "_logsetu_reported", False):
114
+ return
115
+ self.client.enqueue(self.build_entry(record))
116
+ except Exception:
117
+ self.handleError(record)
118
+
119
+ def build_entry(self, record: logging.LogRecord) -> Dict[str, Any]:
120
+ meta: Dict[str, Any] = {
121
+ "logger": record.name,
122
+ "module": record.module,
123
+ "function": record.funcName,
124
+ "line": record.lineno,
125
+ "process": record.process,
126
+ "thread": record.threadName,
127
+ }
128
+ if record.exc_info:
129
+ exc_type = record.exc_info[0]
130
+ meta["exception"] = exc_type.__name__ if exc_type else None
131
+ if not record.exc_text:
132
+ record.exc_text = logging.Formatter().formatException(record.exc_info)
133
+ if record.exc_text:
134
+ meta["stack"] = record.exc_text
135
+ if record.stack_info:
136
+ meta["stack_info"] = record.stack_info
137
+ if self.include_extra:
138
+ for key, value in record.__dict__.items():
139
+ if key not in _STANDARD_ATTRS and not key.startswith("_") and key not in meta:
140
+ meta[key] = _summarize_request(value) if key == "request" else value
141
+ if self.include_request_context:
142
+ ctx = get_request_context()
143
+ if ctx:
144
+ meta["request"] = ctx
145
+ if "tenant_id" in ctx and "tenant_id" not in meta:
146
+ meta["tenant_id"] = ctx["tenant_id"]
147
+ # Allow logger.info("...", extra={"source": "celery"}) to override per-record.
148
+ source = meta.pop("source", None) or self.source
149
+ environment = meta.pop("environment", None) or self.environment
150
+ return {
151
+ "level": level_name(record.levelno),
152
+ "message": self.format_message(record),
153
+ "source": str(source),
154
+ "environment": str(environment),
155
+ "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
156
+ "meta": _json_safe(meta),
157
+ }
158
+
159
+ def format_message(self, record: logging.LogRecord) -> str:
160
+ try:
161
+ msg = record.getMessage()
162
+ except Exception:
163
+ msg = str(record.msg)
164
+ return msg[:10_000]
165
+
166
+ def flush(self) -> None:
167
+ self.client.flush(timeout=5.0)
168
+
169
+ def close(self) -> None:
170
+ try:
171
+ self.client.flush(timeout=5.0)
172
+ finally:
173
+ super().close()
174
+
175
+
176
+ def _summarize_request(value: Any) -> Any:
177
+ """``extra={"request": HttpRequest}`` (as Django does) → a small dict instead of a repr."""
178
+ meta = getattr(value, "META", None)
179
+ if not isinstance(meta, dict):
180
+ return value
181
+ return {
182
+ "method": getattr(value, "method", None),
183
+ "path": getattr(value, "path", None),
184
+ "query": meta.get("QUERY_STRING", "")[:2000],
185
+ }
186
+
187
+
188
+ def _json_safe(meta: Dict[str, Any]) -> Dict[str, Any]:
189
+ from .client import to_json_safe
190
+
191
+ 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
logsetu/web.py ADDED
@@ -0,0 +1,71 @@
1
+ """Framework-neutral helpers shared by the Flask and ASGI (FastAPI / Starlette) integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import traceback
7
+ import uuid
8
+ from typing import Any, Dict, Iterable, Optional, Tuple
9
+
10
+ from .client import LogSetuClient, get_client
11
+ from .conf import DEFAULT_REDACT_HEADERS
12
+ from .handler import LogSetuHandler
13
+
14
+
15
+ def make_client(
16
+ api_key: Optional[str],
17
+ endpoint: Optional[str],
18
+ *,
19
+ environment: str,
20
+ source: str,
21
+ debug: bool = False,
22
+ client: Optional[LogSetuClient] = None,
23
+ ) -> LogSetuClient:
24
+ if client is not None:
25
+ return client
26
+ return get_client(api_key or "", endpoint or "", environment=environment, source=source, debug=debug)
27
+
28
+
29
+ def attach_handler(client: LogSetuClient, level: int, logger: Optional[logging.Logger] = None) -> LogSetuHandler:
30
+ """Attach a ``LogSetuHandler`` sharing *client* to *logger* (root by default), once."""
31
+ target = logger or logging.getLogger()
32
+ for h in target.handlers:
33
+ if isinstance(h, LogSetuHandler) and h.client is client:
34
+ return h
35
+ handler = LogSetuHandler(client.api_key, client.endpoint, environment=client.environment, source=client.source,
36
+ level=level, client=client)
37
+ target.addHandler(handler)
38
+ if target.level == logging.NOTSET or target.level > level:
39
+ target.setLevel(level)
40
+ return handler
41
+
42
+
43
+ def request_id(headers: Dict[str, str]) -> str:
44
+ return headers.get("x-request-id") or uuid.uuid4().hex[:16]
45
+
46
+
47
+ def client_ip(headers: Dict[str, str], remote_addr: Optional[str]) -> Optional[str]:
48
+ forwarded = headers.get("x-forwarded-for")
49
+ if forwarded:
50
+ return forwarded.split(",")[0].strip()
51
+ return remote_addr
52
+
53
+
54
+ def redact(headers: Iterable[Tuple[str, str]], redact_headers: Iterable[str] = DEFAULT_REDACT_HEADERS) -> Dict[str, str]:
55
+ hidden = {h.lower() for h in redact_headers}
56
+ return {k.lower(): ("[REDACTED]" if k.lower() in hidden else str(v)[:500]) for k, v in headers}
57
+
58
+
59
+ def mark_reported(exc: BaseException) -> None:
60
+ """Flag *exc* so ``LogSetuHandler`` drops the framework's own log record for it (no duplicates)."""
61
+ try:
62
+ exc._logsetu_reported = True # type: ignore[attr-defined]
63
+ except Exception: # pragma: no cover - exceptions with __slots__
64
+ pass
65
+
66
+
67
+ def exception_meta(exc: BaseException) -> Dict[str, Any]:
68
+ return {
69
+ "exception": type(exc).__name__,
70
+ "stack": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
71
+ }
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.5
2
+ Name: logsetu
3
+ Version: 0.3.0
4
+ Summary: LogSetu SDK for Python — Django, DRF, Flask and FastAPI: ship 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: asgi,django,drf,error-tracking,fastapi,flask,logging,logsetu,python,self-hosted,sentry-alternative
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Framework :: Flask
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Topic :: System :: Logging
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: build; extra == 'dev'
25
+ Requires-Dist: django>=3.2; extra == 'dev'
26
+ Requires-Dist: fastapi>=0.95; extra == 'dev'
27
+ Requires-Dist: flask>=2.2; extra == 'dev'
28
+ Requires-Dist: httpx; extra == 'dev'
29
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Provides-Extra: django
33
+ Requires-Dist: django>=3.2; extra == 'django'
34
+ Provides-Extra: fastapi
35
+ Requires-Dist: fastapi>=0.95; extra == 'fastapi'
36
+ Provides-Extra: flask
37
+ Requires-Dist: flask>=2.2; extra == 'flask'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # logsetu (Python)
41
+
42
+ Ship Django / DRF, **Flask** and **FastAPI** logs and unhandled errors to your self-hosted [LogSetu](https://github.com/itsrajverma/logsetu) server.
43
+ Zero runtime dependencies, non-blocking (background thread + batching), and it never crashes your app if the log server is down.
44
+
45
+ ```bash
46
+ pip install "logsetu[django]" # Django / DRF
47
+ pip install "logsetu[flask]" # Flask
48
+ pip install "logsetu[fastapi]" # FastAPI / Starlette / any ASGI app
49
+ pip install logsetu # just the client + logging handler (zero dependencies)
50
+ ```
51
+
52
+ The extras only pull in the framework itself; the SDK has no runtime dependencies. Integrations:
53
+ [Django](#setup-2-minutes), [Flask](#flask) and [FastAPI / any ASGI app](#fastapi--starlette--asgi).
54
+
55
+ > **Coming from `logsetu-django`?** The package was renamed to `logsetu` in 0.3.0. The import name (`import logsetu`)
56
+ > and all settings are unchanged. Switch with `pip uninstall -y logsetu-django && pip install "logsetu[django]"` and
57
+ > update your requirements file. Don't keep both installed: they contain the same `logsetu` module. `logsetu-django`
58
+ > is still published with identical code for existing installs.
59
+
60
+ ## Setup (2 minutes)
61
+
62
+ ```python
63
+ # settings.py
64
+ INSTALLED_APPS = [..., "logsetu"] # optional, but nice for discoverability
65
+
66
+ LOGGING = {
67
+ "version": 1,
68
+ "disable_existing_loggers": False,
69
+ "handlers": {
70
+ "logsetu": {
71
+ "class": "logsetu.handler.LogSetuHandler",
72
+ "api_key": env("LOGSETU_API_KEY"), # from the LogSetu dashboard → Projects
73
+ "endpoint": env("LOGSETU_ENDPOINT"), # e.g. https://logs.example.com
74
+ "environment": env("ENVIRONMENT", default="production"),
75
+ "source": "django-backend",
76
+ "level": "WARNING",
77
+ },
78
+ },
79
+ "root": {"handlers": ["logsetu"], "level": "INFO"},
80
+ }
81
+
82
+ MIDDLEWARE = [
83
+ ...,
84
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
85
+ "logsetu.middleware.LogSetuMiddleware", # after auth middleware so user ids are captured
86
+ ]
87
+ ```
88
+
89
+ That's it. Every `logging` call at or above the handler's level is sent to LogSetu, and the middleware
90
+ reports unhandled exceptions (and 5xx responses) with the request path, method, view, user, redacted headers and full traceback.
91
+
92
+ ```python
93
+ import logging
94
+ log = logging.getLogger(__name__)
95
+
96
+ log.warning("Checkout is slow", extra={"duration_ms": 1830, "cart_items": 12})
97
+ try:
98
+ charge(card)
99
+ except PaymentError:
100
+ log.exception("Payment failed") # traceback is attached automatically
101
+ ```
102
+
103
+ ## Multi-tenant apps
104
+
105
+ Tag every log with the current tenant by pointing the SDK at a callable `(request) -> str | None`:
106
+
107
+ ```python
108
+ # settings.py
109
+ LOGGING["handlers"]["logsetu"]["get_tenant_id"] = "myapp.tenants.get_tenant_id"
110
+
111
+ # myapp/tenants.py
112
+ def get_tenant_id(request):
113
+ return getattr(request, "tenant", None) and request.tenant.slug
114
+ ```
115
+
116
+ Or, from anywhere inside a request (e.g. after you resolve the tenant in your own middleware):
117
+
118
+ ```python
119
+ from logsetu import update_request_context
120
+ update_request_context(tenant_id=tenant.slug, plan=tenant.plan)
121
+ ```
122
+
123
+ ## Handler options
124
+
125
+ | Option | Default | Description |
126
+ |---|---|---|
127
+ | `api_key` / `endpoint` | — | Required (or set `LOGSETU = {"API_KEY": ..., "ENDPOINT": ...}` in settings) |
128
+ | `environment` | `"production"` | Tag on every log |
129
+ | `source` | `"django"` | Tag on every log; override per record with `extra={"source": "celery"}` |
130
+ | `get_tenant_id` | `None` | Dotted path or callable `(request) -> str` |
131
+ | `include_extra` | `True` | Forward `extra={...}` fields as metadata |
132
+ | `include_request_context` | `True` | Attach request id / path / method / tenant set by the middleware |
133
+ | `flush_interval` | `2.0` | Seconds between background flushes |
134
+ | `batch_size` | `10` | Flush as soon as this many logs are queued |
135
+ | `max_queue_size` | `1000` | Drop oldest logs beyond this |
136
+ | `max_retries` | `3` | Retries with exponential backoff on 5xx / network errors |
137
+ | `timeout` | `5.0` | HTTP timeout in seconds |
138
+ | `debug` | `False` | Print every transport failure to stderr (otherwise only the first) |
139
+
140
+ ## Middleware options (`settings.LOGSETU`)
141
+
142
+ The middleware reuses the client and options of a configured `LogSetuHandler`. Without a handler it can run standalone:
143
+
144
+ ```python
145
+ LOGSETU = {
146
+ "API_KEY": env("LOGSETU_API_KEY"),
147
+ "ENDPOINT": env("LOGSETU_ENDPOINT"),
148
+ "ENVIRONMENT": "production",
149
+ "SOURCE": "django-backend",
150
+ "GET_TENANT_ID": "myapp.tenants.get_tenant_id",
151
+ "REDACT_HEADERS": ["authorization", "cookie", "x-api-key"], # defaults already cover these
152
+ "CAPTURE_5XX": True, # also report 5xx responses that didn't raise
153
+ }
154
+ ```
155
+
156
+ Every request gets a `request_id` (taken from `X-Request-ID` if present, otherwise generated) which is
157
+ attached to all logs emitted during that request and exposed as `request.logsetu_request_id`.
158
+
159
+ ## Guarantees
160
+
161
+ - **Never blocks** the request cycle: `emit()` only appends to an in-memory queue.
162
+ - **Never raises** into your app: transport errors are printed once to stderr, then suppressed.
163
+ - **Never leaks secrets**: `Authorization`, `Cookie`, `X-Api-Key`, `X-CSRFToken` and `Proxy-Authorization` are redacted.
164
+ - **Never loses the last logs** on shutdown: the queue is flushed via `atexit`.
165
+
166
+ ## Celery / management commands
167
+
168
+ Nothing special — the handler works anywhere Python's `logging` does. Set `extra={"source": "celery"}` to distinguish
169
+ workers, or configure a second handler with a different `source`.
170
+
171
+ ## Flask
172
+
173
+ ```python
174
+ import os
175
+ from flask import Flask
176
+ from logsetu.flask import LogSetu
177
+
178
+ app = Flask(__name__)
179
+ LogSetu(app, api_key=os.environ["LOGSETU_API_KEY"], endpoint=os.environ["LOGSETU_ENDPOINT"], source="flask-api")
180
+ ```
181
+
182
+ App factory? Create `logsetu = LogSetu()` at module level and call `logsetu.init_app(app)`; options you don't pass are
183
+ read from `app.config` (`LOGSETU_API_KEY`, `LOGSETU_ENDPOINT`, `LOGSETU_ENVIRONMENT`, `LOGSETU_SOURCE`).
184
+
185
+ The extension reports unhandled exceptions and 5xx responses (route, endpoint, redacted headers, traceback), and attaches
186
+ a `LogSetuHandler` to the root logger so `logging.warning(...)` and above is shipped with the request id, method and
187
+ path of the current request. Flask's own `"Exception on /path"` log line is de-duplicated.
188
+
189
+ | Option | Default | Description |
190
+ |---|---|---|
191
+ | `api_key` / `endpoint` | `app.config` | Project API key and server URL |
192
+ | `environment` / `source` | `"production"` / `"flask"` | Tags on every log |
193
+ | `get_tenant_id` | — | `callable(request) -> str \| None` or dotted path |
194
+ | `capture_logging` | `True` | Attach a handler to the root logger |
195
+ | `log_level` | `logging.WARNING` | Minimum level forwarded from `logging` |
196
+ | `capture_5xx` | `True` | Report 5xx responses that didn't raise |
197
+ | `log_requests` | `False` | One info/warn log per request |
198
+ | `redact_headers` | auth / cookie / API-key headers | Header names replaced by `[REDACTED]` |
199
+ | `client` | — | Use an existing `LogSetuClient` |
200
+
201
+ ## FastAPI / Starlette / ASGI
202
+
203
+ ```python
204
+ import os
205
+ from fastapi import FastAPI
206
+ from logsetu.fastapi import LogSetuMiddleware # alias of logsetu.asgi — works with any ASGI app
207
+
208
+ app = FastAPI()
209
+ app.add_middleware(
210
+ LogSetuMiddleware,
211
+ api_key=os.environ["LOGSETU_API_KEY"],
212
+ endpoint=os.environ["LOGSETU_ENDPOINT"],
213
+ source="fastapi",
214
+ ignore_paths=["/healthz"],
215
+ )
216
+ ```
217
+
218
+ A pure ASGI middleware (streaming responses aren't buffered). Same options as Flask plus `ignore_paths`;
219
+ `get_tenant_id` receives the ASGI `scope`, and `api_key` / `endpoint` / `environment` / `source` fall back to the
220
+ `LOGSETU_*` environment variables. Request context reaches logs from both `async def` endpoints and sync endpoints
221
+ running in the threadpool. `HTTPException`s with 4xx status codes aren't reported as errors. The request id is
222
+ available as `request.state.logsetu_request_id`.
223
+
224
+ ## License
225
+
226
+ MIT