logsetu-django 0.1.0__tar.gz

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.
@@ -0,0 +1,49 @@
1
+ # deps
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # builds
6
+ dist/
7
+ .next/
8
+ out/
9
+ .turbo/
10
+ *.tsbuildinfo
11
+ apps/server/generated/
12
+
13
+ # python
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.egg-info/
17
+ build/
18
+ .venv/
19
+ venv/
20
+ .pytest_cache/
21
+ .mypy_cache/
22
+ .ruff_cache/
23
+
24
+ # env / secrets
25
+ .env
26
+ .env.*
27
+ !.env.example
28
+
29
+ # data
30
+ data/
31
+ *.db
32
+ *.db-journal
33
+ *.sqlite
34
+ *.sqlite3
35
+ *.sqlite-*
36
+
37
+ # editors / os
38
+ .idea/
39
+ .vscode/
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # logs
44
+ *.log
45
+ npm-debug.log*
46
+ pnpm-debug.log*
47
+
48
+ # generated by next
49
+ next-env.d.ts
@@ -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.
@@ -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,123 @@
1
+ # logsetu-django
2
+
3
+ Ship Django / DRF logs and unhandled errors to your self-hosted [LogSetu](https://github.com/itsrajverma/logsetu) server.
4
+ Zero runtime dependencies, non-blocking (background thread + batching), and it never crashes your app if the log server is down.
5
+
6
+ ```bash
7
+ pip install logsetu-django
8
+ ```
9
+
10
+ ## Setup (2 minutes)
11
+
12
+ ```python
13
+ # settings.py
14
+ INSTALLED_APPS = [..., "logsetu"] # optional, but nice for discoverability
15
+
16
+ LOGGING = {
17
+ "version": 1,
18
+ "disable_existing_loggers": False,
19
+ "handlers": {
20
+ "logsetu": {
21
+ "class": "logsetu.handler.LogSetuHandler",
22
+ "api_key": env("LOGSETU_API_KEY"), # from the LogSetu dashboard → Projects
23
+ "endpoint": env("LOGSETU_ENDPOINT"), # e.g. https://logs.example.com
24
+ "environment": env("ENVIRONMENT", default="production"),
25
+ "source": "django-backend",
26
+ "level": "WARNING",
27
+ },
28
+ },
29
+ "root": {"handlers": ["logsetu"], "level": "INFO"},
30
+ }
31
+
32
+ MIDDLEWARE = [
33
+ ...,
34
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
35
+ "logsetu.middleware.LogSetuMiddleware", # after auth middleware so user ids are captured
36
+ ]
37
+ ```
38
+
39
+ That's it. Every `logging` call at or above the handler's level is sent to LogSetu, and the middleware
40
+ reports unhandled exceptions (and 5xx responses) with the request path, method, view, user, redacted headers and full traceback.
41
+
42
+ ```python
43
+ import logging
44
+ log = logging.getLogger(__name__)
45
+
46
+ log.warning("Checkout is slow", extra={"duration_ms": 1830, "cart_items": 12})
47
+ try:
48
+ charge(card)
49
+ except PaymentError:
50
+ log.exception("Payment failed") # traceback is attached automatically
51
+ ```
52
+
53
+ ## Multi-tenant apps
54
+
55
+ Tag every log with the current tenant by pointing the SDK at a callable `(request) -> str | None`:
56
+
57
+ ```python
58
+ # settings.py
59
+ LOGGING["handlers"]["logsetu"]["get_tenant_id"] = "myapp.tenants.get_tenant_id"
60
+
61
+ # myapp/tenants.py
62
+ def get_tenant_id(request):
63
+ return getattr(request, "tenant", None) and request.tenant.slug
64
+ ```
65
+
66
+ Or, from anywhere inside a request (e.g. after you resolve the tenant in your own middleware):
67
+
68
+ ```python
69
+ from logsetu import update_request_context
70
+ update_request_context(tenant_id=tenant.slug, plan=tenant.plan)
71
+ ```
72
+
73
+ ## Handler options
74
+
75
+ | Option | Default | Description |
76
+ |---|---|---|
77
+ | `api_key` / `endpoint` | — | Required (or set `LOGSETU = {"API_KEY": ..., "ENDPOINT": ...}` in settings) |
78
+ | `environment` | `"production"` | Tag on every log |
79
+ | `source` | `"django"` | Tag on every log; override per record with `extra={"source": "celery"}` |
80
+ | `get_tenant_id` | `None` | Dotted path or callable `(request) -> str` |
81
+ | `include_extra` | `True` | Forward `extra={...}` fields as metadata |
82
+ | `include_request_context` | `True` | Attach request id / path / method / tenant set by the middleware |
83
+ | `flush_interval` | `2.0` | Seconds between background flushes |
84
+ | `batch_size` | `10` | Flush as soon as this many logs are queued |
85
+ | `max_queue_size` | `1000` | Drop oldest logs beyond this |
86
+ | `max_retries` | `3` | Retries with exponential backoff on 5xx / network errors |
87
+ | `timeout` | `5.0` | HTTP timeout in seconds |
88
+ | `debug` | `False` | Print every transport failure to stderr (otherwise only the first) |
89
+
90
+ ## Middleware options (`settings.LOGSETU`)
91
+
92
+ The middleware reuses the client and options of a configured `LogSetuHandler`. Without a handler it can run standalone:
93
+
94
+ ```python
95
+ LOGSETU = {
96
+ "API_KEY": env("LOGSETU_API_KEY"),
97
+ "ENDPOINT": env("LOGSETU_ENDPOINT"),
98
+ "ENVIRONMENT": "production",
99
+ "SOURCE": "django-backend",
100
+ "GET_TENANT_ID": "myapp.tenants.get_tenant_id",
101
+ "REDACT_HEADERS": ["authorization", "cookie", "x-api-key"], # defaults already cover these
102
+ "CAPTURE_5XX": True, # also report 5xx responses that didn't raise
103
+ }
104
+ ```
105
+
106
+ Every request gets a `request_id` (taken from `X-Request-ID` if present, otherwise generated) which is
107
+ attached to all logs emitted during that request and exposed as `request.logsetu_request_id`.
108
+
109
+ ## Guarantees
110
+
111
+ - **Never blocks** the request cycle: `emit()` only appends to an in-memory queue.
112
+ - **Never raises** into your app: transport errors are printed once to stderr, then suppressed.
113
+ - **Never leaks secrets**: `Authorization`, `Cookie`, `X-Api-Key`, `X-CSRFToken` and `Proxy-Authorization` are redacted.
114
+ - **Never loses the last logs** on shutdown: the queue is flushed via `atexit`.
115
+
116
+ ## Celery / management commands
117
+
118
+ Nothing special — the handler works anywhere Python's `logging` does. Set `extra={"source": "celery"}` to distinguish
119
+ workers, or configure a second handler with a different `source`.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "logsetu-django"
7
+ version = "0.1.0"
8
+ description = "LogSetu SDK for Django & DRF — ship Python logging records and request errors to your self-hosted LogSetu server"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Raj Verma" }]
13
+ requires-python = ">=3.9"
14
+ keywords = ["logging", "django", "drf", "error-tracking", "self-hosted", "sentry-alternative", "logsetu"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: Django",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: System :: Logging",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ django = ["Django>=3.2"]
28
+ dev = ["Django>=3.2", "pytest>=8", "pytest-django>=4.8", "build", "twine"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/itsrajverma/logsetu"
32
+ Documentation = "https://github.com/itsrajverma/logsetu/tree/main/docs"
33
+ Repository = "https://github.com/itsrajverma/logsetu"
34
+ Issues = "https://github.com/itsrajverma/logsetu/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/logsetu"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src/logsetu", "tests", "README.md", "LICENSE", "pyproject.toml"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ DJANGO_SETTINGS_MODULE = "tests.settings"
45
+ pythonpath = ["src", "."]
@@ -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)
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LogSetuConfig(AppConfig):
5
+ name = "logsetu"
6
+ verbose_name = "LogSetu"
@@ -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