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/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)