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/asgi.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""ASGI middleware for Starlette, FastAPI and other ASGI apps.
|
|
2
|
+
|
|
3
|
+
FastAPI and Starlette::
|
|
4
|
+
|
|
5
|
+
from trailscope.asgi import TrailscopeMiddleware
|
|
6
|
+
|
|
7
|
+
app = FastAPI()
|
|
8
|
+
app.add_middleware(TrailscopeMiddleware, service="orders-api")
|
|
9
|
+
|
|
10
|
+
Any ASGI app::
|
|
11
|
+
|
|
12
|
+
app = TrailscopeMiddleware(app)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ._context import TrailscopeContext, record_error, reset_context, set_context
|
|
24
|
+
from .client import Client, HttpExchange, get_client, init, log
|
|
25
|
+
|
|
26
|
+
Scope = MutableMapping[str, Any]
|
|
27
|
+
Message = MutableMapping[str, Any]
|
|
28
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
29
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
30
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
_PARAM = re.compile(r"\{([^}:]+)(?::[^}]*)?\}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def route_template(path: str) -> str:
|
|
36
|
+
"""``/items/{item_id:int}`` -> ``/items/:item_id``."""
|
|
37
|
+
return _PARAM.sub(r":\1", path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _headers(scope: Scope) -> dict[str, str]:
|
|
41
|
+
out: dict[str, str] = {}
|
|
42
|
+
for k, v in scope.get("headers", []):
|
|
43
|
+
name = k.decode("latin-1").lower()
|
|
44
|
+
value = v.decode("latin-1")
|
|
45
|
+
out[name] = f"{out[name]}, {value}" if name in out else value
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _client_ip(scope: Scope, headers: dict[str, str]) -> str | None:
|
|
50
|
+
xff = headers.get("x-forwarded-for")
|
|
51
|
+
if xff:
|
|
52
|
+
return xff.split(",")[0].strip() or None
|
|
53
|
+
client = scope.get("client")
|
|
54
|
+
return str(client[0]) if client else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _route(scope: Scope, root_path: str) -> str | None:
|
|
58
|
+
"""The matched route template (Starlette and FastAPI set ``scope["route"]``)."""
|
|
59
|
+
route = scope.get("route")
|
|
60
|
+
path = getattr(route, "path_format", None) or getattr(route, "path", None)
|
|
61
|
+
if not isinstance(path, str):
|
|
62
|
+
return None
|
|
63
|
+
# Mounted apps: the mount prefix is what routing added to root_path.
|
|
64
|
+
current = scope.get("root_path", "")
|
|
65
|
+
prefix = (
|
|
66
|
+
current[len(root_path) :]
|
|
67
|
+
if isinstance(current, str) and current.startswith(root_path)
|
|
68
|
+
else ""
|
|
69
|
+
)
|
|
70
|
+
return route_template(prefix + path) or "/"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TrailscopeMiddleware:
|
|
74
|
+
"""Records every HTTP request and runs the app inside its Trailscope context.
|
|
75
|
+
|
|
76
|
+
Pass ``client``, or keyword options to create the process-wide client with
|
|
77
|
+
:func:`trailscope.init`. Without either it uses the client from an earlier
|
|
78
|
+
``init()``, or creates one from the ``TRAILSCOPE_*`` environment variables.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, app: ASGIApp, client: Client | None = None, **options: Any) -> None:
|
|
82
|
+
self.app = app
|
|
83
|
+
if client is not None:
|
|
84
|
+
self.client = client
|
|
85
|
+
elif options:
|
|
86
|
+
self.client = init(**options)
|
|
87
|
+
else:
|
|
88
|
+
self.client = get_client() or init()
|
|
89
|
+
|
|
90
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
91
|
+
client = self.client
|
|
92
|
+
if scope["type"] != "http" or not client.enabled:
|
|
93
|
+
await self.app(scope, receive, send)
|
|
94
|
+
return
|
|
95
|
+
headers = _headers(scope)
|
|
96
|
+
ctx = client.context_from_headers(headers.get)
|
|
97
|
+
token = set_context(ctx)
|
|
98
|
+
timestamp = time.time() * 1000
|
|
99
|
+
t0 = time.perf_counter()
|
|
100
|
+
root_path = scope.get("root_path", "") or ""
|
|
101
|
+
limit = client.max_body_bytes
|
|
102
|
+
keep = client.capture_bodies != "never"
|
|
103
|
+
req_body = bytearray()
|
|
104
|
+
res_body = bytearray()
|
|
105
|
+
state: dict[str, Any] = {"status": None, "headers": [], "size": 0}
|
|
106
|
+
rid = client.request_id_header.encode("latin-1")
|
|
107
|
+
|
|
108
|
+
async def receive_wrapper() -> Message:
|
|
109
|
+
message = await receive()
|
|
110
|
+
if keep and message["type"] == "http.request" and len(req_body) <= limit:
|
|
111
|
+
req_body.extend(message.get("body", b"")[: limit + 1 - len(req_body)])
|
|
112
|
+
return message
|
|
113
|
+
|
|
114
|
+
async def send_wrapper(message: Message) -> None:
|
|
115
|
+
if message["type"] == "http.response.start":
|
|
116
|
+
hdrs = list(message.get("headers", []))
|
|
117
|
+
if not any(k.lower() == rid for k, _ in hdrs):
|
|
118
|
+
hdrs.append((rid, ctx.request_id.encode("latin-1")))
|
|
119
|
+
message = {**message, "headers": hdrs}
|
|
120
|
+
state["status"] = message["status"]
|
|
121
|
+
state["headers"] = hdrs
|
|
122
|
+
elif message["type"] == "http.response.body":
|
|
123
|
+
body = message.get("body", b"")
|
|
124
|
+
state["size"] += len(body)
|
|
125
|
+
if keep and len(res_body) <= limit:
|
|
126
|
+
res_body.extend(body[: limit + 1 - len(res_body)])
|
|
127
|
+
await send(message)
|
|
128
|
+
|
|
129
|
+
status_if_unsent = 500
|
|
130
|
+
try:
|
|
131
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
132
|
+
except asyncio.CancelledError:
|
|
133
|
+
status_if_unsent = 499 # the client went away
|
|
134
|
+
raise
|
|
135
|
+
except Exception as e:
|
|
136
|
+
record_error(e, ctx)
|
|
137
|
+
raise
|
|
138
|
+
finally:
|
|
139
|
+
try:
|
|
140
|
+
self._record(
|
|
141
|
+
scope,
|
|
142
|
+
headers,
|
|
143
|
+
ctx,
|
|
144
|
+
timestamp,
|
|
145
|
+
t0,
|
|
146
|
+
root_path,
|
|
147
|
+
state,
|
|
148
|
+
status_if_unsent,
|
|
149
|
+
req_body,
|
|
150
|
+
res_body,
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
log.exception("trailscope: could not record request")
|
|
154
|
+
reset_context(token)
|
|
155
|
+
|
|
156
|
+
def _record(
|
|
157
|
+
self,
|
|
158
|
+
scope: Scope,
|
|
159
|
+
headers: dict[str, str],
|
|
160
|
+
ctx: TrailscopeContext,
|
|
161
|
+
timestamp: float,
|
|
162
|
+
t0: float,
|
|
163
|
+
root_path: str,
|
|
164
|
+
state: dict[str, Any],
|
|
165
|
+
status_if_unsent: int,
|
|
166
|
+
req_body: bytearray,
|
|
167
|
+
res_body: bytearray,
|
|
168
|
+
) -> None:
|
|
169
|
+
query = scope.get("query_string", b"").decode("latin-1")
|
|
170
|
+
path = scope.get("path", "/")
|
|
171
|
+
res_headers = {
|
|
172
|
+
k.decode("latin-1").lower(): v.decode("latin-1") for k, v in state["headers"]
|
|
173
|
+
}
|
|
174
|
+
length = res_headers.get("content-length")
|
|
175
|
+
self.client.record_http(
|
|
176
|
+
ctx,
|
|
177
|
+
HttpExchange(
|
|
178
|
+
timestamp=timestamp,
|
|
179
|
+
duration_ms=(time.perf_counter() - t0) * 1000,
|
|
180
|
+
method=scope.get("method", "GET"),
|
|
181
|
+
url=f"{path}?{query}" if query else path,
|
|
182
|
+
status=state["status"] if state["status"] is not None else status_if_unsent,
|
|
183
|
+
route=_route(scope, root_path),
|
|
184
|
+
req_headers=headers,
|
|
185
|
+
res_headers=res_headers,
|
|
186
|
+
req_body=bytes(req_body) if req_body else None,
|
|
187
|
+
res_body=bytes(res_body) if res_body else None,
|
|
188
|
+
ip=_client_ip(scope, headers),
|
|
189
|
+
req_size=int(headers["content-length"])
|
|
190
|
+
if headers.get("content-length", "").isdigit()
|
|
191
|
+
else None,
|
|
192
|
+
res_size=int(length) if length and length.isdigit() else state["size"] or None,
|
|
193
|
+
),
|
|
194
|
+
)
|
trailscope/celery.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Celery integration: every task run becomes a ``kind=job`` event, linked to the
|
|
2
|
+
request that queued it.
|
|
3
|
+
|
|
4
|
+
Call it once in the web process (to send the context with each task) and in
|
|
5
|
+
the worker (to record runs), after ``trailscope.init()``::
|
|
6
|
+
|
|
7
|
+
import trailscope
|
|
8
|
+
from trailscope.celery import instrument
|
|
9
|
+
|
|
10
|
+
trailscope.init(service="orders-worker")
|
|
11
|
+
instrument()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from celery import signals
|
|
21
|
+
|
|
22
|
+
from ._context import (
|
|
23
|
+
TrailscopeContext,
|
|
24
|
+
deserialize_context,
|
|
25
|
+
reset_context,
|
|
26
|
+
serialize_context,
|
|
27
|
+
set_context,
|
|
28
|
+
)
|
|
29
|
+
from ._ids import new_request_id, new_span_id, new_trace_id
|
|
30
|
+
from .client import Client, get_client
|
|
31
|
+
|
|
32
|
+
#: Message header that carries the serialized context.
|
|
33
|
+
HEADER = "trailscope"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _Run:
|
|
38
|
+
client: Client
|
|
39
|
+
ctx: TrailscopeContext
|
|
40
|
+
token: Any
|
|
41
|
+
t0: float
|
|
42
|
+
timestamp: int
|
|
43
|
+
error: BaseException | None = None
|
|
44
|
+
attributes: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_runs: dict[str, _Run] = {}
|
|
48
|
+
_client: Client | None = None
|
|
49
|
+
_instrumented = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def instrument(client: Client | None = None) -> None:
|
|
53
|
+
"""Connects the Celery signal handlers (idempotent)."""
|
|
54
|
+
global _client, _instrumented
|
|
55
|
+
_client = client
|
|
56
|
+
if _instrumented:
|
|
57
|
+
return
|
|
58
|
+
_instrumented = True
|
|
59
|
+
signals.before_task_publish.connect(_before_publish, weak=False)
|
|
60
|
+
signals.task_prerun.connect(_prerun, weak=False)
|
|
61
|
+
signals.task_failure.connect(_failure, weak=False)
|
|
62
|
+
signals.task_retry.connect(_retry, weak=False)
|
|
63
|
+
signals.task_postrun.connect(_postrun, weak=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _before_publish(sender: Any = None, headers: Any = None, **_: Any) -> None:
|
|
67
|
+
if isinstance(headers, dict):
|
|
68
|
+
carried = serialize_context()
|
|
69
|
+
if carried:
|
|
70
|
+
headers[HEADER] = carried
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _prerun(sender: Any = None, task_id: str | None = None, task: Any = None, **_: Any) -> None:
|
|
74
|
+
client = _client or get_client()
|
|
75
|
+
if client is None or not client.enabled or task_id is None or task is None:
|
|
76
|
+
return
|
|
77
|
+
request = task.request
|
|
78
|
+
parent = deserialize_context(getattr(request, HEADER, None)) or {}
|
|
79
|
+
ctx = TrailscopeContext(
|
|
80
|
+
request_id=parent.get("request_id") or new_request_id(),
|
|
81
|
+
trace_id=parent.get("trace_id") or new_trace_id(),
|
|
82
|
+
span_id=new_span_id(),
|
|
83
|
+
parent_span_id=parent.get("span_id"),
|
|
84
|
+
user_id=parent.get("user_id"),
|
|
85
|
+
correlation=dict(parent.get("correlation") or {}),
|
|
86
|
+
)
|
|
87
|
+
attributes = {"task_id": task_id}
|
|
88
|
+
delivery = getattr(request, "delivery_info", None) or {}
|
|
89
|
+
queue = delivery.get("routing_key") if isinstance(delivery, dict) else None
|
|
90
|
+
if queue:
|
|
91
|
+
attributes["queue"] = str(queue)
|
|
92
|
+
_runs[task_id] = _Run(
|
|
93
|
+
client,
|
|
94
|
+
ctx,
|
|
95
|
+
set_context(ctx),
|
|
96
|
+
time.perf_counter(),
|
|
97
|
+
int(time.time() * 1000),
|
|
98
|
+
None,
|
|
99
|
+
attributes,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _failure(
|
|
104
|
+
sender: Any = None, task_id: str | None = None, exception: BaseException | None = None, **_: Any
|
|
105
|
+
) -> None:
|
|
106
|
+
run = _runs.get(task_id or "")
|
|
107
|
+
if run is not None and exception is not None:
|
|
108
|
+
run.error = exception
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _retry(sender: Any = None, request: Any = None, reason: Any = None, **_: Any) -> None:
|
|
112
|
+
run = _runs.get(getattr(request, "id", None) or "")
|
|
113
|
+
if run is not None and run.error is None:
|
|
114
|
+
run.error = reason if isinstance(reason, BaseException) else RuntimeError(str(reason))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _postrun(
|
|
118
|
+
sender: Any = None,
|
|
119
|
+
task_id: str | None = None,
|
|
120
|
+
task: Any = None,
|
|
121
|
+
state: str | None = None,
|
|
122
|
+
**_: Any,
|
|
123
|
+
) -> None:
|
|
124
|
+
run = _runs.pop(task_id or "", None)
|
|
125
|
+
if run is None:
|
|
126
|
+
return
|
|
127
|
+
name = getattr(task, "name", None) or "task"
|
|
128
|
+
retries = getattr(getattr(task, "request", None), "retries", 0) or 0
|
|
129
|
+
failed = state in ("FAILURE", "RETRY") or (state != "SUCCESS" and run.error is not None)
|
|
130
|
+
if failed:
|
|
131
|
+
verb = "failed (will retry)" if state == "RETRY" else "failed"
|
|
132
|
+
message = f"{name} {verb}: {run.error}" if run.error else f"{name} {verb}"
|
|
133
|
+
else:
|
|
134
|
+
message = f"{name} completed"
|
|
135
|
+
run.client.capture_event(
|
|
136
|
+
name,
|
|
137
|
+
kind="job",
|
|
138
|
+
level=("warn" if state == "RETRY" else "error") if failed else "info",
|
|
139
|
+
status="error" if failed else "ok",
|
|
140
|
+
message=message,
|
|
141
|
+
duration_ms=round((time.perf_counter() - run.t0) * 1000, 2),
|
|
142
|
+
attempt=int(retries) + 1,
|
|
143
|
+
attributes=run.attributes,
|
|
144
|
+
error=run.error if failed else None,
|
|
145
|
+
timestamp=run.timestamp,
|
|
146
|
+
context=run.ctx,
|
|
147
|
+
)
|
|
148
|
+
reset_context(run.token)
|