trailscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trailscope/__init__.py +55 -0
- trailscope/_capture.py +108 -0
- trailscope/_context.py +125 -0
- trailscope/_ids.py +56 -0
- trailscope/_redact.py +168 -0
- trailscope/_version.py +1 -0
- trailscope/asgi.py +194 -0
- trailscope/celery.py +148 -0
- trailscope/client.py +838 -0
- trailscope/django.py +204 -0
- trailscope/handler.py +49 -0
- trailscope/py.typed +0 -0
- trailscope-0.1.0.dist-info/METADATA +81 -0
- trailscope-0.1.0.dist-info/RECORD +15 -0
- trailscope-0.1.0.dist-info/WHEEL +4 -0
trailscope/django.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Django middleware (WSGI and ASGI).
|
|
2
|
+
|
|
3
|
+
Add it first in ``MIDDLEWARE`` and configure it in settings::
|
|
4
|
+
|
|
5
|
+
MIDDLEWARE = ["trailscope.django.TrailscopeMiddleware", ...]
|
|
6
|
+
TRAILSCOPE = {"service": "orders-api"} # same keyword options as trailscope.Client
|
|
7
|
+
|
|
8
|
+
Without a ``TRAILSCOPE`` setting it uses the client from an earlier
|
|
9
|
+
``trailscope.init()``, or creates one from the ``TRAILSCOPE_*`` environment variables.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
20
|
+
|
|
21
|
+
from ._capture import is_binary_content_type, should_capture_bodies
|
|
22
|
+
from ._context import TrailscopeContext, record_error, reset_context, set_context
|
|
23
|
+
from .client import Client, HttpExchange, get_client, init, log
|
|
24
|
+
|
|
25
|
+
_CONVERTER = re.compile(r"<(?:[^>:]+:)?([^>]+)>")
|
|
26
|
+
_NAMED_GROUP = re.compile(r"\(\?P<([^>]+)>[^)]*\)")
|
|
27
|
+
_CTX_ATTR = "_trailscope_context"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def route_template(pattern: str) -> str:
|
|
31
|
+
"""``projects/<int:project_id>/tasks`` -> ``/projects/:project_id/tasks``.
|
|
32
|
+
|
|
33
|
+
Regex routes (``re_path``) keep their literal parts, with named groups as
|
|
34
|
+
parameters.
|
|
35
|
+
"""
|
|
36
|
+
p = _NAMED_GROUP.sub(r":\1", pattern)
|
|
37
|
+
p = _CONVERTER.sub(r":\1", p).replace("^", "").replace("$", "")
|
|
38
|
+
return "/" + p.lstrip("/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_settings_client: Client | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client_for_settings() -> Client:
|
|
45
|
+
"""The client configured by ``settings.TRAILSCOPE``, created once per process."""
|
|
46
|
+
global _settings_client
|
|
47
|
+
from django.conf import settings
|
|
48
|
+
|
|
49
|
+
options = getattr(settings, "TRAILSCOPE", None)
|
|
50
|
+
if isinstance(options, dict) and options:
|
|
51
|
+
if _settings_client is None:
|
|
52
|
+
_settings_client = init(**options)
|
|
53
|
+
return _settings_client
|
|
54
|
+
return get_client() or init()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _user_id(request: Any) -> str | None:
|
|
58
|
+
"""The authenticated user, only if Django already loaded it (no extra query)."""
|
|
59
|
+
user = getattr(request, "_cached_user", None) or getattr(request, "_acached_user", None)
|
|
60
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
61
|
+
pk = getattr(user, "pk", None)
|
|
62
|
+
return str(pk) if pk is not None else None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _form_fields(request: Any) -> dict[str, Any] | None:
|
|
67
|
+
"""A multipart form's text fields, with files listed by name, size and type
|
|
68
|
+
(never their content). Only if Django or DRF already parsed the form, so
|
|
69
|
+
logging never triggers parsing a large upload."""
|
|
70
|
+
post = getattr(request, "_post", None)
|
|
71
|
+
files = getattr(request, "_files", None)
|
|
72
|
+
out: dict[str, Any] = {}
|
|
73
|
+
if post is not None:
|
|
74
|
+
for key in post:
|
|
75
|
+
values = post.getlist(key)
|
|
76
|
+
out[key] = values[0] if len(values) == 1 else values
|
|
77
|
+
if files is not None:
|
|
78
|
+
for key in files:
|
|
79
|
+
listed = [
|
|
80
|
+
f"[file {f.name}, {f.size} bytes, {f.content_type}]" for f in files.getlist(key)
|
|
81
|
+
]
|
|
82
|
+
out[key] = listed[0] if len(listed) == 1 else listed
|
|
83
|
+
return out or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _request_body(request: Any, client: Client) -> bytes | dict[str, Any] | None:
|
|
87
|
+
content_type = request.META.get("CONTENT_TYPE", "")
|
|
88
|
+
if content_type.startswith("multipart/form-data"):
|
|
89
|
+
return _form_fields(request)
|
|
90
|
+
if is_binary_content_type(content_type):
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
body = request.body
|
|
94
|
+
except Exception: # streamed already, or over DATA_UPLOAD_MAX_MEMORY_SIZE
|
|
95
|
+
return None
|
|
96
|
+
return bytes(body[: client.max_body_bytes + 1]) if body else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _response_body(response: Any, client: Client) -> bytes | None:
|
|
100
|
+
if getattr(response, "streaming", False):
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
content = response.content
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
return bytes(content[: client.max_body_bytes + 1]) if content else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TrailscopeMiddleware:
|
|
110
|
+
sync_capable = True
|
|
111
|
+
async_capable = True
|
|
112
|
+
|
|
113
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
114
|
+
self.get_response = get_response
|
|
115
|
+
self.client = _client_for_settings()
|
|
116
|
+
self._async = iscoroutinefunction(get_response)
|
|
117
|
+
if self._async:
|
|
118
|
+
markcoroutinefunction(self)
|
|
119
|
+
|
|
120
|
+
def __call__(self, request: Any) -> Any:
|
|
121
|
+
if self._async:
|
|
122
|
+
return self.__acall__(request)
|
|
123
|
+
if not self.client.enabled:
|
|
124
|
+
return self.get_response(request)
|
|
125
|
+
ctx, token, timestamp, t0 = self._start(request)
|
|
126
|
+
try:
|
|
127
|
+
response = self.get_response(request)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
record_error(e, ctx)
|
|
130
|
+
reset_context(token)
|
|
131
|
+
raise
|
|
132
|
+
self._finish(request, response, ctx, timestamp, t0)
|
|
133
|
+
reset_context(token)
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
async def __acall__(self, request: Any) -> Any:
|
|
137
|
+
get_response: Callable[[Any], Awaitable[Any]] = self.get_response
|
|
138
|
+
if not self.client.enabled:
|
|
139
|
+
return await get_response(request)
|
|
140
|
+
ctx, token, timestamp, t0 = self._start(request)
|
|
141
|
+
try:
|
|
142
|
+
response = await get_response(request)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
record_error(e, ctx)
|
|
145
|
+
reset_context(token)
|
|
146
|
+
raise
|
|
147
|
+
self._finish(request, response, ctx, timestamp, t0)
|
|
148
|
+
reset_context(token)
|
|
149
|
+
return response
|
|
150
|
+
|
|
151
|
+
def process_exception(self, request: Any, exception: Exception) -> None:
|
|
152
|
+
"""Called by Django for exceptions raised in views, before the 500 response."""
|
|
153
|
+
record_error(exception, getattr(request, _CTX_ATTR, None))
|
|
154
|
+
|
|
155
|
+
def _start(self, request: Any) -> tuple[TrailscopeContext, Any, float, float]:
|
|
156
|
+
headers = request.headers
|
|
157
|
+
ctx = self.client.context_from_headers(lambda name: headers.get(name))
|
|
158
|
+
setattr(request, _CTX_ATTR, ctx)
|
|
159
|
+
return ctx, set_context(ctx), time.time() * 1000, time.perf_counter()
|
|
160
|
+
|
|
161
|
+
def _finish(
|
|
162
|
+
self, request: Any, response: Any, ctx: TrailscopeContext, timestamp: float, t0: float
|
|
163
|
+
) -> None:
|
|
164
|
+
client = self.client
|
|
165
|
+
try:
|
|
166
|
+
if not response.has_header(client.request_id_header):
|
|
167
|
+
response[client.request_id_header] = ctx.request_id
|
|
168
|
+
match = getattr(request, "resolver_match", None)
|
|
169
|
+
route = getattr(match, "route", None)
|
|
170
|
+
if ctx.user_id is None:
|
|
171
|
+
ctx.user_id = _user_id(request)
|
|
172
|
+
meta = request.META
|
|
173
|
+
xff = meta.get("HTTP_X_FORWARDED_FOR")
|
|
174
|
+
ip = xff.split(",")[0].strip() if xff else meta.get("REMOTE_ADDR")
|
|
175
|
+
res_headers = dict(response.headers.items())
|
|
176
|
+
length = res_headers.get("Content-Length")
|
|
177
|
+
duration_ms = (time.perf_counter() - t0) * 1000
|
|
178
|
+
status = int(response.status_code)
|
|
179
|
+
# Read bodies only when the capture policy keeps them.
|
|
180
|
+
bodies = should_capture_bodies(
|
|
181
|
+
client.capture_bodies, status, duration_ms, client.slow_ms
|
|
182
|
+
)
|
|
183
|
+
client.record_http(
|
|
184
|
+
ctx,
|
|
185
|
+
HttpExchange(
|
|
186
|
+
timestamp=timestamp,
|
|
187
|
+
duration_ms=duration_ms,
|
|
188
|
+
method=request.method or "GET",
|
|
189
|
+
url=request.get_full_path(),
|
|
190
|
+
status=status,
|
|
191
|
+
route=route_template(route) if isinstance(route, str) else None,
|
|
192
|
+
req_headers=dict(request.headers.items()),
|
|
193
|
+
res_headers=res_headers,
|
|
194
|
+
req_body=_request_body(request, client) if bodies else None,
|
|
195
|
+
res_body=_response_body(response, client) if bodies else None,
|
|
196
|
+
ip=ip or None,
|
|
197
|
+
req_size=int(meta["CONTENT_LENGTH"])
|
|
198
|
+
if str(meta.get("CONTENT_LENGTH", "")).isdigit()
|
|
199
|
+
else None,
|
|
200
|
+
res_size=int(length) if length and str(length).isdigit() else None,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
log.exception("trailscope: could not record request")
|
trailscope/handler.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A ``logging`` handler that sends records to Trailscope as ``log`` events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .client import Client, Level, get_client
|
|
8
|
+
|
|
9
|
+
_LEVELS: tuple[tuple[int, Level], ...] = (
|
|
10
|
+
(logging.ERROR, "error"),
|
|
11
|
+
(logging.WARNING, "warn"),
|
|
12
|
+
(logging.INFO, "info"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TrailscopeHandler(logging.Handler):
|
|
17
|
+
"""Sends log records to Trailscope, linked to the current request or job.
|
|
18
|
+
|
|
19
|
+
::
|
|
20
|
+
|
|
21
|
+
logging.getLogger().addHandler(TrailscopeHandler(level=logging.INFO))
|
|
22
|
+
|
|
23
|
+
The record's logger name becomes the event name, ``exc_info`` becomes the
|
|
24
|
+
error type, message and stack, and records from Trailscope's own logger are
|
|
25
|
+
skipped.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, level: int = logging.INFO, client: Client | None = None) -> None:
|
|
29
|
+
super().__init__(level)
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
33
|
+
if record.name == "trailscope" or record.name.startswith("trailscope."):
|
|
34
|
+
return
|
|
35
|
+
client = self._client or get_client()
|
|
36
|
+
if client is None:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
level: Level = next((name for no, name in _LEVELS if record.levelno >= no), "debug")
|
|
40
|
+
error = record.exc_info[1] if record.exc_info and record.exc_info[1] else None
|
|
41
|
+
client.log(
|
|
42
|
+
level,
|
|
43
|
+
record.getMessage(),
|
|
44
|
+
name=record.name,
|
|
45
|
+
attributes={"module": record.module, "line": record.lineno},
|
|
46
|
+
error=error,
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
self.handleError(record)
|
trailscope/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trailscope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Trailscope SDK for Python: non-blocking API request logging for Django, FastAPI/Starlette and Celery
|
|
5
|
+
Project-URL: Homepage, https://trailscope.pro
|
|
6
|
+
Project-URL: Source, https://github.com/karaa1122/trailscope
|
|
7
|
+
Project-URL: Documentation, https://trailscope.pro/docs/sdk-python/
|
|
8
|
+
Author: Trailscope contributors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Logging
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Provides-Extra: celery
|
|
19
|
+
Requires-Dist: celery>=5.3; extra == 'celery'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: celery>=5.6; extra == 'dev'
|
|
22
|
+
Requires-Dist: django<6,>=5.2; (python_version < '3.12') and extra == 'dev'
|
|
23
|
+
Requires-Dist: django>=6.1; (python_version >= '3.12') and extra == 'dev'
|
|
24
|
+
Requires-Dist: fastapi>=0.162; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=2.4; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=9.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.17; extra == 'dev'
|
|
30
|
+
Requires-Dist: starlette<1.9,>=1.0; (python_version < '3.11') and extra == 'dev'
|
|
31
|
+
Requires-Dist: starlette>=1.9; (python_version >= '3.11') and extra == 'dev'
|
|
32
|
+
Provides-Extra: django
|
|
33
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
34
|
+
Provides-Extra: fastapi
|
|
35
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
36
|
+
Provides-Extra: starlette
|
|
37
|
+
Requires-Dist: starlette>=0.37; extra == 'starlette'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# trailscope
|
|
41
|
+
|
|
42
|
+
The [Trailscope](https://github.com/karaa1122/trailscope) SDK for Python 3.10+: logs every request with its route, timing, user, journey keys and (on errors) bodies and stack, plus Celery jobs and log lines, without blocking your request path. No dependencies beyond the standard library.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install trailscope
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## FastAPI / Starlette
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import trailscope
|
|
52
|
+
from trailscope.asgi import TrailscopeMiddleware
|
|
53
|
+
|
|
54
|
+
trailscope.init(service="orders-api") # reads TRAILSCOPE_ENDPOINT and TRAILSCOPE_INGEST_KEY
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
app.add_middleware(TrailscopeMiddleware) # add it last, so it runs first
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Django
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
MIDDLEWARE = ["trailscope.django.TrailscopeMiddleware", ...] # first
|
|
63
|
+
TRAILSCOPE = {"service": "orders-api"}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Celery and logging
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from trailscope.celery import instrument
|
|
70
|
+
|
|
71
|
+
instrument() # in the web process and the worker, after trailscope.init()
|
|
72
|
+
logging.getLogger().addHandler(trailscope.TrailscopeHandler())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
In request code: `trailscope.set_user(user_id)`, `trailscope.set_correlation("order_id", order_id)`.
|
|
76
|
+
|
|
77
|
+
Full documentation: [docs/sdk-python.md](https://github.com/karaa1122/trailscope/blob/main/docs/sdk-python.md).
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
trailscope/__init__.py,sha256=AHPbiMXfmrsuH7etVMnVFO9kZ6XukuJ-FVm4gsOlESQ,1208
|
|
2
|
+
trailscope/_capture.py,sha256=M_YVVJsrpNcxemBhktSk7sufZV8GGQp-QEGGW5KfDsA,3264
|
|
3
|
+
trailscope/_context.py,sha256=0BpnXO40rUMExI1IDpXOQMZ3q19OXOWCtFVndushQps,3881
|
|
4
|
+
trailscope/_ids.py,sha256=p8Sp7Fs36pvFePEFKJPC15tSHsIDYpMOAtbWsS5ju64,1524
|
|
5
|
+
trailscope/_redact.py,sha256=V7Ro8Tf3UjsaGJan00QYG2yaLzsaRcsmyTdf2lHwnjk,5581
|
|
6
|
+
trailscope/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
7
|
+
trailscope/asgi.py,sha256=jfeTQt7ZEjbaN2d8OT87W9uLGaj8_c3YwZszgu0Sfv4,6983
|
|
8
|
+
trailscope/celery.py,sha256=cHumr1bGfRYINdGxYfKx_xOuK9l0QG9XY3fLc3Dqf9s,4617
|
|
9
|
+
trailscope/client.py,sha256=0tlTlPO2e3CPFsyG6_zD6mmu-tavHKEG_723rJJrFGo,32558
|
|
10
|
+
trailscope/django.py,sha256=28of7_BbithbL3_vVODOnilv9Gb70BbvWH2xtmhUvFM,7978
|
|
11
|
+
trailscope/handler.py,sha256=riAZhYvKVrwPK5xCZUNHvc2OwGSH_WxP2Ma4RC59fK4,1605
|
|
12
|
+
trailscope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
trailscope-0.1.0.dist-info/METADATA,sha256=DX9IdHpLFL8KcB58fMOp9HcFQSfX-eYq80as0ZgVaSU,2883
|
|
14
|
+
trailscope-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
trailscope-0.1.0.dist-info/RECORD,,
|