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/__init__.py +41 -0
- logsetu/apps.py +6 -0
- logsetu/asgi.py +151 -0
- logsetu/client.py +250 -0
- logsetu/conf.py +41 -0
- logsetu/context.py +36 -0
- logsetu/fastapi.py +11 -0
- logsetu/flask.py +141 -0
- logsetu/handler.py +191 -0
- logsetu/middleware.py +186 -0
- logsetu/py.typed +0 -0
- logsetu/web.py +71 -0
- logsetu-0.3.0.dist-info/METADATA +226 -0
- logsetu-0.3.0.dist-info/RECORD +16 -0
- logsetu-0.3.0.dist-info/WHEEL +4 -0
- logsetu-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|