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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any