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.
- logsetu_django-0.1.0/.gitignore +49 -0
- logsetu_django-0.1.0/LICENSE +21 -0
- logsetu_django-0.1.0/PKG-INFO +153 -0
- logsetu_django-0.1.0/README.md +123 -0
- logsetu_django-0.1.0/pyproject.toml +45 -0
- logsetu_django-0.1.0/src/logsetu/__init__.py +41 -0
- logsetu_django-0.1.0/src/logsetu/apps.py +6 -0
- logsetu_django-0.1.0/src/logsetu/client.py +250 -0
- logsetu_django-0.1.0/src/logsetu/conf.py +41 -0
- logsetu_django-0.1.0/src/logsetu/context.py +36 -0
- logsetu_django-0.1.0/src/logsetu/handler.py +188 -0
- logsetu_django-0.1.0/src/logsetu/middleware.py +186 -0
- logsetu_django-0.1.0/src/logsetu/py.typed +0 -0
- logsetu_django-0.1.0/tests/__init__.py +0 -0
- logsetu_django-0.1.0/tests/conftest.py +28 -0
- logsetu_django-0.1.0/tests/fakeserver.py +47 -0
- logsetu_django-0.1.0/tests/settings.py +18 -0
- logsetu_django-0.1.0/tests/test_client.py +95 -0
- logsetu_django-0.1.0/tests/test_django.py +149 -0
- logsetu_django-0.1.0/tests/urls.py +22 -0
|
@@ -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,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
|