slowpoke-python 0.1.3__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.
- slowpoke/__init__.py +174 -0
- slowpoke/_config.py +45 -0
- slowpoke/_origin.py +136 -0
- slowpoke/_sender.py +145 -0
- slowpoke/_tracer.py +235 -0
- slowpoke/asgi.py +77 -0
- slowpoke/celery.py +67 -0
- slowpoke/django/__init__.py +226 -0
- slowpoke/django/apps.py +12 -0
- slowpoke/flask.py +55 -0
- slowpoke/sqlalchemy.py +71 -0
- slowpoke_python-0.1.3.dist-info/METADATA +244 -0
- slowpoke_python-0.1.3.dist-info/RECORD +16 -0
- slowpoke_python-0.1.3.dist-info/WHEEL +5 -0
- slowpoke_python-0.1.3.dist-info/licenses/LICENSE +21 -0
- slowpoke_python-0.1.3.dist-info/top_level.txt +1 -0
slowpoke/_tracer.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
VERSION = "0.1.3"
|
|
10
|
+
|
|
11
|
+
SERVER = 2
|
|
12
|
+
CLIENT = 3
|
|
13
|
+
CONSUMER = 5
|
|
14
|
+
|
|
15
|
+
# The trace of the request or job running in this thread or asyncio task. Worker threads and
|
|
16
|
+
# sync_to_async/run_in_threadpool copy the context, so their queries land in the same trace object.
|
|
17
|
+
_current = contextvars.ContextVar("slowpoke_trace", default=None)
|
|
18
|
+
|
|
19
|
+
_STATEMENT = re.compile(r"[\s(]*([A-Za-z]+)")
|
|
20
|
+
_DB_SYSTEMS = {"postgres": "postgresql", "mssql": "microsoft.sql_server", "sqlserver": "microsoft.sql_server"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def current():
|
|
24
|
+
return _current.get()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _random_id(size):
|
|
28
|
+
return os.urandom(size).hex()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Trace:
|
|
32
|
+
__slots__ = ("kind", "name", "start", "end", "error", "trace_id", "span_id", "attributes", "queries", "dropped", "task")
|
|
33
|
+
|
|
34
|
+
def __init__(self, kind, name, start, trace_id, span_id):
|
|
35
|
+
self.kind = kind
|
|
36
|
+
self.name = name
|
|
37
|
+
self.start = start
|
|
38
|
+
self.end = None
|
|
39
|
+
self.error = False
|
|
40
|
+
self.trace_id = trace_id
|
|
41
|
+
self.span_id = span_id
|
|
42
|
+
self.attributes = []
|
|
43
|
+
self.queries = []
|
|
44
|
+
self.dropped = 0
|
|
45
|
+
self.task = _running_task()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Tracer:
|
|
49
|
+
"""Collects one trace per HTTP request or job and turns it into OTLP/JSON for the agent: a SERVER
|
|
50
|
+
(or CONSUMER) span and one CLIENT span per query, with the SQL as the driver received it
|
|
51
|
+
(placeholders, never parameter values) and the application line that ran it.
|
|
52
|
+
|
|
53
|
+
Every public method swallows its own errors: observability must never break the app."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, origin, submit, service="app", version=VERSION, max_queries=500, max_sql_length=10000,
|
|
56
|
+
clock=time.time, ids=_random_id):
|
|
57
|
+
self.origin = origin
|
|
58
|
+
self.submit = submit
|
|
59
|
+
self.service = service
|
|
60
|
+
self.version = version
|
|
61
|
+
self.max_queries = max(0, int(max_queries))
|
|
62
|
+
self.max_sql_length = max(1, int(max_sql_length))
|
|
63
|
+
self.clock = clock
|
|
64
|
+
self.ids = ids
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------ requests
|
|
67
|
+
|
|
68
|
+
def start_request(self, method):
|
|
69
|
+
try:
|
|
70
|
+
method = str(method).upper()
|
|
71
|
+
trace = Trace(SERVER, method, self.clock(), self.ids(16), self.ids(8))
|
|
72
|
+
trace.attributes.append(_kv("http.request.method", method))
|
|
73
|
+
_current.set(trace)
|
|
74
|
+
return trace
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def finish_request(self, trace, route, path, status):
|
|
79
|
+
if trace is None or trace.end is not None:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
trace.end = self.clock()
|
|
83
|
+
if route:
|
|
84
|
+
route = "/" + str(route).lstrip("/")
|
|
85
|
+
trace.name = trace.name + " " + route
|
|
86
|
+
trace.attributes.append(_kv("http.route", route))
|
|
87
|
+
else:
|
|
88
|
+
trace.attributes.append(_kv("url.path", "/" + str(path or "").split("?", 1)[0].lstrip("/")))
|
|
89
|
+
status = int(status)
|
|
90
|
+
trace.attributes.append(_kv("http.response.status_code", status))
|
|
91
|
+
trace.error = status >= 500
|
|
92
|
+
except Exception:
|
|
93
|
+
trace.end = trace.end or self.clock()
|
|
94
|
+
self._done(trace)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------ jobs
|
|
97
|
+
|
|
98
|
+
def start_job(self, name, queue=None):
|
|
99
|
+
return self._start_background(name, "job", queue)
|
|
100
|
+
|
|
101
|
+
def start_command(self, name):
|
|
102
|
+
"""A command run by cron: nobody waits for it, which is why nobody notices when it doubles."""
|
|
103
|
+
return self._start_background(name, "command", None)
|
|
104
|
+
|
|
105
|
+
def _start_background(self, name, kind, queue):
|
|
106
|
+
running = _current.get()
|
|
107
|
+
if running is not None and running.end is None:
|
|
108
|
+
return None # work run inside a request or another job belongs to it
|
|
109
|
+
try:
|
|
110
|
+
trace = Trace(CONSUMER, str(name), self.clock(), self.ids(16), self.ids(8))
|
|
111
|
+
# The agent files it under Jobs by this attribute, instead of among the endpoints.
|
|
112
|
+
trace.attributes.append(_kv("slowpoke.kind", kind))
|
|
113
|
+
if queue:
|
|
114
|
+
trace.attributes.append(_kv("messaging.destination.name", str(queue)))
|
|
115
|
+
_current.set(trace)
|
|
116
|
+
return trace
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def finish_job(self, trace, failed=False):
|
|
121
|
+
if trace is None or trace.end is not None:
|
|
122
|
+
return
|
|
123
|
+
trace.end = self.clock()
|
|
124
|
+
trace.error = bool(failed)
|
|
125
|
+
self._done(trace)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------ queries
|
|
128
|
+
|
|
129
|
+
def record_query(self, sql, seconds, system):
|
|
130
|
+
"""Called right after a statement ran, from the thread that ran it."""
|
|
131
|
+
trace = _current.get()
|
|
132
|
+
if trace is None or trace.end is not None:
|
|
133
|
+
return # outside requests and jobs, or after the response
|
|
134
|
+
try:
|
|
135
|
+
if len(trace.queries) >= self.max_queries:
|
|
136
|
+
trace.dropped += 1
|
|
137
|
+
return
|
|
138
|
+
end = self.clock()
|
|
139
|
+
sql = sql if isinstance(sql, str) else str(sql)
|
|
140
|
+
trace.queries.append((
|
|
141
|
+
sql[:self.max_sql_length],
|
|
142
|
+
system,
|
|
143
|
+
max(trace.start, end - max(0.0, seconds)),
|
|
144
|
+
end,
|
|
145
|
+
self.origin.find(trace.task),
|
|
146
|
+
))
|
|
147
|
+
except Exception:
|
|
148
|
+
pass # never let observability break the query that was just run
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------ sending
|
|
151
|
+
|
|
152
|
+
def _done(self, trace):
|
|
153
|
+
trace.task = None # the queued trace must not keep the request's coroutine alive
|
|
154
|
+
try:
|
|
155
|
+
if _current.get() is trace:
|
|
156
|
+
_current.set(None)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
self.submit(trace)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass # the agent is optional: a missing or broken one costs a trace, nothing else
|
|
163
|
+
|
|
164
|
+
def encode(self, t):
|
|
165
|
+
"""The OTLP/JSON payload of a finished trace. Runs on the background thread."""
|
|
166
|
+
root = {
|
|
167
|
+
"traceId": t.trace_id,
|
|
168
|
+
"spanId": t.span_id,
|
|
169
|
+
"name": t.name,
|
|
170
|
+
"kind": t.kind,
|
|
171
|
+
"startTimeUnixNano": _nanos(t.start),
|
|
172
|
+
"endTimeUnixNano": _nanos(t.end),
|
|
173
|
+
"attributes": list(t.attributes),
|
|
174
|
+
}
|
|
175
|
+
if t.dropped > 0:
|
|
176
|
+
root["attributes"].append(_kv("slowpoke.dropped_queries", t.dropped))
|
|
177
|
+
if t.error:
|
|
178
|
+
root["status"] = {"code": 2}
|
|
179
|
+
spans = [root]
|
|
180
|
+
for sql, system, start, end, origin in list(t.queries):
|
|
181
|
+
attributes = [_kv("db.system.name", _DB_SYSTEMS.get(system, system)), _kv("db.query.text", sql)]
|
|
182
|
+
if origin is not None:
|
|
183
|
+
attributes.append(_kv("code.file.path", origin[0]))
|
|
184
|
+
if origin[1] is not None:
|
|
185
|
+
attributes.append(_kv("code.line.number", origin[1]))
|
|
186
|
+
m = _STATEMENT.match(sql)
|
|
187
|
+
spans.append({
|
|
188
|
+
"traceId": t.trace_id,
|
|
189
|
+
"spanId": self.ids(8),
|
|
190
|
+
"parentSpanId": t.span_id,
|
|
191
|
+
"name": m.group(1).upper() if m else "QUERY",
|
|
192
|
+
"kind": CLIENT,
|
|
193
|
+
"startTimeUnixNano": _nanos(start),
|
|
194
|
+
"endTimeUnixNano": _nanos(end),
|
|
195
|
+
"attributes": attributes,
|
|
196
|
+
})
|
|
197
|
+
return {"resourceSpans": [{
|
|
198
|
+
"resource": {"attributes": [
|
|
199
|
+
_kv("service.name", self.service),
|
|
200
|
+
_kv("telemetry.sdk.name", "slowpoke-python"),
|
|
201
|
+
_kv("telemetry.sdk.language", "python"),
|
|
202
|
+
_kv("telemetry.sdk.version", self.version),
|
|
203
|
+
]},
|
|
204
|
+
"scopeSpans": [{
|
|
205
|
+
"scope": {"name": "slowpoke", "version": self.version},
|
|
206
|
+
"spans": spans,
|
|
207
|
+
}],
|
|
208
|
+
}]}
|
|
209
|
+
|
|
210
|
+
def encode_json(self, trace):
|
|
211
|
+
text = json.dumps(self.encode(trace), ensure_ascii=False, separators=(",", ":"))
|
|
212
|
+
return text.encode("utf-8", "replace")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _running_task():
|
|
216
|
+
asyncio = sys.modules.get("asyncio")
|
|
217
|
+
if asyncio is None:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
return asyncio.current_task() if asyncio._get_running_loop() is not None else None
|
|
221
|
+
except Exception:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _nanos(seconds):
|
|
226
|
+
# 64-bit integers travel as strings. Microseconds, as the PHP packages, and rounded the same
|
|
227
|
+
# way: Python's round() goes to the even digit (round(0.5) is 0), math.floor(x + 0.5) does not,
|
|
228
|
+
# and the same measure must produce the same trace in every language.
|
|
229
|
+
return "%d000" % math.floor(seconds * 1e6 + 0.5)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _kv(key, value):
|
|
233
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
234
|
+
return {"key": key, "value": {"intValue": str(value)}}
|
|
235
|
+
return {"key": key, "value": {"stringValue": str(value)}}
|
slowpoke/asgi.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""ASGI integration for FastAPI and Starlette:
|
|
2
|
+
|
|
3
|
+
from slowpoke.asgi import SlowpokeMiddleware
|
|
4
|
+
app.add_middleware(SlowpokeMiddleware) # add it last: it becomes the outermost middleware
|
|
5
|
+
|
|
6
|
+
Plain ASGI, not BaseHTTPMiddleware: the endpoint runs in the same task and context as the trace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import slowpoke
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SlowpokeMiddleware:
|
|
13
|
+
def __init__(self, app):
|
|
14
|
+
self.app = app
|
|
15
|
+
|
|
16
|
+
async def __call__(self, scope, receive, send):
|
|
17
|
+
tracer = slowpoke.get_tracer() if scope.get("type") == "http" else None
|
|
18
|
+
trace = tracer.start_request(scope.get("method", "GET")) if tracer is not None else None
|
|
19
|
+
if trace is None:
|
|
20
|
+
return await self.app(scope, receive, send)
|
|
21
|
+
|
|
22
|
+
path = scope.get("path", "/")
|
|
23
|
+
root_path = scope.get("root_path", "")
|
|
24
|
+
status = []
|
|
25
|
+
|
|
26
|
+
async def send_with_status(message):
|
|
27
|
+
if message.get("type") == "http.response.start" and not status:
|
|
28
|
+
status.append(message.get("status", 200))
|
|
29
|
+
await send(message)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
await self.app(scope, receive, send_with_status)
|
|
33
|
+
except BaseException:
|
|
34
|
+
_finish(tracer, trace, scope, root_path, path, 500)
|
|
35
|
+
raise
|
|
36
|
+
_finish(tracer, trace, scope, root_path, path, status[0] if status else 500)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _finish(tracer, trace, scope, root_path, path, status):
|
|
40
|
+
try:
|
|
41
|
+
route = route_template(scope, root_path)
|
|
42
|
+
except Exception:
|
|
43
|
+
route = None
|
|
44
|
+
tracer.finish_request(trace, route, path, status)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def route_template(scope, initial_root_path=""):
|
|
48
|
+
"""The template of the route that handled the request, read after routing: FastAPI leaves the route
|
|
49
|
+
in scope["route"]; plain Starlette only the endpoint, looked up in the app's routes."""
|
|
50
|
+
route = scope.get("route")
|
|
51
|
+
template = getattr(route, "path", None)
|
|
52
|
+
if isinstance(template, str):
|
|
53
|
+
# A mounted sub-application knows its routes without the mount prefix.
|
|
54
|
+
final_root = scope.get("root_path", "") or ""
|
|
55
|
+
prefix = final_root[len(initial_root_path):] if final_root.startswith(initial_root_path) else ""
|
|
56
|
+
if prefix and not template.startswith(prefix + "/"):
|
|
57
|
+
template = prefix.rstrip("/") + template
|
|
58
|
+
return template
|
|
59
|
+
endpoint = scope.get("endpoint")
|
|
60
|
+
app = scope.get("app")
|
|
61
|
+
if endpoint is None or app is None:
|
|
62
|
+
return None
|
|
63
|
+
return _find_endpoint(getattr(app, "routes", None) or [], endpoint, "", 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _find_endpoint(routes, endpoint, prefix, depth):
|
|
67
|
+
if depth > 8:
|
|
68
|
+
return None
|
|
69
|
+
for r in routes:
|
|
70
|
+
if getattr(r, "endpoint", None) is endpoint and isinstance(getattr(r, "path", None), str):
|
|
71
|
+
return prefix + r.path
|
|
72
|
+
children = getattr(r, "routes", None)
|
|
73
|
+
if children and isinstance(getattr(r, "path", None), str):
|
|
74
|
+
found = _find_endpoint(children, endpoint, prefix + r.path, depth + 1)
|
|
75
|
+
if found is not None:
|
|
76
|
+
return found
|
|
77
|
+
return None
|
slowpoke/celery.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Celery integration: one trace per task a worker runs.
|
|
2
|
+
|
|
3
|
+
# wherever the Celery app is created
|
|
4
|
+
import slowpoke.celery
|
|
5
|
+
|
|
6
|
+
slowpoke.celery.install()
|
|
7
|
+
|
|
8
|
+
A task dispatched from a request is not traced there: it is traced where it runs, in the worker.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import slowpoke
|
|
12
|
+
|
|
13
|
+
# Bounded on purpose: a worker that somehow never sees the end of its tasks must not grow a map
|
|
14
|
+
# forever. Past this many open tasks the next ones are simply not traced.
|
|
15
|
+
MAX_OPEN = 512
|
|
16
|
+
|
|
17
|
+
_open = {}
|
|
18
|
+
_state = {"installed": False}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install():
|
|
22
|
+
"""Connects the worker signals. Idempotent, and never raises: a monitoring package must not be
|
|
23
|
+
able to stop a worker from starting."""
|
|
24
|
+
try:
|
|
25
|
+
if _state["installed"]:
|
|
26
|
+
return True
|
|
27
|
+
from celery import signals
|
|
28
|
+
|
|
29
|
+
signals.task_prerun.connect(_prerun, dispatch_uid="slowpoke")
|
|
30
|
+
signals.task_postrun.connect(_postrun, dispatch_uid="slowpoke")
|
|
31
|
+
_state["installed"] = True
|
|
32
|
+
return True
|
|
33
|
+
except Exception:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prerun(task_id=None, task=None, **kwargs):
|
|
38
|
+
try:
|
|
39
|
+
tracer = slowpoke.get_tracer()
|
|
40
|
+
if tracer is None or task_id is None or len(_open) >= MAX_OPEN:
|
|
41
|
+
return
|
|
42
|
+
trace = tracer.start_job(getattr(task, "name", None) or "task", _queue(task))
|
|
43
|
+
if trace is not None:
|
|
44
|
+
_open[task_id] = trace
|
|
45
|
+
except Exception:
|
|
46
|
+
pass # never let observability break the worker
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _postrun(task_id=None, state=None, **kwargs):
|
|
50
|
+
try:
|
|
51
|
+
trace = _open.pop(task_id, None)
|
|
52
|
+
if trace is None:
|
|
53
|
+
return
|
|
54
|
+
tracer = slowpoke.get_tracer()
|
|
55
|
+
if tracer is not None:
|
|
56
|
+
# RETRY is this attempt giving up: to whoever is waiting for the work it is a failure.
|
|
57
|
+
tracer.finish_job(trace, failed=str(state) in ("FAILURE", "RETRY"))
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _queue(task):
|
|
63
|
+
try:
|
|
64
|
+
info = getattr(getattr(task, "request", None), "delivery_info", None) or {}
|
|
65
|
+
return info.get("routing_key") or None
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Django integration: add "slowpoke.django" to INSTALLED_APPS and
|
|
2
|
+
"slowpoke.django.SlowpokeMiddleware" at the top of MIDDLEWARE."""
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
9
|
+
from django.db import connections
|
|
10
|
+
from django.db.backends.signals import connection_created
|
|
11
|
+
|
|
12
|
+
import slowpoke
|
|
13
|
+
from slowpoke._tracer import current
|
|
14
|
+
|
|
15
|
+
_state = {"signal": False, "commands": False}
|
|
16
|
+
|
|
17
|
+
# Commands that never end: traced as one run they would hold a trace open for as long as the
|
|
18
|
+
# process lives, and the requests or tasks inside them would be lost. Names from Django itself and
|
|
19
|
+
# from the workers people usually run with manage.py.
|
|
20
|
+
LONG_RUNNING = (
|
|
21
|
+
"runserver", "runserver_plus", "testserver", "runworker", "shell", "dbshell", "test",
|
|
22
|
+
"qcluster", "rqworker", "rqscheduler", "process_tasks", "run_huey", "celery", "mail_queue",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def install():
|
|
27
|
+
"""Hooks every database connection, the ones already open and the ones opened later in other threads
|
|
28
|
+
or async contexts. Idempotent: a statement is never recorded twice."""
|
|
29
|
+
try:
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
|
|
32
|
+
base = getattr(settings, "BASE_DIR", None)
|
|
33
|
+
slowpoke.set_defaults(code_root=str(base) if base else None)
|
|
34
|
+
if not _state["signal"]:
|
|
35
|
+
connection_created.connect(_on_connection_created, dispatch_uid="slowpoke")
|
|
36
|
+
_state["signal"] = True
|
|
37
|
+
_wrap_open_connections()
|
|
38
|
+
_trace_commands()
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _trace_commands():
|
|
44
|
+
"""One trace per management command, which on a server means cron. SLOWPOKE_COMMANDS=false
|
|
45
|
+
turns it off; the commands that never end are left alone whatever it says."""
|
|
46
|
+
if _state["commands"] or os.environ.get("SLOWPOKE_COMMANDS", "").strip().lower() in ("0", "false", "no", "off"):
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
from django.core.management.base import BaseCommand
|
|
50
|
+
except Exception:
|
|
51
|
+
return
|
|
52
|
+
if getattr(BaseCommand.execute, "_slowpoke", False):
|
|
53
|
+
_state["commands"] = True
|
|
54
|
+
return
|
|
55
|
+
original = BaseCommand.execute
|
|
56
|
+
|
|
57
|
+
@functools.wraps(original)
|
|
58
|
+
def execute(self, *args, **options):
|
|
59
|
+
name = command_name(self)
|
|
60
|
+
if name in LONG_RUNNING:
|
|
61
|
+
return original(self, *args, **options)
|
|
62
|
+
with slowpoke.command(name):
|
|
63
|
+
return original(self, *args, **options)
|
|
64
|
+
|
|
65
|
+
execute._slowpoke = True
|
|
66
|
+
BaseCommand.execute = execute
|
|
67
|
+
_state["commands"] = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def command_name(cmd):
|
|
71
|
+
"""The name a person would type: "close_orders", from myapp.management.commands.close_orders."""
|
|
72
|
+
module = getattr(type(cmd), "__module__", "") or ""
|
|
73
|
+
return module.rsplit(".", 1)[-1] or type(cmd).__name__
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wrap_open_connections():
|
|
77
|
+
try:
|
|
78
|
+
for conn in connections.all(initialized_only=True):
|
|
79
|
+
_wrap(conn)
|
|
80
|
+
except TypeError: # before Django 4.1
|
|
81
|
+
for conn in connections.all():
|
|
82
|
+
_wrap(conn)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _on_connection_created(sender, connection, **kwargs):
|
|
86
|
+
_wrap(connection)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _wrap(conn):
|
|
90
|
+
wrappers = conn.execute_wrappers
|
|
91
|
+
if _execute_wrapper not in wrappers:
|
|
92
|
+
# First, not last: connection.execute_wrapper() pops the last one when its block ends.
|
|
93
|
+
wrappers.insert(0, _execute_wrapper)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _execute_wrapper(execute, sql, params, many, context):
|
|
97
|
+
if current() is None:
|
|
98
|
+
return execute(sql, params, many, context)
|
|
99
|
+
started = time.perf_counter()
|
|
100
|
+
try:
|
|
101
|
+
return execute(sql, params, many, context)
|
|
102
|
+
finally:
|
|
103
|
+
seconds = time.perf_counter() - started
|
|
104
|
+
tracer = slowpoke.get_tracer()
|
|
105
|
+
if tracer is not None:
|
|
106
|
+
try:
|
|
107
|
+
vendor = context["connection"].vendor
|
|
108
|
+
except Exception:
|
|
109
|
+
vendor = "unknown"
|
|
110
|
+
# sql only, never params: Django passes %s placeholders to the driver
|
|
111
|
+
tracer.record_query(sql, seconds, vendor)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SlowpokeMiddleware:
|
|
115
|
+
"""Opens one trace per request. Put it first in MIDDLEWARE so the timing covers the others."""
|
|
116
|
+
|
|
117
|
+
sync_capable = True
|
|
118
|
+
async_capable = True
|
|
119
|
+
|
|
120
|
+
def __init__(self, get_response):
|
|
121
|
+
self.get_response = get_response
|
|
122
|
+
self.is_async = iscoroutinefunction(get_response)
|
|
123
|
+
if self.is_async:
|
|
124
|
+
markcoroutinefunction(self)
|
|
125
|
+
install()
|
|
126
|
+
|
|
127
|
+
def __call__(self, request):
|
|
128
|
+
if self.is_async:
|
|
129
|
+
return self.__acall__(request)
|
|
130
|
+
tracer, trace = _start(request)
|
|
131
|
+
try:
|
|
132
|
+
response = self.get_response(request)
|
|
133
|
+
except BaseException:
|
|
134
|
+
_finish(tracer, trace, request, 500)
|
|
135
|
+
raise
|
|
136
|
+
_finish(tracer, trace, request, getattr(response, "status_code", 200))
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
async def __acall__(self, request):
|
|
140
|
+
tracer, trace = _start(request)
|
|
141
|
+
try:
|
|
142
|
+
response = await self.get_response(request)
|
|
143
|
+
except BaseException:
|
|
144
|
+
_finish(tracer, trace, request, 500)
|
|
145
|
+
raise
|
|
146
|
+
_finish(tracer, trace, request, getattr(response, "status_code", 200))
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _start(request):
|
|
151
|
+
tracer = slowpoke.get_tracer()
|
|
152
|
+
if tracer is None:
|
|
153
|
+
return None, None
|
|
154
|
+
try:
|
|
155
|
+
_wrap_open_connections()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return tracer, tracer.start_request(request.method)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _finish(tracer, trace, request, status):
|
|
162
|
+
if trace is None:
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
match = getattr(request, "resolver_match", None)
|
|
166
|
+
route = route_template(match.route) if match is not None and getattr(match, "route", None) else None
|
|
167
|
+
path = request.path
|
|
168
|
+
except Exception:
|
|
169
|
+
route, path = None, "/"
|
|
170
|
+
tracer.finish_request(trace, route, path, status)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def route_template(route):
|
|
174
|
+
"""path() routes already read like templates ("orders/<int:pk>/", the agent normalizes them);
|
|
175
|
+
re_path() gives a regular expression, turned here into "legacy/{slug}/"."""
|
|
176
|
+
if not route or ("(" not in route and not route.startswith("^") and not route.endswith("$")):
|
|
177
|
+
return route or ""
|
|
178
|
+
if route.startswith("^"):
|
|
179
|
+
route = route[1:]
|
|
180
|
+
for end in ("\\Z", "$"):
|
|
181
|
+
if route.endswith(end) and not route.endswith("\\" + end):
|
|
182
|
+
route = route[: -len(end)]
|
|
183
|
+
break
|
|
184
|
+
out = []
|
|
185
|
+
i, n = 0, len(route)
|
|
186
|
+
while i < n:
|
|
187
|
+
c = route[i]
|
|
188
|
+
if c == "\\" and i + 1 < n:
|
|
189
|
+
out.append(route[i + 1])
|
|
190
|
+
i += 2
|
|
191
|
+
elif c == "(":
|
|
192
|
+
name = "param"
|
|
193
|
+
if route.startswith("(?P<", i):
|
|
194
|
+
close = route.find(">", i)
|
|
195
|
+
if close > 0:
|
|
196
|
+
name = route[i + 4:close]
|
|
197
|
+
i = _group_end(route, i)
|
|
198
|
+
while i < n and route[i] in "?*+":
|
|
199
|
+
i += 1
|
|
200
|
+
out.append("{%s}" % name)
|
|
201
|
+
else:
|
|
202
|
+
out.append(c)
|
|
203
|
+
i += 1
|
|
204
|
+
return "".join(out)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _group_end(pattern, i):
|
|
208
|
+
"""Index after the group that opens at pattern[i], skipping escapes and character classes."""
|
|
209
|
+
depth, in_class, n = 0, False, len(pattern)
|
|
210
|
+
while i < n:
|
|
211
|
+
c = pattern[i]
|
|
212
|
+
if c == "\\":
|
|
213
|
+
i += 2
|
|
214
|
+
continue
|
|
215
|
+
if in_class:
|
|
216
|
+
in_class = c != "]"
|
|
217
|
+
elif c == "[":
|
|
218
|
+
in_class = True
|
|
219
|
+
elif c == "(":
|
|
220
|
+
depth += 1
|
|
221
|
+
elif c == ")":
|
|
222
|
+
depth -= 1
|
|
223
|
+
if depth == 0:
|
|
224
|
+
return i + 1
|
|
225
|
+
i += 1
|
|
226
|
+
return n
|
slowpoke/django/apps.py
ADDED
slowpoke/flask.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Flask integration (2.x and 3.x):
|
|
2
|
+
|
|
3
|
+
import slowpoke.flask, slowpoke.sqlalchemy
|
|
4
|
+
slowpoke.flask.init_app(app)
|
|
5
|
+
slowpoke.sqlalchemy.instrument(db.engine) # or instrument() for every engine
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from flask import request
|
|
9
|
+
|
|
10
|
+
import slowpoke
|
|
11
|
+
|
|
12
|
+
_KEY = "slowpoke.trace"
|
|
13
|
+
_STATUS = "slowpoke.status"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_app(app):
|
|
17
|
+
"""Idempotent. The trace opens before every other before_request hook, so a hook that answers
|
|
18
|
+
early (authentication, rate limits) is traced too."""
|
|
19
|
+
before = app.before_request_funcs.setdefault(None, [])
|
|
20
|
+
if _before not in before:
|
|
21
|
+
before.insert(0, _before)
|
|
22
|
+
if _after not in app.after_request_funcs.setdefault(None, []):
|
|
23
|
+
app.after_request(_after)
|
|
24
|
+
if _teardown not in app.teardown_request_funcs.setdefault(None, []):
|
|
25
|
+
app.teardown_request(_teardown)
|
|
26
|
+
return app
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _before():
|
|
30
|
+
try:
|
|
31
|
+
tracer = slowpoke.get_tracer()
|
|
32
|
+
if tracer is not None:
|
|
33
|
+
request.environ[_KEY] = (tracer, tracer.start_request(request.method))
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _after(response):
|
|
39
|
+
try:
|
|
40
|
+
request.environ[_STATUS] = response.status_code
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _teardown(exc):
|
|
47
|
+
try:
|
|
48
|
+
tracer, trace = request.environ.pop(_KEY, (None, None))
|
|
49
|
+
if trace is None:
|
|
50
|
+
return
|
|
51
|
+
status = request.environ.get(_STATUS) or (500 if exc is not None else 200)
|
|
52
|
+
rule = request.url_rule
|
|
53
|
+
tracer.finish_request(trace, rule.rule if rule is not None else None, request.path, status)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|